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
@@ -101,17 +101,6 @@ describe("ModelTags reordering", () => {
firePointer('pointerup', target, { clientY: 999 });
}
function pressKey(target, key, init = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
document.body.innerHTML = TAG_SECTION_HTML(tags);
setupTagEditMode('loras');
@@ -170,18 +159,11 @@ describe("ModelTags reordering", () => {
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
});
it("saves the new order after a keyboard reorder", async () => {
it("saves the new order after a drag", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(order()).toEqual(['beta', 'alpha', 'gamma']);
const liveRegion = section().querySelector('.reorder-live-region');
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
expect(liveRegion.textContent).toBe('Moved to position 2 of 3');
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder beta, position 1 of 3');
dragToEnd(handles()[0]);
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
document.querySelector('.save-tags-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
@@ -191,19 +173,10 @@ describe("ModelTags reordering", () => {
});
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
tags: ['beta', 'alpha', 'gamma'],
tags: ['beta', 'gamma', 'alpha'],
});
});
it("swallows the reorder shortcut at the ends of the list", async () => {
await enterEditMode();
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
expect(event.defaultPrevented).toBe(true);
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
});
it("updates the sortable flag when tags are deleted", async () => {
await enterEditMode(['alpha', 'beta']);
@@ -8,14 +8,12 @@ const POINTER_SORT_MODULE = new URL(
describe("pointerSort", () => {
let enablePointerSort;
let disablePointerSort;
let moveItemWithinContainer;
beforeEach(async () => {
document.body.innerHTML = '';
const module = await import(POINTER_SORT_MODULE);
enablePointerSort = module.enablePointerSort;
disablePointerSort = module.disablePointerSort;
moveItemWithinContainer = module.moveItemWithinContainer;
});
function buildList(words) {
@@ -172,25 +170,4 @@ describe("pointerSort", () => {
expect(order()).toEqual(['a', 'b']);
});
it("moveItemWithinContainer moves items within bounds only", () => {
const { container } = buildList(['a', 'b', 'c']);
const items = Array.from(document.querySelectorAll('.item'));
expect(moveItemWithinContainer(items[0], 1, sortOptions()))
.toEqual({ index: 1, total: 3 });
expect(order()).toEqual(['b', 'a', 'c']);
expect(moveItemWithinContainer(items[2], -1, sortOptions()))
.toEqual({ index: 1, total: 3 });
expect(order()).toEqual(['b', 'c', 'a']);
// Out of range / unknown item moves are refused
const currentFirst = document.querySelector('.item');
expect(moveItemWithinContainer(currentFirst, -1, sortOptions())).toBeNull();
expect(moveItemWithinContainer(currentFirst, 3, sortOptions())).toBeNull();
expect(moveItemWithinContainer(document.createElement('div'), 1, {
...sortOptions(),
container,
})).toBeNull();
});
});
@@ -77,17 +77,6 @@ describe("TriggerWords reordering", () => {
firePointer('pointerup', handle, { clientY: 999 });
}
function pressKey(handle, key, init = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
handle.dispatchEvent(event);
return event;
}
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
setupTriggerWordsEditMode();
@@ -114,6 +103,16 @@ describe("TriggerWords reordering", () => {
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
it("keeps the grip a decorative drag affordance, not a keyboard control", async () => {
await enterEditMode();
const grip = handles()[0];
expect(grip.tagName).toBe('SPAN');
expect(grip.getAttribute('aria-hidden')).toBe('true');
expect(grip.hasAttribute('tabindex')).toBe(false);
expect(grip.getAttribute('title')).toBe('Drag to reorder');
});
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
await enterEditMode();
@@ -144,13 +143,23 @@ describe("TriggerWords reordering", () => {
expect(document.querySelector('.reorder-dragging')).toBeNull();
});
it("reorders with the keyboard and saves the new order", async () => {
it("treats a click on the grip without movement as a click, not a drag", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(order()).toEqual(["beta", "alpha", "gamma"]);
const grip = handles()[0];
firePointer('pointerdown', grip, { clientY: 10 });
firePointer('pointermove', grip, { clientY: 12 });
firePointer('pointerup', grip, { clientY: 12 });
grip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
pressKey(handles()[2], 'ArrowUp', { altKey: true });
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("saves the new order after a drag", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
document.querySelector('.metadata-save-btn')
@@ -165,49 +174,6 @@ describe("TriggerWords reordering", () => {
});
});
it("swallows the reorder shortcut at the ends of the list", async () => {
await enterEditMode();
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
expect(event.defaultPrevented).toBe(true);
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("ignores the reorder shortcut without the Alt modifier", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight');
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("announces moved words for screen readers", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
const liveRegion = section().querySelector('.reorder-live-region');
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
expect(liveRegion.textContent).toBe(
'Moved to position 2 of 3',
);
});
it("updates handle labels with the current position", async () => {
await enterEditMode();
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder alpha, position 1 of 3');
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder beta, position 1 of 3');
expect(handles()[1].getAttribute('aria-label'))
.toBe('Reorder alpha, position 2 of 3');
});
it("restores the original order when edit mode is canceled", async () => {
await enterEditMode();