selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
/**
|
|
* 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`.
|
|
*
|
|
* The title is both the visible heading and the accessible name: `Heading
|
|
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
|
|
* cannot name the dialog one thing and show another.
|
|
*/
|
|
|
|
import type { ReactNode } from "react";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
|
import { colors, layers, metrics } from "./tokens.stylex";
|
|
import { styles as shared } from "./styles";
|
|
|
|
interface Props {
|
|
/** The dialog's heading, and with it the dialog's accessible name. */
|
|
title: string;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
|
|
size?: "form" | "detail";
|
|
children: ReactNode;
|
|
}
|
|
|
|
const styles = stylex.create({
|
|
overlay: {
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: layers.overlay,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "1rem",
|
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
|
},
|
|
/**
|
|
* A column so the header stays put and the body scrolls under it. The height
|
|
* cap is what keeps a long record inside the viewport instead of running off
|
|
* the bottom of a short one.
|
|
*/
|
|
panel: {
|
|
width: "100%",
|
|
maxHeight: "85vh",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
borderRadius: "0.5rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
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)",
|
|
},
|
|
panelForm: {
|
|
maxWidth: "28rem",
|
|
},
|
|
panelDetail: {
|
|
maxWidth: "52rem",
|
|
},
|
|
/**
|
|
* The panel already draws the boundary; the dialog's own ring would double
|
|
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
|
|
*/
|
|
body: {
|
|
outlineStyle: "none",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 0,
|
|
},
|
|
header: {
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "1rem",
|
|
borderBottomWidth: 1,
|
|
borderBottomStyle: "solid",
|
|
borderBottomColor: colors.border,
|
|
paddingInline: "1.5rem",
|
|
paddingBlock: "0.75rem",
|
|
},
|
|
title: {
|
|
fontSize: "1.125rem",
|
|
lineHeight: "1.75rem",
|
|
fontWeight: 600,
|
|
},
|
|
/** The pointer-target floor on both axes, which the word alone misses. */
|
|
close: {
|
|
minWidth: metrics.hitTarget,
|
|
minHeight: metrics.hitTarget,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
content: {
|
|
minHeight: 0,
|
|
overflowY: "auto",
|
|
paddingInline: "1.5rem",
|
|
paddingBlock: "1.5rem",
|
|
},
|
|
});
|
|
|
|
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
|
|
return (
|
|
<ModalOverlay
|
|
isOpen={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) onClose();
|
|
}}
|
|
isDismissable
|
|
className={() => stylex.props(styles.overlay).className ?? ""}
|
|
>
|
|
<Modal
|
|
className={() =>
|
|
stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
|
|
""
|
|
}
|
|
>
|
|
<AriaDialog {...stylex.props(styles.body)}>
|
|
<div {...stylex.props(styles.header)}>
|
|
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
|
{title}
|
|
</Heading>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
{...stylex.props(shared.button, styles.close, shared.focusRing)}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
<div {...stylex.props(styles.content)}>{children}</div>
|
|
</AriaDialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
);
|
|
}
|