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:
co-authored by
Claude Opus 5
parent
721c4c90fb
commit
74c1f74e87
@@ -1,7 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
import type { LaneId } from "../../shared/custom.ts";
|
||||||
import type { Region } from "../../shared/schema.ts";
|
import type { Region } from "../../shared/schema.ts";
|
||||||
import { REGION_LABEL } from "../../shared/time.ts";
|
import { REGION_LABEL } from "../../shared/time.ts";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
|
import { moveGame } from "../state/gameOrder.ts";
|
||||||
import type { ThemeChoice } from "../state/theme.ts";
|
import type { ThemeChoice } from "../state/theme.ts";
|
||||||
import type { Prefs } from "../state/usePrefs.ts";
|
import type { Prefs } from "../state/usePrefs.ts";
|
||||||
import { YourOwn } from "./YourOwn.tsx";
|
import { YourOwn } from "./YourOwn.tsx";
|
||||||
@@ -72,31 +74,19 @@ export function Controls({
|
|||||||
you came to change. */}
|
you came to change. */}
|
||||||
<div className="lg:grid lg:grid-cols-2 lg:gap-x-10">
|
<div className="lg:grid lg:grid-cols-2 lg:gap-x-10">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Games</p>
|
<GameOrder
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
games={games}
|
||||||
{games.map((id) => {
|
hidden={prefs.hiddenGames}
|
||||||
const game = gameMeta(id);
|
custom={prefs.gameOrder !== undefined}
|
||||||
const on = !prefs.hiddenGames.includes(id);
|
onToggleGame={onToggleGame}
|
||||||
return (
|
onReorder={(from, to) =>
|
||||||
<button
|
// The whole displayed list, every time: the indices are positions
|
||||||
key={id}
|
// on screen, and what the reader is looking at is the order they
|
||||||
type="button"
|
// mean. See `moveGame`.
|
||||||
onClick={() => onToggleGame(id)}
|
onUpdate({ gameOrder: moveGame(games, from, to) })
|
||||||
aria-pressed={on}
|
}
|
||||||
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
onReset={() => onUpdate({ gameOrder: undefined })}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -290,3 +280,169 @@ export function Controls({
|
|||||||
</section>
|
</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 A–Z
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,3 +133,40 @@ describe("Controls: what am I allowed to look at", () => {
|
|||||||
expect(render(PREFS, 3)).toContain("Show the 3 events I'm");
|
expect(render(PREFS, 3)).toContain("Show the 3 events I'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 A–Z");
|
||||||
|
expect(render({ ...PREFS, gameOrder: ["hsr", "genshin"] })).toContain(
|
||||||
|
"Reset to A–Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user