admin: group sources and safe search become rac checkboxes

the assigned sources list is a rac checkboxgroup and safe search uses the same drawn checkbox, extracted to a shared ui component with grouped and standalone modes enforced by a discriminated union. the label carries a 44px pointer-target floor on both axes, the focus ring is driven from rac's focus-visible state and guarded by a test, and toggleSource is gone because the group hands back the whole set.
This commit is contained in:
2026-08-29 12:33:00 +02:00
parent 3c674966be
commit f1de80477a
7 changed files with 281 additions and 64 deletions
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import { sameSet } from "./sourceSet";
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -28,13 +29,6 @@ const styles = stylex.create({
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
return (
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
</li>
))}
</ul>
{/* The section's own "Assigned sources" heading is the visible label; a
Label here would put the same words on screen twice. Ids cross the
React Aria boundary as strings, the same convention as Select. */}
<CheckboxGroup
aria-label="Assigned sources"
value={current.map(String)}
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
</li>
))}
</ul>
</CheckboxGroup>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}>
<button
@@ -18,6 +18,7 @@ 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";
@@ -68,13 +69,6 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.75rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
spacer: {
marginLeft: "auto",
},
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
)}
<div {...stylex.props(styles.controlRow)}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={update.isPending}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
update.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
<Checkbox
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
}
>
Safe search
</label>
</Checkbox>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<button
@@ -89,6 +89,29 @@ test("the source assignment saves the full set via PUT", async () => {
});
});
test("the source checkboxes form one named group, and each carries its own state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
// The section heading names the group; the boxes belong to it rather than
// sitting loose beside the group's other checkboxes.
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();
fireEvent.click(ads);
await waitFor(() => expect(ads.checked).toBe(true));
// Discard returns the group to the server's set rather than clearing it.
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
await waitFor(() =>
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
);
expect(writes("PUT")).toEqual([]);
});
test("only the selected group's rules are listed", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
@@ -1,18 +1,4 @@
import { sameSet, toggleSource } from "./sourceSet";
test("toggleSource adds a missing id keeping ascending order", () => {
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
expect(toggleSource([], 5)).toEqual([5]);
});
test("toggleSource removes a present id", () => {
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
expect(toggleSource([5], 5)).toEqual([]);
});
test("toggleSource twice is a no-op set-wise", () => {
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
});
import { sameSet } from "./sourceSet";
test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
@@ -1,8 +1,3 @@
export function toggleSource(ids: number[], id: number): number[] {
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
return [...ids, id].sort((a, b) => a - b);
}
export function sameSet(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y);