fix(design): claim audit part 1 — a closing key no longer re-presses its trigger, the Switch box and name, named z-levels (E28)
- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper, Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default. - Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does. - Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and the Plex two-way-sync switch had no name. - z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract. - The /watch file card's Download is the filled primary: the page's one action. - Stale comments: the 1024x768 floor, the retired glass tuner.
This commit is contained in:
@@ -99,7 +99,7 @@ export function Carousel({
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
// Round, dark translucent: they sit over the pictures (A2).
|
||||
const arrowBtn =
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-base backdrop-blur-xs border border-white/15 shadow-lg " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
|
||||
|
||||
return (
|
||||
|
||||
@@ -174,10 +174,10 @@ export function PriorityStepper({
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
editFocus.arm();
|
||||
editFocus.arm(e);
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
editFocus.arm();
|
||||
editFocus.arm(e);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 audit: the Switch was a bare 36×20 track — under the 24 floor on a mouse, never the 44 touch box
|
||||
// DESIGN.md A3 lists it under, and its name was optional, so the Plex-sync switch shipped nameless.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { Switch } from "./form";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Switch", () => {
|
||||
it("is a named switch whose own box meets the 24 floor and grows to the touch target", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Switch label="Dark mode" checked={false} onChange={onChange} />);
|
||||
const sw = screen.getByRole("switch", { name: "Dark mode" });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
expect(sw.className).toContain("h-6");
|
||||
expect(sw.className).toContain("pointer-coarse:min-h-(--touch-target)");
|
||||
fireEvent.click(sw);
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("cannot be rendered without a name", () => {
|
||||
// @ts-expect-error — `label` is required: a switch has no text of its own to be named by.
|
||||
render(<Switch checked onChange={() => {}} />);
|
||||
expect(screen.getByRole("switch")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,10 @@ import { SURFACE } from "./surfaces";
|
||||
// Shared form/settings primitives, factored out of the many panels that each re-declared
|
||||
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
|
||||
|
||||
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
|
||||
* the accessible name (the visible row text) since the control itself has no inner text. */
|
||||
/** Pill on/off switch — the bare control; compose it with your own label/row. `label` is the
|
||||
* accessible name (the visible row text), required: the control has no inner text to be named by.
|
||||
* The button is its own box around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44
|
||||
* touch target on a coarse pointer (A3), a real box rather than an invisible hit area. */
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
@@ -16,7 +18,7 @@ export function Switch({
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
|
||||
@@ -29,13 +31,17 @@ export function Switch({
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`}
|
||||
className="group inline-flex h-6 min-w-9 shrink-0 items-center justify-center rounded-full disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-(--touch-target) pointer-coarse:min-w-(--touch-target)"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${
|
||||
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white"
|
||||
}`}
|
||||
/>
|
||||
className={`relative h-5 w-9 rounded-full transition ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${
|
||||
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</FocusSafeButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// VirtualGrid's column-count policy, extracted as a pure function so the viewport contract's UPPER
|
||||
// end is unit-testable without a layout engine (the lower end, the 1024x768 floor, needs no policy —
|
||||
// end is unit-testable without a layout engine (the lower end, the 768-wide tablet floor, needs no policy —
|
||||
// the grid simply drops to the columns that fit).
|
||||
//
|
||||
// An auto-fill grid answers "more room" with more columns, never bigger ones, so it always settles
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 audit: an in-place editor closed by a REAL Enter re-opened at once. The keydown commits and hands
|
||||
// the focus back to the trigger button; the same keystroke's default action (the keypress activation)
|
||||
// then lands on that button and clicks it. A synthetic keydown has no default action, so the first
|
||||
// test passed while the live rename was broken. Arming now cancels the key's default action.
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { useReturnFocus } from "./useReturnFocus";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Editor() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const focus = useReturnFocus(open);
|
||||
return open ? (
|
||||
<input
|
||||
aria-label="name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
focus.arm(e);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button ref={focus.target} onClick={() => setOpen(true)}>
|
||||
Rename
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useReturnFocus", () => {
|
||||
it("returns the focus to the trigger, and the closing key does not press it again", () => {
|
||||
render(<Editor />);
|
||||
for (const key of ["Enter", "Escape"]) {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename" }));
|
||||
// fireEvent returns false when a handler cancelled the event's default action.
|
||||
expect(fireEvent.keyDown(screen.getByRole("textbox", { name: "name" }), { key })).toBe(false);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Rename" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,13 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
// and back. Leaving it by Enter or Escape unmounts the focused field, and the browser drops the focus
|
||||
// on <body>: the next Tab starts again at the top of the page (WCAG 2.4.3). This hands the focus back
|
||||
// to the trigger once the editor has CLOSED — after the commit, so the trigger is mounted and visible
|
||||
// again whether it stayed in the tree or was swapped out. `arm()` it from the keys only: leaving by a
|
||||
// again whether it stayed in the tree or was swapped out. `arm(e)` it from the keys only: leaving by a
|
||||
// click elsewhere means the user already put the focus where they wanted it.
|
||||
//
|
||||
// `arm` takes the key event and cancels its default action. The focus lands on the trigger BEFORE the
|
||||
// same keystroke has finished: a real Enter's keypress then activates the trigger button and re-opens
|
||||
// the editor it just closed (the playlist rename did exactly that; a synthetic keydown in a test has no
|
||||
// default action, so only a real key showed it).
|
||||
export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open: boolean) {
|
||||
const target = useRef<T | null>(null);
|
||||
const armed = useRef(false);
|
||||
@@ -14,7 +19,8 @@ export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open:
|
||||
armed.current = false;
|
||||
target.current?.focus();
|
||||
}, [open]);
|
||||
const arm = useCallback(() => {
|
||||
const arm = useCallback((e: { preventDefault(): void }) => {
|
||||
e.preventDefault();
|
||||
armed.current = true;
|
||||
}, []);
|
||||
return { target, arm };
|
||||
|
||||
Reference in New Issue
Block a user