refactor(reorder): drop the Alt + Arrow shortcut, keep drag only

The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is
the browser's tab-history / back-forward gesture on several platforms,
and the modal already binds bare `ArrowLeft`/`ArrowRight` to model
navigation, so the binding either did nothing — a keypress with nothing
focused never reaches a listener on the tag list — or fought the browser.
An affordance that occasionally navigates the page away is worse than
having no keyboard path at all, so drop it.

Reordering is pointer-only again: drag the chip (tags) or its `⠿` grip
(trigger words, whose chip body is click-to-edit). Everything that existed
only to serve the shortcut goes with it — the keydown listener, the hover
tracking used to resolve the target chip, the aria-live announcements, the
per-grip position labels and `moveItemWithinContainer`. The grip becomes a
decorative, non-focusable `<span>` (`aria-hidden`, behind a 5px drag
threshold) instead of a `<button>`, so it no longer promises a keyboard
action it cannot perform.

The tooltip and hint drop the shortcut mention in all 10 locales
(`common.reorder.dragHandle` = "Drag to reorder" and the localised
equivalents); `common.reorder.ariaLabel` and `common.reorder.announcement`
are pruned from every locale by the sync script. The i18n guidelines
record the decision so no shortcut is re-added without re-adding the keys.
This commit is contained in:
Will Miao
2026-09-17 09:07:24 +08:00
parent f67689b0f9
commit e09fe5888b
19 changed files with 116 additions and 405 deletions
+23 -118
View File
@@ -1,38 +1,35 @@
/**
* reorderSupport.js
* Shared keyboard + screen-reader layer for chip lists sorted with pointerSort.
* Shared drag affordance for chip lists sorted with pointerSort.
*
* Drag gestures are handled by pointerSort.js; this module adds the parts every
* sortable list needs on top of it:
* - the `⠿` grip affordance (markup + labels),
* - the "sortable" flag that reveals the grip only when reordering is possible,
* - Alt + Arrow keyboard reordering with aria-live announcements.
* The drag gesture itself lives in pointerSort.js; this module owns the parts
* every sortable list needs on top of it:
* - the `⠿` grip markup,
* - the "sortable" flag that reveals the grip only when reordering is possible.
*
* Convention used by both callers: a list always shows the grip while it is
* sortable. Whether the item *body* is draggable as well depends on the item:
* - body has no click action (model/recipe tags) -> whole item is draggable,
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
*
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
* navigation, so the grip is a plain decorative affordance rather than a
* focusable control.
*/
import { translate } from '../../utils/i18nHelpers.js';
import { escapeAttribute, escapeHtml } from './utils.js';
import { moveItemWithinContainer } from './pointerSort.js';
const SORTABLE_CLASS = 'has-sortable-words';
const LIVE_REGION_CLASS = 'reorder-live-region';
const SR_ONLY_CLASS = 'reorder-sr-only';
const DEFAULT_HANDLE_SELECTOR = '.reorder-handle';
const DEFAULT_ARIA_LABEL_KEY = 'common.reorder.ariaLabel';
const DEFAULT_ANNOUNCEMENT_KEY = 'common.reorder.announcement';
/**
* Render the shared reorder grip button
* @param {string} label - Tooltip / accessible label
* Render the shared reorder grip
* @param {string} label - Tooltip text
* @returns {string} Handle markup
*/
export function renderReorderHandle(label) {
const safeLabel = escapeAttribute(label || '');
return `<button type="button" class="reorder-handle" title="${safeLabel}" aria-label="${safeLabel}"><i class="fas fa-grip-vertical"></i></button>`;
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
}
/**
@@ -45,115 +42,23 @@ export function renderReorderHint(label) {
}
/**
* Map a keydown event to a reorder offset
* @param {KeyboardEvent} event - Keydown event
* @returns {number} -1 (earlier), 1 (later) or 0 when it is not a reorder shortcut
*/
function getReorderOffset(event) {
if (!event.altKey || event.ctrlKey || event.metaKey) return 0;
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') return -1;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') return 1;
return 0;
}
/**
* Create the keyboard / label support of a sortable list
* Show or hide the grip and hint of a list.
* They are only offered while the list is editable and holds more than one
* item, so the UI never shows an affordance that cannot do anything.
* @param {Object} options - Options
* @param {HTMLElement} options.container - Element holding the items
* @param {HTMLElement} options.container - Element holding the sortable items
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
* @param {string} [options.handleSelector] - Grip selector inside an item
* @param {Object} options.sortConfig - Same config passed to enablePointerSort()
* @param {string} options.itemSelector - Selector of the sortable items
* @param {Function} [options.isActive] - Whether reordering is currently allowed
* @param {Function} [options.getItemLabel] - (item) => label used in messages
* @param {Object} [options.i18n] - { ariaLabel, announcement } translation keys
* @returns {{refresh: Function, announce: Function, handleKeydown: Function}}
*/
export function createReorderSupport({
export function refreshReorderState({
container,
scope = container,
handleSelector = DEFAULT_HANDLE_SELECTOR,
sortConfig,
itemSelector,
isActive = () => true,
getItemLabel = (item) => item.dataset.word || item.dataset.tag || item.textContent.trim(),
i18n = {},
}) {
const itemSelector = sortConfig.itemSelector;
const ariaLabelKey = i18n.ariaLabel || DEFAULT_ARIA_LABEL_KEY;
const announcementKey = i18n.announcement || DEFAULT_ANNOUNCEMENT_KEY;
if (!container) return;
const getItems = () => Array.from(container.querySelectorAll(itemSelector));
function refresh() {
const items = getItems();
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
items.forEach((item, index) => {
const handle = item.querySelector(handleSelector);
if (!handle) return;
const label = getItemLabel(item);
handle.setAttribute('aria-label', translate(
ariaLabelKey,
{ item: label, position: index + 1, total: items.length },
`Reorder ${label}, position ${index + 1} of ${items.length}`,
));
});
}
function ensureLiveRegion() {
let liveRegion = scope.querySelector(`.${LIVE_REGION_CLASS}`);
if (liveRegion) return liveRegion;
liveRegion = document.createElement('div');
liveRegion.className = `${LIVE_REGION_CLASS} ${SR_ONLY_CLASS}`;
liveRegion.setAttribute('role', 'status');
liveRegion.setAttribute('aria-live', 'polite');
scope.appendChild(liveRegion);
return liveRegion;
}
function announce(item) {
const items = getItems();
const index = items.indexOf(item);
if (index === -1) return;
ensureLiveRegion().textContent = translate(
announcementKey,
{ position: index + 1, total: items.length },
`Moved to position ${index + 1} of ${items.length}`,
);
}
function handleKeydown(event) {
const handle = event.target.closest(handleSelector);
if (!handle || !isActive()) return;
const offset = getReorderOffset(event);
if (!offset) return;
// Swallow the shortcut even at the ends of the list: Alt + Left/Right
// would otherwise trigger the browser's back/forward navigation.
event.preventDefault();
event.stopPropagation();
const item = handle.closest(itemSelector);
if (!item) return;
const result = moveItemWithinContainer(item, offset, sortConfig);
if (!result) return;
refresh();
announce(item);
const nextHandle = item.querySelector(handleSelector);
if (nextHandle) nextHandle.focus();
}
if (!container.__reorderKeyboardAttached) {
container.__reorderKeyboardAttached = true;
container.addEventListener('keydown', handleKeydown);
}
return { refresh, announce, handleKeydown };
const items = container.querySelectorAll(itemSelector);
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
}