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
@@ -18,10 +18,10 @@ import {
rulesQuery,
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import Checkbox from "@/ui/Checkbox";
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
@@ -349,7 +349,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
)}
<div {...stylex.props(styles.controlRow)}>
<Checkbox
<Switch
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
@@ -357,7 +357,7 @@ function GroupDetailEditable({ group }: { group: Group }) {
}
>
Safe search
</Checkbox>
</Switch>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<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 });
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" }));
const dialog = await screen.findByRole("alertdialog");
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
await openProtection(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));
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 ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
expect(ads.checked).toBe(false);
// Safe search is the group's own field, not one of its sources.
expect(within(group).queryByRole("checkbox", { name: "Safe search" })).toBeNull();
// Safe search is the group's own field, not one of its sources, and it is a
// 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);
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(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
// Switches, not checkboxes: the row applies the moment it moves, and the role
// 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();
});
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 () => {
let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, {
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote";
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
</span>
</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
isSelected={b.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
</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 { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
isSelected={u.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
@@ -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]',
),
];
}