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:
@@ -18,10 +18,10 @@ import {
|
|||||||
rulesQuery,
|
rulesQuery,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||||
import Checkbox from "@/ui/Checkbox";
|
|
||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import DefinitionList from "@/ui/DefinitionList";
|
import DefinitionList from "@/ui/DefinitionList";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
@@ -349,7 +349,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div {...stylex.props(styles.controlRow)}>
|
<div {...stylex.props(styles.controlRow)}>
|
||||||
<Checkbox
|
<Switch
|
||||||
isSelected={group.safe_search}
|
isSelected={group.safe_search}
|
||||||
isDisabled={update.isPending}
|
isDisabled={update.isPending}
|
||||||
onChange={(safeSearch) =>
|
onChange={(safeSearch) =>
|
||||||
@@ -357,7 +357,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Safe search
|
Safe search
|
||||||
</Checkbox>
|
</Switch>
|
||||||
<span {...stylex.props(styles.spacer)}>
|
<span {...stylex.props(styles.spacer)}>
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
|
|||||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
|
|
||||||
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
||||||
const dialog = await screen.findByRole("alertdialog");
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
|
|||||||
await openProtection(2);
|
await openProtection(2);
|
||||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
|
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
|
||||||
|
|
||||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||||
expect(writes("PUT")[0]).toMatchObject({
|
expect(writes("PUT")[0]).toMatchObject({
|
||||||
@@ -98,8 +98,10 @@ test("the source checkboxes form one named group, and each carries its own state
|
|||||||
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||||
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||||
expect(ads.checked).toBe(false);
|
expect(ads.checked).toBe(false);
|
||||||
// Safe search is the group's own field, not one of its sources.
|
// Safe search is the group's own field, not one of its sources, and it is a
|
||||||
expect(within(group).queryByRole("checkbox", { name: "Safe search" })).toBeNull();
|
// switch rather than a checkbox because it applies the moment it moves.
|
||||||
|
expect(within(group).queryByRole("switch")).toBeNull();
|
||||||
|
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(ads);
|
fireEvent.click(ads);
|
||||||
await waitFor(() => expect(ads.checked).toBe(true));
|
await waitFor(() => expect(ads.checked).toBe(true));
|
||||||
@@ -235,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
// Switches, not checkboxes: the row applies the moment it moves, and the role
|
||||||
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
// is what tells a screen reader so.
|
||||||
|
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
|
||||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||||
|
calls = stubApi(DATABASE);
|
||||||
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
|
||||||
|
|
||||||
|
// No Save button stands between the switch and the write: one click, one PUT.
|
||||||
|
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||||
|
expect(writes("PUT")[0]).toMatchObject({
|
||||||
|
url: "/api/blocklists/2",
|
||||||
|
// The whole row goes back, not a patch of the one field that moved.
|
||||||
|
body: {
|
||||||
|
url: "https://example.com/trackers.txt",
|
||||||
|
name: "Trackers",
|
||||||
|
enabled: true,
|
||||||
|
is_suggested: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Update now says it started, and says nothing once it succeeds", async () => {
|
test("Update now says it started, and says nothing once it succeeds", async () => {
|
||||||
let release: ((response: Response) => void) | null = null;
|
let release: ((response: Response) => void) | null = null;
|
||||||
calls = stubApi(DATABASE, {
|
calls = stubApi(DATABASE, {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
import BlocklistForm from "./BlocklistForm";
|
import BlocklistForm from "./BlocklistForm";
|
||||||
import FileModeNote from "./FileModeNote";
|
import FileModeNote from "./FileModeNote";
|
||||||
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${b.name} enabled`}
|
aria-label={`${b.name} enabled`}
|
||||||
checked={b.enabled}
|
isSelected={b.enabled}
|
||||||
disabled={toggle.isPending}
|
isDisabled={toggle.isPending}
|
||||||
onChange={() => toggleEnabled(b)}
|
onChange={() => toggleEnabled(b)}
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
import FileModeNote from "./FileModeNote";
|
import FileModeNote from "./FileModeNote";
|
||||||
import QueryPanel from "./QueryPanel";
|
import QueryPanel from "./QueryPanel";
|
||||||
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${u.url} enabled`}
|
aria-label={`${u.url} enabled`}
|
||||||
checked={u.enabled}
|
isSelected={u.enabled}
|
||||||
disabled={toggle.isPending}
|
isDisabled={toggle.isPending}
|
||||||
onChange={() => toggleEnabled(u)}
|
onChange={() => toggleEnabled(u)}
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
|||||||
function mutationControls(): Element[] {
|
function mutationControls(): Element[] {
|
||||||
return [
|
return [
|
||||||
...contentArea().querySelectorAll(
|
...contentArea().querySelectorAll(
|
||||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user