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
+155 -89
View File
@@ -1,13 +1,96 @@
import { useState, type FormEvent } 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 { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -20,6 +103,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -30,58 +114,52 @@ export default function RulesPage() {
);
}
function deleteRule(rule: Rule) {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
remove.mutate(rule.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
return (
<section>
<h1 className="text-2xl font-semibold">Rules</h1>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create 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}>Pattern</th>
<th className={thClass}>Kind</th>
<th className={thClass}>Action</th>
<th className={thClass}>Group</th>
<th className={thClass}>Created</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td>
<td className={tdClass}>
<span
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td className={tdClass}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => deleteRule(rule)}
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -94,10 +172,10 @@ export default function RulesPage() {
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
<h2 className="text-lg font-medium">Create rule</h2>
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div>
<label htmlFor="rule-pattern" className="block text-sm font-medium">
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
@@ -107,66 +185,54 @@ export default function RulesPage() {
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label htmlFor="rule-kind" className="block text-sm font-medium">
Kind
</label>
<select
id="rule-kind"
value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)}
className={inputClass}
>
<option value="exact">exact</option>
<option value="wildcard">wildcard</option>
</select>
</div>
<div>
<label htmlFor="rule-action" className="block text-sm font-medium">
Action
</label>
<select
id="rule-action"
value={action}
onChange={(event) => setAction(event.target.value as RuleAction)}
className={inputClass}
>
<option value="allow">allow</option>
<option value="block">block</option>
</select>
</div>
<div>
<label htmlFor="rule-group" className="block text-sm font-medium">
Group
</label>
<select
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}