feat: reorder games from settings

The games chip row becomes a row per game, because a reorder affordance needs
somewhere to put a handle and two arrows, and at fourteen games a list reads
better than a wrapped row of pills anyway.

Reordering lives here and nowhere else. The focus bar and the dailies strip are
the fastest tap targets in the app, and a drag target sitting on top of a tick
target costs somebody a streak the first time it misfires — so the live surfaces
stay drag-free and this is the screen you visit on purpose.

Both affordances ship. Touch fires no drag events at all, so the arrows are the
mechanism and the handle is the pointer fast path; being ordinary buttons is
also what makes the whole thing reachable by keyboard and screen reader without
a second implementation of the same interaction. An arrow at either end is
disabled rather than removed, because a control that disappears on the first row
slides the other one under the finger aiming at it.

A move writes back the whole displayed list: the indices are positions on
screen, so applying them to a stored order that names only some lanes would move
the wrong game. Reset writes the field away rather than storing an empty order.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:01:56 +02:00
co-authored by Claude Opus 5
parent 721c4c90fb
commit 74c1f74e87
2 changed files with 218 additions and 25 deletions
+37
View File
@@ -133,3 +133,40 @@ describe("Controls: what am I allowed to look at", () => {
expect(render(PREFS, 3)).toContain("Show the 3 events I&#x27;m");
});
});
describe("Controls: the game order editor", () => {
const html = () => render(PREFS);
test("both affordances ship, because touch fires no drag events", () => {
// The arrows are the mechanism and the handle is the pointer fast path. A
// drag-only list is unreachable on a phone and by keyboard alike.
expect(html()).toContain("draggable");
expect(html()).toContain('aria-label="Move Genshin Impact up (1 of 2)"');
expect(html()).toContain('aria-label="Move Honkai: Star Rail down (2 of 2)"');
});
test("an arrow at the end is disabled, not missing", () => {
// A control that disappears on the first row slides the other one under the
// finger aiming at it.
const markup = html();
// The whole tag: `disabled` is serialised before `aria-label`.
const tag = (label: string) =>
new RegExp(`<button[^>]*aria-label="${label}"[^>]*>`).exec(markup)?.[0] ?? "";
expect(tag("Move Genshin Impact up \\(1 of 2\\)")).toContain("disabled");
expect(tag("Move Genshin Impact down \\(1 of 2\\)")).not.toContain("disabled");
expect(tag("Move Honkai: Star Rail down \\(2 of 2\\)")).toContain("disabled");
});
test("the row names the game in full, and still toggles it", () => {
// Rows have room for the real name where the chips only had `short`.
expect(html()).toContain("Honkai: Star Rail");
expect(html()).toContain('aria-pressed="true"');
});
test("reset appears only once the reader has an order to reset", () => {
expect(html()).not.toContain("Reset to AZ");
expect(render({ ...PREFS, gameOrder: ["hsr", "genshin"] })).toContain(
"Reset to AZ",
);
});
});