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
+181 -25
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { REGION_LABEL } from "../../shared/time.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
import { moveGame } from "../state/gameOrder.ts";
import type { ThemeChoice } from "../state/theme.ts";
import type { Prefs } from "../state/usePrefs.ts";
import { YourOwn } from "./YourOwn.tsx";
@@ -72,31 +74,19 @@ export function Controls({
you came to change. */}
<div className="lg:grid lg:grid-cols-2 lg:gap-x-10">
<div>
<p className="eyebrow">Games</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const on = !prefs.hiddenGames.includes(id);
return (
<button
key={id}
type="button"
onClick={() => onToggleGame(id)}
aria-pressed={on}
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: on ? game.hue : "var(--color-hairline)",
color: on ? game.hue : "var(--color-faint)",
background: on
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
: "transparent",
}}
>
{game.short}
</button>
);
})}
</div>
<GameOrder
games={games}
hidden={prefs.hiddenGames}
custom={prefs.gameOrder !== undefined}
onToggleGame={onToggleGame}
onReorder={(from, to) =>
// The whole displayed list, every time: the indices are positions
// on screen, and what the reader is looking at is the order they
// mean. See `moveGame`.
onUpdate({ gameOrder: moveGame(games, from, to) })
}
onReset={() => onUpdate({ gameOrder: undefined })}
/>
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
@@ -290,3 +280,169 @@ export function Controls({
</section>
);
}
/**
* Which games the reader plays, and the order they read them in.
*
* One row per game rather than the chip row this replaced: 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 on the page — the strip is the part of it that is
* answerable in ten seconds — 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.
*
* The row itself is not a target. The handle, the two arrows and the on/off
* switch are four explicit controls, and nothing else here is clickable — which
* is what keeps this the right side of "a list row is one target": that rule is
* about a full-bleed row target with a second control hidden inside it.
*/
function GameOrder({
games,
hidden,
custom,
onToggleGame,
onReorder,
onReset,
}: {
/** Every lane, already in the reader's order. */
games: LaneId[];
hidden: LaneId[];
/** Whether the reader has an order of their own, so reset has something to do. */
custom: boolean;
onToggleGame: (g: LaneId) => void;
onReorder: (from: number, to: number) => void;
onReset: () => void;
}) {
const gameMeta = useGameMeta();
const [dragging, setDragging] = useState<number | null>(null);
return (
<>
<div className="flex items-baseline justify-between gap-3">
<p className="eyebrow">Games</p>
{custom && (
<button
type="button"
onClick={onReset}
className="text-[0.6875rem] text-faint transition-colors hover:text-muted"
>
Reset to AZ
</button>
)}
</div>
{/* Both affordances, always visible. Touch fires no drag events at all, so
the arrows are the mechanism and the handle is the fast path where a
pointer exists — and the arrows are ordinary buttons, which is what
makes this reachable by keyboard and screen reader without a second
implementation of the same interaction. */}
<p className="mt-1 text-[0.6875rem] leading-relaxed text-faint">
Drag a row, or use the arrows, to put your games in order. Everything
that lists a game follows it.
</p>
<ul className="mt-2">
{games.map((id, i) => {
const game = gameMeta(id);
const on = !hidden.includes(id);
return (
<li
key={id}
draggable
onDragStart={(e) => {
setDragging(i);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragging !== null && dragging !== i) onReorder(dragging, i);
setDragging(null);
}}
onDragEnd={() => setDragging(null)}
className={`flex items-center gap-2 rounded-lg py-1 ${
dragging === i ? "opacity-40" : ""
}`}
>
<span
aria-hidden
title="Drag to reorder"
className="cursor-grab select-none px-0.5 text-xs leading-none text-faint"
>
</span>
<span className="tnum w-4 shrink-0 text-[0.625rem] text-faint">
{i + 1}
</span>
<button
type="button"
onClick={() => onToggleGame(id)}
aria-pressed={on}
className="flex min-w-0 flex-1 items-center gap-2 rounded-full border px-3 py-1.5 text-left text-xs font-medium transition-colors"
style={{
borderColor: on ? game.hue : "var(--color-hairline)",
color: on ? game.hue : "var(--color-faint)",
background: on
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
: "transparent",
}}
>
<span className="truncate">{game.name}</span>
</button>
{/* Rendered at the ends too, and inert there. A control that
disappears on the first row slides the other one under the
finger that was aiming at it. */}
<Nudge
label={`Move ${game.name} up (${i + 1} of ${games.length})`}
disabled={i === 0}
onClick={() => onReorder(i, i - 1)}
d="M8 3.5l4.5 5h-9z"
/>
<Nudge
label={`Move ${game.name} down (${i + 1} of ${games.length})`}
disabled={i === games.length - 1}
onClick={() => onReorder(i, i + 1)}
d="M8 12.5l-4.5-5h9z"
/>
</li>
);
})}
</ul>
</>
);
}
/** One arrow. Disabled at the ends rather than removed — see above. */
function Nudge({
label,
disabled,
onClick,
d,
}: {
label: string;
disabled: boolean;
onClick: () => void;
d: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={`grid size-6 shrink-0 place-items-center rounded-md border border-hairline transition-colors ${
disabled
? "cursor-not-allowed opacity-25"
: "text-muted hover:border-faint hover:text-ink"
}`}
>
<svg viewBox="0 0 16 16" aria-hidden className="size-2.5">
<path d={d} fill="currentColor" />
</svg>
</button>
);
}
+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",
);
});
});