diff --git a/admin/src/features/configuration/authority.test.tsx b/admin/src/features/configuration/authority.test.tsx
index 0306397..e34421c 100644
--- a/admin/src/features/configuration/authority.test.tsx
+++ b/admin/src/features/configuration/authority.test.tsx
@@ -24,7 +24,7 @@ afterEach(() => {
function mutationControls(): Element[] {
return [
...contentArea().querySelectorAll(
- 'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
+ 'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
),
];
}
diff --git a/admin/src/ui/Switch.test.tsx b/admin/src/ui/Switch.test.tsx
new file mode 100644
index 0000000..acdaf15
--- /dev/null
+++ b/admin/src/ui/Switch.test.tsx
@@ -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(
+
+ Safe search
+ ,
+ );
+
+ // 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();
+
+ 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(
+
+ Safe search
+ ,
+ );
+
+ 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();
+
+ const control = screen.getByRole("switch");
+ const off = track(control).className.split(" ");
+
+ rerender();
+ 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();
+
+ 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);
+});
diff --git a/admin/src/ui/Switch.tsx b/admin/src/ui/Switch.tsx
new file mode 100644
index 0000000..ed2e3f9
--- /dev/null
+++ b/admin/src/ui/Switch.tsx
@@ -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 (
+ stylex.props(styles.label).className ?? ""}>
+ {(renderProps) => (
+ <>
+
+
+
+ {children}
+ >
+ )}
+
+ );
+}