admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
This commit is contained in:
+83
-10
@@ -4,18 +4,25 @@
|
||||
* 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, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
/** The dialog's accessible name. */
|
||||
label: string;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -30,25 +37,74 @@ const styles = stylex.create({
|
||||
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%",
|
||||
maxWidth: "28rem",
|
||||
maxHeight: "85vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
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. */
|
||||
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,
|
||||
},
|
||||
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
|
||||
close: {
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
content: {
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
paddingInline: "1.5rem",
|
||||
paddingBlock: "1.5rem",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
isDismissable
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||
{children}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user