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
+113
View File
@@ -0,0 +1,113 @@
/**
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
* every `window.confirm` call in the app.
*
* `role="alertdialog"` rather than `dialog`: the message is the reason the
* dialog exists, so it is announced with the dialog instead of after it. The
* overlay is deliberately not dismissable — a delete needs an explicit answer,
* and a stray outside click is not one. Escape still cancels.
*/
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
isOpen: boolean;
title: string;
/** The full sentence the operator reads before confirming; names the entity. */
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "26rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
body: {
outlineStyle: "none",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
marginTop: "1.5rem",
},
dangerButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onCancel();
}}
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<p {...stylex.props(styles.message)}>{message}</p>
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
</div>
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The modal dialog (milestone 23, ruling 4).
*
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Props {
/** The dialog's accessible name. */
label: string;
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "28rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
/** The panel already draws the boundary; the dialog's own ring would double it. */
body: {
outlineStyle: "none",
},
});
export default function Dialog({ label, isOpen, onClose, children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* The single-choice picker (milestone 23, ruling 4), replacing every native
* `<select>` in the app.
*
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
* ListBoxItem; those parts are the Select, not extra components adopted beyond
* ruling 4's scope, and nothing outside this file imports them.
*
* Keys are strings because a `<select>`'s value was a string. A call site that
* models an id converts on both edges.
*/
import * as stylex from "@stylexjs/stylex";
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
value: string;
label: string;
}
interface Props {
options: readonly SelectOption[];
value: string;
onChange: (value: string) => void;
/** The visible label. Omit it only when `aria-label` names the control. */
label?: string;
"aria-label"?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
*/
variant?: "field" | "compactField" | "inline";
}
const styles = stylex.create({
root: {
display: "block",
},
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
trigger: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
textAlign: "left",
cursor: "pointer",
},
compact: {
marginTop: "0.25rem",
width: "100%",
},
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
chevron: {
color: colors.textMuted,
},
popover: {
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
listBox: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
item: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
cursor: "pointer",
outlineStyle: "none",
},
itemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
},
itemSelected: {
fontWeight: 600,
},
});
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
const base = variant === "field" ? shared.input : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
<AriaSelect
aria-label={ariaLabel}
value={value}
onChange={(key) => onChange(String(key ?? ""))}
{...stylex.props(styles.root)}
>
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
</span>
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<ListBox {...stylex.props(styles.listBox)}>
{options.map((option) => (
<ListBoxItem
key={option.value}
id={option.value}
textValue={option.label}
className={({ isFocused, isSelected }) =>
stylex.props(
styles.item,
isSelected && styles.itemSelected,
isFocused && styles.itemFocused,
).className ?? ""
}
>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</AriaSelect>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* The tab switcher (milestone 23, ruling 4).
*
* The hand-rolled version spelled the ARIA attributes by hand but had no
* keyboard navigation; React Aria brings arrow-key movement and roving
* tabindex with the same roles. State stays inside RAC — no caller needs to
* read which tab is open.
*
* RAC exposes state as render-prop booleans, so every rule below is a
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
import { colors } from "./tokens.stylex";
export interface TabSpec {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
}
const styles = stylex.create({
list: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
tab: {
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
borderBottomColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textMuted,
cursor: "pointer",
},
tabSelected: {
borderBottomColor: colors.primary,
color: colors.primaryOnSurface,
},
tabHovered: {
color: colors.text,
},
/**
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
*/
tabFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
panel: {
outlineStyle: "none",
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
root: {
display: "block",
},
});
export default function Tabs({ label, tabs }: Props) {
return (
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab
key={tab.id}
id={tab.id}
className={({ isSelected, isHovered, isFocusVisible }) =>
stylex.props(
styles.tab,
isHovered && !isSelected && styles.tabHovered,
isSelected && styles.tabSelected,
isFocusVisible && styles.tabFocusVisible,
).className ?? ""
}
>
{tab.label}
</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
{tab.content}
</TabPanel>
))}
</AriaTabs>
);
}
+69 -8
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import ConfirmDialog from "./ConfirmDialog";
import { useCrudForm } from "./useCrudForm";
interface Row {
@@ -41,6 +42,50 @@ function setup() {
return { hook, created, updated, removed };
}
/**
* The delete path is only finished when a real dialog drives it, so this
* harness wires the hook's pending-delete state to the component operators
* actually see (ruling 5). `window.confirm` no longer exists to stub.
*/
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
onRemoved(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
});
return (
<>
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
Delete gamma
</button>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete row"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</>
);
}
function renderDeleteHarness() {
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DeleteHarness onRemoved={(id) => removed.push(id)} />
</QueryClientProvider>,
);
return removed;
}
afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => {
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
});
test("delete asks for confirmation and only removes when confirmed", async () => {
test("onDelete opens the confirmation instead of removing the row", () => {
const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
expect(removed).toEqual([]);
});
confirm.mockReturnValue(true);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
test("cancelling the confirmation dialog leaves the row alone", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete "gamma"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(removed).toEqual([]);
});
test("confirming the dialog removes the row by id", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(removed).toEqual([3]));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("closeForm closes whichever form is open", () => {
+36 -6
View File
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
/** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
/** The row a confirmation dialog is open for, and the message it shows. */
export interface PendingDelete<Entity> {
entity: Entity;
message: string;
}
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */
/** The message the confirmation dialog shows before a delete. */
confirmDelete: (entity: Entity) => string;
}
/**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX
* and the table stay in the caller.
* three mutations, the open-form state, the pending-delete state and the
* handlers. The field JSX, the table and the confirmation dialog stay in the
* caller: `onDelete` opens the confirmation instead of deciding anything, so
* the hook never reaches for `window.confirm` (ruling 5).
*/
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient();
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) {
create.reset();
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
}
function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return;
remove.mutate(entity.id);
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
}
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
function confirmPendingDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.entity.id);
setPendingDelete(null);
}
function cancelPendingDelete() {
setPendingDelete(null);
}
return {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
};
}