milestone 23 s2: react aria primitives and their cluster

This commit is contained in:
2026-08-12 22:12:18 +02:00
parent 994bbf922c
commit 01e455c8af
32 changed files with 2316 additions and 532 deletions
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+131 -49
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
@@ -13,21 +14,89 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
numeric: {
fontVariantNumeric: "tabular-nums",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -57,10 +126,10 @@ export default function BlocklistsPage() {
});
}
function deleteBlocklist(b: Blocklist) {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
remove.mutate(b.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -68,60 +137,56 @@ export default function BlocklistsPage() {
return (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Blocklists</h1>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Name</th>
<th className={thClass}>URL</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last updated</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td className={tdClass}>
<span className="font-medium">{b.name}</span>
{b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
@@ -129,32 +194,36 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, styles.numeric)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -179,6 +248,19 @@ export default function BlocklistsPage() {
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}