admin: enable toggles become rac switches

the upstream, blocklist source, and safe search enable controls mutate the row the moment they move, so they now carry the switch role via a shared drawn rac switch with a 44px hit area, a focus-visible ring, and naming modes made exclusive by a discriminated union. safe search supersedes its one-commit-old checkbox form, and the file-authority guard now names the switch role instead of relying on the bare input selector.
This commit is contained in:
2026-08-29 12:40:01 +02:00
parent f1de80477a
commit 207252acee
7 changed files with 254 additions and 20 deletions
+91
View File
@@ -0,0 +1,91 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Switch from "./Switch";
/**
* The drawn track, which is the label's one span that does not hold the hidden
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
* class list is the only place the composed state is observable.
*/
function track(input: HTMLElement): HTMLElement {
const label = input.closest("label") as HTMLElement;
const spans = [...label.querySelectorAll("span")];
const drawn = spans.find((span) => !span.contains(input));
if (drawn === undefined) throw new Error("the switch drew no track");
return drawn;
}
test("a switch reports the switch role, not a checkbox one", () => {
const onChange = vi.fn();
render(
<Switch isSelected onChange={onChange}>
Safe search
</Switch>,
);
// The role is the whole point: it tells a screen reader the setting moves now
// rather than on some later Save.
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
expect(screen.queryByRole("checkbox")).toBeNull();
fireEvent.click(control);
// The caller owns the state, so the switch reports the value it would move to.
expect(onChange).toHaveBeenCalledWith(false);
});
test("a bare switch takes its name from aria-label", () => {
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
expect(control.checked).toBe(false);
});
test("a disabled switch keeps its state on screen but takes no input", () => {
render(
<Switch isSelected isDisabled onChange={vi.fn()}>
Safe search
</Switch>,
);
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
// The disabled input is the whole guard: a browser fires no click on one, and
// it is out of the tab order. Clicking it here would prove nothing either way,
// because `fireEvent` dispatches straight at the node and skips that check.
expect(control.disabled).toBe(true);
});
test("the track is drawn from the selected state, not left to the browser", () => {
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const off = track(control).className.split(" ");
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
const on = track(control).className.split(" ");
// The two states compose to different class sets, so the thumb and the fill
// actually move rather than the input alone changing.
expect(on).not.toEqual(off);
});
test("keyboard focus composes the ring onto the drawn track", () => {
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const drawn = track(control);
const idle = drawn.className.split(" ");
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
// React Aria only calls focus visible after the modality is keyboard, which
// is why a bare focus() is not enough to raise the ring.
act(() => {
fireEvent.keyDown(document.body, { key: "Tab" });
control.focus();
});
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
const ringed = drawn.className.split(" ");
expect(ringed.length).toBeGreaterThan(idle.length);
expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* The on/off switch, wrapping React Aria's.
*
* A switch, not a checkbox: every call site flips a setting that takes effect
* the moment it moves, with no Save between. A checkbox states what a form will
* submit, which is a promise these controls do not make.
*
* React Aria hides the real input and leaves the track to the call site, so the
* track and thumb below are what the reader sees. The caller always holds the
* state — none of these controls owns the value it shows, because the server's
* answer is the value.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Switch as AriaSwitch } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Common {
isSelected: boolean;
onChange: (isSelected: boolean) => void;
/** Visible but inert; React Aria also drops it from the tab order. */
isDisabled?: boolean;
}
/** With visible words beside the track, which name it. */
interface Labelled extends Common {
children: ReactNode;
"aria-label"?: never;
}
/** Bare, in a table cell whose column heading cannot name a single row. */
interface Named extends Common {
children?: never;
"aria-label": string;
}
/**
* A switch has to be named, and exactly one of the two ways: visible words that
* `aria-label` would then override and hide from the reader who can see them,
* or no words and a label only the screen reader gets.
*/
type Props = Labelled | Named;
const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor on both axes, the same floor `Checkbox` and the dialog's Close
* button already set. The track itself is far under it.
*/
label: {
minWidth: 44,
minHeight: 44,
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Bare switches sit in a table cell, where the row sets the rhythm. */
track: {
flexShrink: 0,
boxSizing: "border-box",
width: 28,
height: 16,
display: "inline-flex",
alignItems: "center",
justifyContent: "flex-start",
borderRadius: 8,
padding: 2,
backgroundColor: colors.borderStrong,
},
/**
* The thumb moves by the box's own alignment rather than by a transform, so
* there is no transition to withhold from a reader who asked for less motion.
*/
trackSelected: {
justifyContent: "flex-end",
backgroundColor: colors.primary,
},
/** As on `Checkbox`: the ring follows the state React Aria reports, because
* `:focus-visible` lands on the hidden input rather than on this track. */
trackFocused: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The track dims, not the words: the label still has to be readable. */
trackDisabled: {
opacity: 0.5,
},
thumb: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.surfaceRaised,
},
});
export default function Switch({ children, ...state }: Props) {
return (
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
{(renderProps) => (
<>
<span
{...stylex.props(
styles.track,
renderProps.isSelected && styles.trackSelected,
renderProps.isFocusVisible && styles.trackFocused,
renderProps.isDisabled && styles.trackDisabled,
)}
>
<span {...stylex.props(styles.thumb)} />
</span>
{children}
</>
)}
</AriaSwitch>
);
}