rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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",
|
||||
},
|
||||
/**
|
||||
* The inverted row is the listbox convention, and the ring is the milestone-9
|
||||
* floor; an option gets both. RAC focuses the option's own DOM node, so the
|
||||
* shared `:focus-visible` ring does apply here — it is drawn inset because an
|
||||
* option flush against a scrolling popover clips an outset one, and recoloured
|
||||
* because the focus token is the same blue this row just painted behind it.
|
||||
*/
|
||||
itemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": 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,
|
||||
shared.insetFocusRing,
|
||||
isSelected && styles.itemSelected,
|
||||
isFocused && styles.itemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles } from "./styles";
|
||||
|
||||
/**
|
||||
* The StyleX compile-time transform must run in the vitest pipeline, not just
|
||||
* in `vite build`. Without the plugin these tests do not fail an assertion —
|
||||
* importing the module throws `Unexpected 'stylex.defineVars' call at runtime`
|
||||
* before a single case runs, which is the louder failure of the two
|
||||
* (milestone 23, ruling 1).
|
||||
*/
|
||||
describe("the StyleX build integration", () => {
|
||||
it("compiles stylex.props into a class name", () => {
|
||||
render(
|
||||
<button type="button" {...stylex.props(styles.button, styles.focusRing)}>
|
||||
probe
|
||||
</button>,
|
||||
);
|
||||
const probe = screen.getByRole("button", { name: "probe" });
|
||||
expect(probe.className).not.toBe("");
|
||||
});
|
||||
|
||||
it("keeps composed styles distinct from a single style", () => {
|
||||
const one = stylex.props(styles.button).className;
|
||||
const two = stylex.props(styles.button, styles.focusRing).className;
|
||||
expect(one).toBeTruthy();
|
||||
expect(two).not.toBe(one);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The shared style vocabulary (milestone 23, ruling 3; replaces the Tailwind
|
||||
* class constants of milestone 18, ruling 10).
|
||||
*
|
||||
* Every interactive element must carry `focusRing` or `insetFocusRing`; that
|
||||
* is the milestone-9 accessibility floor. Compose at the call site with
|
||||
* `stylex.props(styles.button, extra)` instead of re-spelling a variant.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
const FOCUS = ":focus-visible";
|
||||
const DISABLED = ":disabled";
|
||||
|
||||
/** The half-fade loop a placeholder runs while its data is in flight. */
|
||||
const pulseFrames = stylex.keyframes({
|
||||
"50%": { opacity: 0.5 },
|
||||
});
|
||||
|
||||
export const styles = stylex.create({
|
||||
/** The ring drawn outside the element. */
|
||||
focusRing: {
|
||||
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||
outlineOffset: { default: null, [FOCUS]: 2 },
|
||||
},
|
||||
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
||||
insetFocusRing: {
|
||||
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||
outlineOffset: { default: null, [FOCUS]: -2 },
|
||||
},
|
||||
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
smallInput: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
|
||||
button: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
smallButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
largeButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
primaryButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
largePrimaryButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
|
||||
rowButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
linkButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
dangerLinkButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.danger,
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: "transparent",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
color: colors.dangerText,
|
||||
},
|
||||
|
||||
/** Marks an element as waiting on data. Carries no colour of its own. */
|
||||
pulse: {
|
||||
animationName: pulseFrames,
|
||||
animationDuration: "2s",
|
||||
animationTimingFunction: "cubic-bezier(0.4, 0, 0.6, 1)",
|
||||
animationIterationCount: "infinite",
|
||||
},
|
||||
/** Digits of equal width, so a column of counts does not jitter as it updates. */
|
||||
tabularNums: {
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
},
|
||||
/** For a value the operator reads character by character: a domain, an IP, a URL. */
|
||||
mono: {
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
},
|
||||
/** Visible to a screen reader only; the element keeps its place in the a11y tree. */
|
||||
srOnly: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clipPath: "inset(50%)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
},
|
||||
|
||||
th: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.borderStrong,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
textAlign: "left",
|
||||
fontWeight: 500,
|
||||
},
|
||||
td: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
},
|
||||
/**
|
||||
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
||||
* this is a flex formatting context. StyleX has no way to write the `> * + *`
|
||||
* selector that `space-y` compiles to, so the rhythm can only come from
|
||||
* `gap`. Child sizing and margin behaviour differ from the Tailwind original;
|
||||
* both call sites are plain vertical form stacks, where they do not.
|
||||
*/
|
||||
formCard: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
maxWidth: "32rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "1rem",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The design tokens (milestone 23, ruling 2).
|
||||
*
|
||||
* Values are the Tailwind 4 zinc/blue/red ramps the app rendered before the
|
||||
* StyleX conversion, carried over verbatim so the palette does not shift.
|
||||
* Each token holds its dark value under `prefers-color-scheme: dark` — the
|
||||
* same media strategy Tailwind 4 defaulted to. There is no theme toggle and
|
||||
* no `data-theme` attribute; the scheme follows the operating system.
|
||||
*
|
||||
* Name tokens by role, never by shade: a call site asks for `border`, not for
|
||||
* zinc-200.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
export const colors = stylex.defineVars({
|
||||
/** The page ground. */
|
||||
surface: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(14.1% 0.005 285.823)" },
|
||||
/** Cards, dialogs and inputs sitting on top of the ground. */
|
||||
surfaceRaised: { default: "#fff", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
/** The fill under a pointer on a nav item or a menu row. */
|
||||
surfaceHover: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
/** Hairlines between rows and around cards. */
|
||||
border: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||
/** Control outlines, which need more contrast than a row divider. */
|
||||
borderStrong: { default: "oklch(87.1% 0.006 286.286)", [DARK]: "oklch(37% 0.013 285.805)" },
|
||||
text: { default: "oklch(21% 0.006 285.885)", [DARK]: "oklch(96.7% 0.001 286.375)" },
|
||||
/**
|
||||
* Secondary text. Most call sites were a flat zinc-500 before the conversion,
|
||||
* with no dark override, which measured 4.12:1 on the dark ground and so
|
||||
* failed WCAG AA; the dark value here is zinc-400, which measures 7.56:1.
|
||||
*/
|
||||
textMuted: { default: "oklch(55.2% 0.016 285.938)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||
/**
|
||||
* Secondary text that already adapted before the conversion: zinc-600 on the
|
||||
* light ground, zinc-400 on the dark one. It is a separate token because
|
||||
* `textMuted`'s lighter light value measures 4.62:1 against 7.40:1 here, and
|
||||
* these call sites are small text — table headers, an inactive nav item, a
|
||||
* chart legend — where that loss shows.
|
||||
*/
|
||||
textSecondary: { default: "oklch(44.2% 0.017 285.786)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||
/** Primary actions. Identical in both schemes, as before the conversion. */
|
||||
primary: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||
primaryText: { default: "#fff", [DARK]: "#fff" },
|
||||
/** Primary as foreground: lightened in dark so it clears the ground. */
|
||||
primaryOnSurface: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(70.7% 0.165 254.624)" },
|
||||
/**
|
||||
* Warnings: a degraded condition the operator can still act on, as against
|
||||
* `danger`, which is a failure or a destructive action. The amber ramp.
|
||||
*/
|
||||
warnSurface: { default: "oklch(98.7% 0.022 95.277)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
warnBorder: { default: "oklch(87.9% 0.169 91.605)", [DARK]: "oklch(47.3% 0.137 46.201)" },
|
||||
/** A control's outline inside a warning banner, which the banner border would swallow. */
|
||||
warnBorderStrong: { default: "oklch(82.8% 0.189 84.429)", [DARK]: "oklch(55.5% 0.163 48.998)" },
|
||||
warnText: { default: "oklch(41.4% 0.112 45.904)", [DARK]: "oklch(96.2% 0.059 95.617)" },
|
||||
danger: { default: "oklch(57.7% 0.245 27.325)", [DARK]: "oklch(70.4% 0.191 22.216)" },
|
||||
dangerSurface: { default: "oklch(97.1% 0.013 17.38)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
dangerBorder: { default: "oklch(80.8% 0.114 19.571)", [DARK]: "oklch(44.4% 0.177 26.899)" },
|
||||
dangerText: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
/** The focus ring colour. The ring itself is a floor, not a variant. */
|
||||
focus: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ReactNode } from "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 {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const created: string[] = [];
|
||||
const updated: { id: number; input: string }[] = [];
|
||||
const removed: number[] = [];
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useCrudForm<Row, string>({
|
||||
create: (_qc: QC) => ({
|
||||
mutationFn: async (input: string) => {
|
||||
created.push(input);
|
||||
},
|
||||
}),
|
||||
update: (_qc: QC) => ({
|
||||
mutationFn: async (vars: { id: number; input: string }) => {
|
||||
updated.push(vars);
|
||||
},
|
||||
}),
|
||||
remove: (_qc: QC) => ({
|
||||
mutationFn: async (id: number) => {
|
||||
removed.push(id);
|
||||
},
|
||||
}),
|
||||
confirmDelete: (row) => `Delete "${row.name}"?`,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
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 () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
expect(hook.result.current.form).toEqual({ mode: "create" });
|
||||
|
||||
act(() => hook.result.current.onSubmit("alpha"));
|
||||
await waitFor(() => expect(hook.result.current.form).toBeNull());
|
||||
expect(created).toEqual(["alpha"]);
|
||||
expect(updated).toEqual([]);
|
||||
});
|
||||
|
||||
test("the edit form submits the row id through the update mutation", async () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 7, name: "beta" } }));
|
||||
|
||||
act(() => hook.result.current.onSubmit("beta2"));
|
||||
await waitFor(() => expect(hook.result.current.form).toBeNull());
|
||||
expect(updated).toEqual([{ id: 7, input: "beta2" }]);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
test("submitting with no form open does nothing", () => {
|
||||
const { hook, created, updated } = setup();
|
||||
act(() => hook.result.current.onSubmit("ignored"));
|
||||
expect(created).toEqual([]);
|
||||
expect(updated).toEqual([]);
|
||||
});
|
||||
|
||||
test("openForm clears a stale create error so the reopened form starts clean", async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useCrudForm<Row, string>({
|
||||
create: (_qc: QC) => ({ mutationFn: () => Promise.reject(new Error("boom")) }),
|
||||
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
remove: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
confirmDelete: () => "?",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
act(() => hook.result.current.onSubmit("alpha"));
|
||||
await waitFor(() => expect(hook.result.current.create.error).not.toBeNull());
|
||||
expect(hook.result.current.form).toEqual({ mode: "create" });
|
||||
|
||||
act(() => hook.result.current.openForm({ mode: "create" }));
|
||||
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
|
||||
});
|
||||
|
||||
test("onDelete opens the confirmation instead of removing the row", () => {
|
||||
const { hook, removed } = setup();
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
|
||||
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
|
||||
expect(removed).toEqual([]);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 1, name: "delta" } }));
|
||||
act(() => hook.result.current.closeForm());
|
||||
expect(hook.result.current.form).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
|
||||
|
||||
/** 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 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, 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();
|
||||
const create = useMutation(spec.create(queryClient));
|
||||
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();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setForm(null);
|
||||
}
|
||||
|
||||
function onSubmit(input: Input) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.entity.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(entity: Entity) {
|
||||
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user