import { useVisibility } from "@charcuterie/logic" import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import type { ReactNode } from "react" import { useRef, useState } from "react" import { toStoryChoice } from "../argTypes.storyHelpers.ts" import { Button } from "../Button/Button.tsx" import { StoryRow } from "../board.storyHelpers.tsx" import { Field } from "../Field/Field.tsx" import { OverlayStackProvider } from "../Overlay/OverlayStack.tsx" import { Picker } from "../Picker/Picker.tsx" import type { DialogSize } from "./Dialog.tsx" import { Dialog } from "./Dialog.tsx" const meta = { title: "Components/Overlays/Dialog", component: Dialog, parameters: { layout: "padded" }, argTypes: { size: toStoryChoice([ "sm", "md", "lg", "xl", "full", ] as const), }, args: { // `render` supplies the real content; this satisfies the required // `children` for the Controls panel's arg type. children: null, headingLevel: 2, isDismissable: true, size: "md", }, } satisfies Meta export default meta type Story = StoryObj /** * Every board here is triggers-plus-one-open-dialog rather than a * grid, and that is the component rather than the stories being * awkward: a dialog covers the viewport, so two side by side is not * the real arrangement. What the boards show instead is a page, a * scrim, and one dialog over both. * * `footer` is a function of `hide` because a dialog's own buttons * are the normal way out of it, and they need the same `hide` the * close button gets. One `useVisibility` per demo, no second source * of truth. */ const DialogDemo = ({ buttonLabel, children, footer, heading, isDismissable = true, size, }: { buttonLabel: string children?: ReactNode footer?: (hide: () => void) => ReactNode heading: string isDismissable?: boolean size?: DialogSize }): ReactNode => { const { hide, isVisible, show } = useVisibility() return ( <> {children ?? (

MakeMKV reported a read error on title 4. The rip can continue with the remaining eight titles, or stop here and keep what has completed.

)}
) } const InitialFocusDemo = (): ReactNode => { const { hide, isVisible, show } = useVisibility() const titleRef = useRef(null) return ( <> ) } export const Playground: Story = { parameters: playgroundParameters, args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => ( ), } const SIZES: DialogSize[] = ["sm", "md", "lg", "xl", "full"] export const AllVariants: Story = { args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => ( {SIZES.map((size) => ( ))} ), } export const AllStates: Story = { args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => ( ( <> )} heading="Stop the rip?" /> ( <> )} heading="This erases eight completed titles" isDismissable={false} />
{Array.from( { length: 24 }, (_, index) => `14:0${index % 10}:22 title ${ index + 1 } AACS handshake complete, reading…`, ).map((line) => (

{line}

))}
), } /** * Stacking — one dialog opened from inside another. An * `OverlayStackProvider` at the app root is the one piece of setup a * lone dialog does not need: it renders a **single** shared scrim * behind both, keeps the second on top, marks the first `inert`, and * closes them top-first on Escape or an outside press. Open the first, * then open the second from its footer. */ const StackedDemo = (): ReactNode => { const first = useVisibility() const second = useVisibility() return ( Open a second over it } heading="First dialog" isVisible={first.isVisible} onClose={first.hide} >

This one stays open and goes `inert` while the second is over it.

Close this one } heading="Second dialog" isVisible={second.isVisible} onClose={second.hide} >

One scrim behind both. Escape or a backdrop press closes this one first.

) } export const Stacked: Story = { args: { heading: "First dialog", isVisible: false, onClose: () => {}, }, render: () => , } /** * A dialog is the one surface in this library that is **not** a * container-query citizen, so it gets no `ContainerBoard`. Its size * is measured against the viewport by definition — it is the thing * covering the viewport — and boarding it at three container widths * would be measuring something that has no bearing on it. * * What does vary is the clamp: every size caps at `85dvh`, and * `full` takes the lot. `dvh` rather than `vh` because on a phone * the two differ by the address bar, and a dialog whose footer is * underneath it is a dialog with no buttons. */ export const Responsive: Story = { args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => (

`full` is the kiosk and phone case: the dialog is the screen, so there is nothing behind it worth showing.

), } /** * The keyboard contract, driven. `OverlayPanel`'s * `FloatingFocusManager` supplies the focus trap and the focus * restore, and `useDismiss` routes Escape through `onClose` — which, * unlike the old native ``, a synthetic `userEvent.keyboard` * can now press for real. */ /** * A confirm dialog with **no body**: the question is the `heading` * (which is the dialog's accessible name) and the answers are the * `footer`. `children` is optional precisely for this shape — the old * chrome `Modal` extended ``'s DOM props, so it allowed it, * and image-viewer's delete-confirm renders exactly this. */ const NoBodyDemo = (): ReactNode => { const { hide, isVisible, show } = useVisibility() return ( <> } heading="Delete this file? This cannot be undone." isVisible={isVisible} onClose={hide} /> ) } export const NoBody: Story = { args: { heading: "Delete this file? This cannot be undone.", isVisible: false, onClose: () => {}, }, render: () => , } /** * A dialog that wraps a FORM wants the caret in its first field, * not on the Close button the focus manager would otherwise pick. * * This is not a nicety: a capture dialog exists to be typed into * the moment it opens, and without `initialFocus` the first thing * typed goes to a button and vanishes. A consumer cannot fix it * from outside — focusing in its own effect races the focus * manager, and the manager wins. */ export const InitialFocus: Story = { args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => , } export const Interactive: Story = { args: { heading: "Bay 3", isVisible: false, onClose: () => {}, }, render: () => ( ( <> )} heading="Stop the rip?" /> ), } const PickerDraftDemo = () => { const [isVisible, setIsVisible] = useState(true) const [draft, setDraft] = useState( "Keep the unsaved draft", ) const [value, setValue] = useState("") return (
setIsVisible(false)} > setDraft(event.currentTarget.value) } />
) } /** Escape dismisses the picker's portal before its enclosing dialog. */ export const PickerDraft: Story = { args: { heading: "Edit a draft", isVisible: true, onClose: () => {}, }, render: () => , }