From d607d50349ca9af5bc3cc76fc27dc5cd9ba21256 Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Mon, 9 Nov 2026 19:26:48 -0400 Subject: [PATCH 1/3] feat(web): let Dialog and AlertDialog footers sit at any depth Content found its footer by scanning its direct children, so a footer inside a fragment or a component of the consumer's own stayed in the panel. Content now renders an empty band slot and the footer portals into it from wherever it is written. --- .../docs/web/components/alert-dialog.mdx | 2 +- .../content/docs/web/components/dialog.mdx | 8 +++-- apps/docs/public/r/web/alert-dialog.json | 2 +- apps/docs/public/r/web/dialog.json | 2 +- .../panelui-web/components/alert-dialog.md | 2 +- .../skills/panelui-web/components/dialog.md | 8 +++-- apps/docs/scripts/web-usage/alert-dialog.json | 2 +- apps/docs/scripts/web-usage/dialog.json | 4 +-- .../web/src/components/alert-dialog/index.tsx | 27 ++++++++-------- packages/web/src/components/dialog/index.tsx | 31 ++++++++++--------- skills/panelui-web/components/alert-dialog.md | 2 +- skills/panelui-web/components/dialog.md | 8 +++-- 12 files changed, 56 insertions(+), 42 deletions(-) diff --git a/apps/docs/content/docs/web/components/alert-dialog.mdx b/apps/docs/content/docs/web/components/alert-dialog.mdx index afdd3beff..7e2543df4 100644 --- a/apps/docs/content/docs/web/components/alert-dialog.mdx +++ b/apps/docs/content/docs/web/components/alert-dialog.mdx @@ -133,7 +133,7 @@ import { AlertDialog } from "@/components/panelui/alert-dialog" - **`AlertDialog.Media`** — A glyph in a disc of the dialog's tone, above the title. - **`AlertDialog.Title`** — The question. The dialog is announced by it. - **`AlertDialog.Description`** — What follows from each answer, read out after the title. -- **`AlertDialog.Footer`** — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`. +- **`AlertDialog.Footer`** — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`. - **`AlertDialog.Cancel`** — Closes the dialog and does nothing else. It reads "Cancel" unless given children. - **`AlertDialog.Action`** — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held. diff --git a/apps/docs/content/docs/web/components/dialog.mdx b/apps/docs/content/docs/web/components/dialog.mdx index 5e978078f..567efe1f4 100644 --- a/apps/docs/content/docs/web/components/dialog.mdx +++ b/apps/docs/content/docs/web/components/dialog.mdx @@ -164,7 +164,7 @@ import { Dialog } from "@/components/panelui/dialog" - **`Dialog.List`** — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - **`Dialog.ListGroup`** — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - **`Dialog.ListItem`** — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`. +- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. - **`Dialog.Close`** — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Examples @@ -829,7 +829,11 @@ When it opens, focus goes to the first field in it. The close button is drawn in ### Forms -Put the `
` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. + +### State that starts fresh + +Everything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate. ### On a phone diff --git a/apps/docs/public/r/web/alert-dialog.json b/apps/docs/public/r/web/alert-dialog.json index 1ea2a09ae..495e642a9 100644 --- a/apps/docs/public/r/web/alert-dialog.json +++ b/apps/docs/public/r/web/alert-dialog.json @@ -28,7 +28,7 @@ "path": "components/panelui/alert-dialog.tsx", "type": "registry:component", "target": "@components/panelui/alert-dialog.tsx", - "content": "'use client';\n\n/**\n * AlertDialog — a question that has to be answered before anything else on\n * the page can be done: delete this, discard that, sign out everywhere.\n *\n * ```tsx\n * \n * }>Delete project\n * \n * Delete Orchard?\n * Its 14 boards and their history go with it.\n * \n * \n * Delete project\n * \n * \n * \n * ```\n *\n * ## A press outside is Cancel\n *\n * A press anywhere outside the dialog, Escape and `AlertDialog.Cancel` all\n * close it without doing anything; an action closes it once it has done its\n * work. `dismissible={false}` takes the outside press away, for a question\n * that should not be waved off by a stray click. While an action is running,\n * nothing closes it.\n *\n * ## The question, then the answers\n *\n * It is drawn as a panel in a darker band, the way Feedback is: the panel\n * holds the question, and `AlertDialog.Footer` moves out of it into the band\n * underneath, where the answers sit side by side at equal widths. Neither\n * answer is made to look like the default by being bigger.\n *\n * ## Actions that take time, or nerve\n *\n * An action whose `onClick` returns a promise shows a spinner and keeps the\n * dialog open, and unclosable, until the promise settles: closed if it\n * resolved, still open if it failed, so the failure can be shown in it.\n * `hold` makes an action one that must be pressed and held while a fill\n * runs across it, for a step that cannot be undone.\n */\n// The dialog primitive rather than the alert-dialog one, which refuses every\n// press outside; the role is still `alertdialog`, set on the popup.\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport {\n Children,\n createContext,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { Button, type ButtonProps } from '@/components/panelui/button';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\n/** The theme role the dialog's media is drawn in, which also sets its action's default colour. */\nexport type AlertDialogTone = 'neutral' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst alertDialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4',\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in a light theme and a dark one alike. 38 and 30 are\n * one corner: the panel's radius is the band's less the 8px between them.\n */\n shell:\n 'relative flex w-full flex-col rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 text-popover-foreground shadow-xl outline-none ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'flex flex-col gap-2 rounded-[30px] bg-popover p-5 shadow-xs',\n media: [\n 'mb-2 flex size-12 shrink-0 items-center justify-center rounded-full',\n 'bg-[color-mix(in_oklab,var(--pu-tone)_14%,transparent)] text-[var(--pu-tone)] [&_svg:not([class*=size-])]:size-6',\n ],\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n // Held in from the band's edge further than the panel is, so the row of\n // answers does not read as a third edge of the dialog.\n footer: 'flex items-center gap-3 px-[18px] pb-2.5 pt-4',\n action: 'relative isolate h-11 min-w-0 flex-1 overflow-hidden rounded-full px-4 text-[15px]',\n // Drawn under the label and over the button's own fill, from the start edge.\n fill: 'pointer-events-none absolute inset-0 -z-10 origin-left scale-x-0 bg-[color-mix(in_oklab,currentColor_24%,transparent)] rtl:origin-right',\n },\n variants: {\n palette: {\n neutral: { shell: '[--pu-tone:var(--muted-foreground)]' },\n info: { shell: '[--pu-tone:var(--info)]' },\n success: { shell: '[--pu-tone:var(--success)]' },\n warning: { shell: '[--pu-tone:var(--warning)]' },\n destructive: { shell: '[--pu-tone:var(--destructive)]' },\n },\n size: {\n default: { shell: 'max-w-md' },\n // Narrow, so the question is centred over the two answers.\n sm: { shell: 'max-w-xs', panel: 'items-center text-center' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n /** How much an answer asks to be pressed: filled, or a tint of its colour. */\n weight: {\n solid: {},\n soft: {},\n },\n actionTone: {\n primary: {},\n destructive: {},\n neutral: {},\n },\n },\n compoundVariants: [\n {\n weight: 'soft',\n actionTone: 'primary',\n className: {\n action:\n 'bg-foreground/10 text-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground',\n },\n },\n {\n weight: 'soft',\n actionTone: 'neutral',\n className: {\n action:\n 'bg-foreground/10 text-muted-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground',\n },\n },\n {\n weight: 'soft',\n actionTone: 'destructive',\n className: {\n action:\n 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive hover:not-disabled:bg-[color-mix(in_oklab,var(--destructive)_18%,transparent)] hover:not-disabled:text-destructive',\n },\n },\n ],\n defaultVariants: { palette: 'neutral', size: 'default' },\n});\n\ninterface AlertDialogContextValue {\n open: boolean;\n setOpen(open: boolean): void;\n /** An action is running; nothing may close the dialog until it settles. */\n busy: boolean;\n setBusy(busy: boolean): void;\n tone: AlertDialogTone;\n /** Set by `AlertDialog.Content`. */\n inline: boolean;\n}\n\nconst AlertDialogContext = createContext(null);\n\nfunction useAlertDialog(part: string): AlertDialogContextValue {\n const context = useContext(AlertDialogContext);\n if (!context) throw new Error(`${part} must be used inside an .`);\n return context;\n}\n\nexport interface AlertDialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /** Whether a press outside it closes it, as Cancel would. Escape always does. Default `true`. */\n dismissible?: boolean;\n}\n\nfunction AlertDialogRoot({\n children,\n open,\n defaultOpen = false,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: AlertDialogProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const resolvedOpen = open ?? internalOpen;\n const [busy, setBusy] = useState(false);\n const setOpen = useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [open, onOpenChange]\n );\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen, busy, setBusy, tone: 'neutral', inline: false }),\n [resolvedOpen, setOpen, busy]\n );\n\n return (\n \n {\n // While an action runs, the answer is already being acted on.\n if (!next && busy) return;\n setOpen(next);\n }}\n >\n {children}\n \n \n );\n}\nAlertDialogRoot.displayName = 'AlertDialog';\n\nexport interface AlertDialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction AlertDialogTrigger({ render, children, ...props }: AlertDialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nAlertDialogTrigger.displayName = 'AlertDialog.Trigger';\n\nexport interface AlertDialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * The theme role it is drawn in: the colour of `AlertDialog.Media`, and of\n * an action that is not given its own. `destructive` makes the action red.\n * Default `neutral`.\n */\n tone?: AlertDialogTone;\n /** `sm` is narrower, with the question centred over the answers. Default `default`. */\n size?: 'default' | 'sm';\n /** Frost the page behind the dialog instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\nconst isFooter = (child: ReactNode) => isValidElement(child) && child.type === AlertDialogFooter;\n\n/** The dialog: the panel with the question, in a band that holds the answers. */\nfunction AlertDialogContent({\n tone = 'neutral',\n size = 'default',\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: AlertDialogContentProps) {\n const dialog = useAlertDialog('AlertDialog.Content');\n const slots = alertDialogVariants({ palette: tone, size, blur });\n const context = useMemo(() => ({ ...dialog, tone, inline }), [dialog, tone, inline]);\n\n // The footer leaves the panel for the band under it.\n const all = Children.toArray(children);\n const footer = all.filter(isFooter);\n const inside = (\n \n
\n {all.filter((child) => !isFooter(child))}\n
\n {footer}\n
\n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nAlertDialogContent.displayName = 'AlertDialog.Content';\n\nexport interface AlertDialogMediaProps extends ComponentProps<'div'> {}\n\n/** A glyph in a disc of the dialog's tone, above the title. */\nfunction AlertDialogMedia({ className, render, ...props }: AlertDialogMediaProps & RenderProp<'div'>) {\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'alert-dialog-media',\n 'aria-hidden': 'true',\n className: alertDialogVariants().media({ className }),\n },\n props\n ),\n });\n}\nAlertDialogMedia.displayName = 'AlertDialog.Media';\n\nexport interface AlertDialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The question. It is what the dialog is announced as. */\nfunction AlertDialogTitle({ className, ...props }: AlertDialogTitleProps) {\n const { inline } = useAlertDialog('AlertDialog.Title');\n const slots = alertDialogVariants();\n if (inline) return

;\n return ;\n}\nAlertDialogTitle.displayName = 'AlertDialog.Title';\n\nexport interface AlertDialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** What follows from each answer, read out after the title. */\nfunction AlertDialogDescription({ className, ...props }: AlertDialogDescriptionProps) {\n const { inline } = useAlertDialog('AlertDialog.Description');\n const slots = alertDialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nAlertDialogDescription.displayName = 'AlertDialog.Description';\n\nexport interface AlertDialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The answers, side by side at equal widths in the band under the panel.\n * Keep it a direct child of `AlertDialog.Content`, so the band can find it.\n */\nfunction AlertDialogFooter({ className, render, ...props }: AlertDialogFooterProps & RenderProp<'div'>) {\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'alert-dialog-footer', className: alertDialogVariants().footer({ className }) },\n props\n ),\n });\n}\nAlertDialogFooter.displayName = 'AlertDialog.Footer';\n\nexport interface AlertDialogCancelProps extends Omit {}\n\n/** Closes the dialog without doing anything. It reads \"Cancel\" unless given children. */\nfunction AlertDialogCancel({ className, children = 'Cancel', disabled, ...props }: AlertDialogCancelProps) {\n const dialog = useAlertDialog('AlertDialog.Cancel');\n const slots = alertDialogVariants({ weight: 'soft', actionTone: 'neutral' });\n const button = (\n \n );\n if (dialog.inline) return ;\n return {children};\n}\nAlertDialogCancel.displayName = 'AlertDialog.Cancel';\n\nexport interface AlertDialogActionProps extends Omit {\n /**\n * What it does. When it returns a promise, the button shows a spinner and\n * the dialog stays open, and cannot be closed, until it settles: it closes\n * when the promise resolves and stays open when it rejects. Call\n * `event.preventDefault()` to keep the dialog open after a plain handler.\n */\n onClick?: (event: MouseEvent) => void | Promise;\n /** Its colour. Default: `destructive` in a destructive dialog, `primary` otherwise. */\n tone?: 'primary' | 'destructive';\n /** `solid` is filled; `soft` is a tint of its colour, for an answer that is not the main one. Default `solid`. */\n variant?: 'solid' | 'soft';\n /**\n * Makes it an action that must be pressed and held, for this many\n * milliseconds, while a fill runs across it. Letting go early drains the\n * fill. Space and Enter can be held the same way.\n */\n hold?: number;\n}\n\n/** Holding starts on a press and ends on a release; a click on its own does nothing. */\nfunction useHold(duration: number | undefined, onDone: () => void) {\n const [holding, setHolding] = useState(false);\n const timer = useRef(undefined);\n // When the last press or key went down, so a click that no press preceded,\n // as a screen reader sends, can be told apart.\n const pressedAt = useRef(0);\n const done = useRef(onDone);\n done.current = onDone;\n\n const stop = useCallback(() => {\n window.clearTimeout(timer.current);\n setHolding(false);\n }, []);\n const start = useCallback(() => {\n if (duration === undefined) return;\n pressedAt.current = Date.now();\n window.clearTimeout(timer.current);\n setHolding(true);\n timer.current = window.setTimeout(() => {\n setHolding(false);\n done.current();\n }, duration);\n }, [duration]);\n useEffect(() => () => window.clearTimeout(timer.current), []);\n\n return {\n holding,\n pressed: () => Date.now() - pressedAt.current < (duration ?? 0) + 1000,\n handlers:\n duration === undefined\n ? {}\n : {\n onPointerDown: (event: PointerEvent) => {\n if (event.button === 0) start();\n },\n onPointerUp: stop,\n onPointerLeave: stop,\n onPointerCancel: stop,\n onKeyDown: (event: KeyboardEvent) => {\n if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {\n // Enter would click at once; the hold decides instead.\n event.preventDefault();\n start();\n }\n },\n onKeyUp: (event: KeyboardEvent) => {\n if (event.key === ' ' || event.key === 'Enter') {\n event.preventDefault();\n stop();\n }\n },\n onContextMenu: (event: MouseEvent) => event.preventDefault(),\n },\n };\n}\n\n/** Does what the dialog asks about, then closes it. */\nfunction AlertDialogAction({\n onClick,\n tone,\n variant = 'solid',\n hold,\n loading,\n disabled,\n className,\n children,\n ...props\n}: AlertDialogActionProps) {\n const dialog = useAlertDialog('AlertDialog.Action');\n const resolvedTone = tone ?? (dialog.tone === 'destructive' ? 'destructive' : 'primary');\n const slots = alertDialogVariants({ weight: variant, actionTone: resolvedTone });\n const [pending, setPending] = useState(false);\n const hintId = useId();\n\n const run = (event: MouseEvent) => {\n const result = onClick?.(event);\n if (event.defaultPrevented) return;\n if (result && typeof (result as Promise).then === 'function') {\n setPending(true);\n dialog.setBusy(true);\n (result as Promise).then(\n () => {\n setPending(false);\n dialog.setBusy(false);\n dialog.setOpen(false);\n },\n () => {\n setPending(false);\n dialog.setBusy(false);\n }\n );\n return;\n }\n dialog.setOpen(false);\n };\n\n // A hold that completes acts as a click on the button would.\n const button = useRef(null);\n const confirmed = useRef(false);\n const { holding, pressed, handlers } = useHold(hold, () => {\n confirmed.current = true;\n button.current?.click();\n });\n\n return (\n {\n if (hold !== undefined) {\n const wasHeld = confirmed.current;\n confirmed.current = false;\n // A click no press or key preceded comes from assistive technology,\n // which cannot hold; it confirms. Any other click must be a hold.\n if (!wasHeld && pressed()) return;\n }\n run(event);\n }}\n {...props}\n >\n {hold !== undefined ? (\n <>\n \n \n Press and hold to confirm\n \n \n ) : null}\n {children}\n \n );\n}\nAlertDialogAction.displayName = 'AlertDialog.Action';\n\nexport const AlertDialog = Object.assign(AlertDialogRoot, {\n Trigger: AlertDialogTrigger,\n Content: AlertDialogContent,\n Media: AlertDialogMedia,\n Title: AlertDialogTitle,\n Description: AlertDialogDescription,\n Footer: AlertDialogFooter,\n Cancel: AlertDialogCancel,\n Action: AlertDialogAction,\n});\n\nexport {\n AlertDialogTrigger,\n AlertDialogContent,\n AlertDialogMedia,\n AlertDialogTitle,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogCancel,\n AlertDialogAction,\n};\n" + "content": "'use client';\n\n/**\n * AlertDialog — a question that has to be answered before anything else on\n * the page can be done: delete this, discard that, sign out everywhere.\n *\n * ```tsx\n * \n * }>Delete project\n * \n * Delete Orchard?\n * Its 14 boards and their history go with it.\n * \n * \n * Delete project\n * \n * \n * \n * ```\n *\n * ## A press outside is Cancel\n *\n * A press anywhere outside the dialog, Escape and `AlertDialog.Cancel` all\n * close it without doing anything; an action closes it once it has done its\n * work. `dismissible={false}` takes the outside press away, for a question\n * that should not be waved off by a stray click. While an action is running,\n * nothing closes it.\n *\n * ## The question, then the answers\n *\n * It is drawn as a panel in a darker band, the way Feedback is: the panel\n * holds the question, and `AlertDialog.Footer` moves out of it into the band\n * underneath, where the answers sit side by side at equal widths. Neither\n * answer is made to look like the default by being bigger.\n *\n * ## Actions that take time, or nerve\n *\n * An action whose `onClick` returns a promise shows a spinner and keeps the\n * dialog open, and unclosable, until the promise settles: closed if it\n * resolved, still open if it failed, so the failure can be shown in it.\n * `hold` makes an action one that must be pressed and held while a fill\n * runs across it, for a step that cannot be undone.\n */\n// The dialog primitive rather than the alert-dialog one, which refuses every\n// press outside; the role is still `alertdialog`, set on the popup.\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type MouseEvent,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { Button, type ButtonProps } from '@/components/panelui/button';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\n/** The theme role the dialog's media is drawn in, which also sets its action's default colour. */\nexport type AlertDialogTone = 'neutral' | 'info' | 'success' | 'warning' | 'destructive';\n\nconst alertDialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4',\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in a light theme and a dark one alike. 38 and 30 are\n * one corner: the panel's radius is the band's less the 8px between them.\n */\n shell:\n 'relative flex w-full flex-col rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 text-popover-foreground shadow-xl outline-none ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'flex flex-col gap-2 rounded-[30px] bg-popover p-5 shadow-xs',\n media: [\n 'mb-2 flex size-12 shrink-0 items-center justify-center rounded-full',\n 'bg-[color-mix(in_oklab,var(--pu-tone)_14%,transparent)] text-[var(--pu-tone)] [&_svg:not([class*=size-])]:size-6',\n ],\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n // Held in from the band's edge further than the panel is, so the row of\n // answers does not read as a third edge of the dialog.\n footer: 'flex items-center gap-3 px-[18px] pb-2.5 pt-4',\n action: 'relative isolate h-11 min-w-0 flex-1 overflow-hidden rounded-full px-4 text-[15px]',\n // Drawn under the label and over the button's own fill, from the start edge.\n fill: 'pointer-events-none absolute inset-0 -z-10 origin-left scale-x-0 bg-[color-mix(in_oklab,currentColor_24%,transparent)] rtl:origin-right',\n },\n variants: {\n palette: {\n neutral: { shell: '[--pu-tone:var(--muted-foreground)]' },\n info: { shell: '[--pu-tone:var(--info)]' },\n success: { shell: '[--pu-tone:var(--success)]' },\n warning: { shell: '[--pu-tone:var(--warning)]' },\n destructive: { shell: '[--pu-tone:var(--destructive)]' },\n },\n size: {\n default: { shell: 'max-w-md' },\n // Narrow, so the question is centred over the two answers.\n sm: { shell: 'max-w-xs', panel: 'items-center text-center' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n /** How much an answer asks to be pressed: filled, or a tint of its colour. */\n weight: {\n solid: {},\n soft: {},\n },\n actionTone: {\n primary: {},\n destructive: {},\n neutral: {},\n },\n },\n compoundVariants: [\n {\n weight: 'soft',\n actionTone: 'primary',\n className: {\n action:\n 'bg-foreground/10 text-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground',\n },\n },\n {\n weight: 'soft',\n actionTone: 'neutral',\n className: {\n action:\n 'bg-foreground/10 text-muted-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground',\n },\n },\n {\n weight: 'soft',\n actionTone: 'destructive',\n className: {\n action:\n 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive hover:not-disabled:bg-[color-mix(in_oklab,var(--destructive)_18%,transparent)] hover:not-disabled:text-destructive',\n },\n },\n ],\n defaultVariants: { palette: 'neutral', size: 'default' },\n});\n\ninterface AlertDialogContextValue {\n open: boolean;\n setOpen(open: boolean): void;\n /** An action is running; nothing may close the dialog until it settles. */\n busy: boolean;\n setBusy(busy: boolean): void;\n tone: AlertDialogTone;\n /** Set by `AlertDialog.Content`. */\n inline: boolean;\n /** Where the footer portals to. `undefined` outside `AlertDialog.Content`. */\n band?: HTMLElement | null;\n}\n\nconst AlertDialogContext = createContext(null);\n\nfunction useAlertDialog(part: string): AlertDialogContextValue {\n const context = useContext(AlertDialogContext);\n if (!context) throw new Error(`${part} must be used inside an .`);\n return context;\n}\n\nexport interface AlertDialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /** Whether a press outside it closes it, as Cancel would. Escape always does. Default `true`. */\n dismissible?: boolean;\n}\n\nfunction AlertDialogRoot({\n children,\n open,\n defaultOpen = false,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: AlertDialogProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const resolvedOpen = open ?? internalOpen;\n const [busy, setBusy] = useState(false);\n const setOpen = useCallback(\n (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [open, onOpenChange]\n );\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen, busy, setBusy, tone: 'neutral', inline: false }),\n [resolvedOpen, setOpen, busy]\n );\n\n return (\n \n {\n // While an action runs, the answer is already being acted on.\n if (!next && busy) return;\n setOpen(next);\n }}\n >\n {children}\n \n \n );\n}\nAlertDialogRoot.displayName = 'AlertDialog';\n\nexport interface AlertDialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction AlertDialogTrigger({ render, children, ...props }: AlertDialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nAlertDialogTrigger.displayName = 'AlertDialog.Trigger';\n\nexport interface AlertDialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * The theme role it is drawn in: the colour of `AlertDialog.Media`, and of\n * an action that is not given its own. `destructive` makes the action red.\n * Default `neutral`.\n */\n tone?: AlertDialogTone;\n /** `sm` is narrower, with the question centred over the answers. Default `default`. */\n size?: 'default' | 'sm';\n /** Frost the page behind the dialog instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\n/** The dialog: the panel with the question, in a band that holds the answers. */\nfunction AlertDialogContent({\n tone = 'neutral',\n size = 'default',\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: AlertDialogContentProps) {\n const dialog = useAlertDialog('AlertDialog.Content');\n const slots = alertDialogVariants({ palette: tone, size, blur });\n const [band, setBand] = useState(null);\n const context = useMemo(() => ({ ...dialog, tone, inline, band }), [dialog, tone, inline, band]);\n\n // The footer leaves the panel for the band under it, from any depth.\n const inside = (\n \n

\n {children}\n
\n
\n \n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nAlertDialogContent.displayName = 'AlertDialog.Content';\n\nexport interface AlertDialogMediaProps extends ComponentProps<'div'> {}\n\n/** A glyph in a disc of the dialog's tone, above the title. */\nfunction AlertDialogMedia({ className, render, ...props }: AlertDialogMediaProps & RenderProp<'div'>) {\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'alert-dialog-media',\n 'aria-hidden': 'true',\n className: alertDialogVariants().media({ className }),\n },\n props\n ),\n });\n}\nAlertDialogMedia.displayName = 'AlertDialog.Media';\n\nexport interface AlertDialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The question. It is what the dialog is announced as. */\nfunction AlertDialogTitle({ className, ...props }: AlertDialogTitleProps) {\n const { inline } = useAlertDialog('AlertDialog.Title');\n const slots = alertDialogVariants();\n if (inline) return

;\n return ;\n}\nAlertDialogTitle.displayName = 'AlertDialog.Title';\n\nexport interface AlertDialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** What follows from each answer, read out after the title. */\nfunction AlertDialogDescription({ className, ...props }: AlertDialogDescriptionProps) {\n const { inline } = useAlertDialog('AlertDialog.Description');\n const slots = alertDialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nAlertDialogDescription.displayName = 'AlertDialog.Description';\n\nexport interface AlertDialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The answers, side by side at equal widths in the band under the panel,\n * wherever they are written inside `AlertDialog.Content`.\n */\nfunction AlertDialogFooter({ className, render, ...props }: AlertDialogFooterProps & RenderProp<'div'>) {\n const band = useContext(AlertDialogContext)?.band;\n const footer = useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'alert-dialog-footer', className: alertDialogVariants().footer({ className }) },\n props\n ),\n });\n if (band === undefined) return footer;\n return band ? createPortal(footer, band) : null;\n}\nAlertDialogFooter.displayName = 'AlertDialog.Footer';\n\nexport interface AlertDialogCancelProps extends Omit {}\n\n/** Closes the dialog without doing anything. It reads \"Cancel\" unless given children. */\nfunction AlertDialogCancel({ className, children = 'Cancel', disabled, ...props }: AlertDialogCancelProps) {\n const dialog = useAlertDialog('AlertDialog.Cancel');\n const slots = alertDialogVariants({ weight: 'soft', actionTone: 'neutral' });\n const button = (\n \n );\n if (dialog.inline) return ;\n return {children};\n}\nAlertDialogCancel.displayName = 'AlertDialog.Cancel';\n\nexport interface AlertDialogActionProps extends Omit {\n /**\n * What it does. When it returns a promise, the button shows a spinner and\n * the dialog stays open, and cannot be closed, until it settles: it closes\n * when the promise resolves and stays open when it rejects. Call\n * `event.preventDefault()` to keep the dialog open after a plain handler.\n */\n onClick?: (event: MouseEvent) => void | Promise;\n /** Its colour. Default: `destructive` in a destructive dialog, `primary` otherwise. */\n tone?: 'primary' | 'destructive';\n /** `solid` is filled; `soft` is a tint of its colour, for an answer that is not the main one. Default `solid`. */\n variant?: 'solid' | 'soft';\n /**\n * Makes it an action that must be pressed and held, for this many\n * milliseconds, while a fill runs across it. Letting go early drains the\n * fill. Space and Enter can be held the same way.\n */\n hold?: number;\n}\n\n/** Holding starts on a press and ends on a release; a click on its own does nothing. */\nfunction useHold(duration: number | undefined, onDone: () => void) {\n const [holding, setHolding] = useState(false);\n const timer = useRef(undefined);\n // When the last press or key went down, so a click that no press preceded,\n // as a screen reader sends, can be told apart.\n const pressedAt = useRef(0);\n const done = useRef(onDone);\n done.current = onDone;\n\n const stop = useCallback(() => {\n window.clearTimeout(timer.current);\n setHolding(false);\n }, []);\n const start = useCallback(() => {\n if (duration === undefined) return;\n pressedAt.current = Date.now();\n window.clearTimeout(timer.current);\n setHolding(true);\n timer.current = window.setTimeout(() => {\n setHolding(false);\n done.current();\n }, duration);\n }, [duration]);\n useEffect(() => () => window.clearTimeout(timer.current), []);\n\n return {\n holding,\n pressed: () => Date.now() - pressedAt.current < (duration ?? 0) + 1000,\n handlers:\n duration === undefined\n ? {}\n : {\n onPointerDown: (event: PointerEvent) => {\n if (event.button === 0) start();\n },\n onPointerUp: stop,\n onPointerLeave: stop,\n onPointerCancel: stop,\n onKeyDown: (event: KeyboardEvent) => {\n if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {\n // Enter would click at once; the hold decides instead.\n event.preventDefault();\n start();\n }\n },\n onKeyUp: (event: KeyboardEvent) => {\n if (event.key === ' ' || event.key === 'Enter') {\n event.preventDefault();\n stop();\n }\n },\n onContextMenu: (event: MouseEvent) => event.preventDefault(),\n },\n };\n}\n\n/** Does what the dialog asks about, then closes it. */\nfunction AlertDialogAction({\n onClick,\n tone,\n variant = 'solid',\n hold,\n loading,\n disabled,\n className,\n children,\n ...props\n}: AlertDialogActionProps) {\n const dialog = useAlertDialog('AlertDialog.Action');\n const resolvedTone = tone ?? (dialog.tone === 'destructive' ? 'destructive' : 'primary');\n const slots = alertDialogVariants({ weight: variant, actionTone: resolvedTone });\n const [pending, setPending] = useState(false);\n const hintId = useId();\n\n const run = (event: MouseEvent) => {\n const result = onClick?.(event);\n if (event.defaultPrevented) return;\n if (result && typeof (result as Promise).then === 'function') {\n setPending(true);\n dialog.setBusy(true);\n (result as Promise).then(\n () => {\n setPending(false);\n dialog.setBusy(false);\n dialog.setOpen(false);\n },\n () => {\n setPending(false);\n dialog.setBusy(false);\n }\n );\n return;\n }\n dialog.setOpen(false);\n };\n\n // A hold that completes acts as a click on the button would.\n const button = useRef(null);\n const confirmed = useRef(false);\n const { holding, pressed, handlers } = useHold(hold, () => {\n confirmed.current = true;\n button.current?.click();\n });\n\n return (\n {\n if (hold !== undefined) {\n const wasHeld = confirmed.current;\n confirmed.current = false;\n // A click no press or key preceded comes from assistive technology,\n // which cannot hold; it confirms. Any other click must be a hold.\n if (!wasHeld && pressed()) return;\n }\n run(event);\n }}\n {...props}\n >\n {hold !== undefined ? (\n <>\n \n \n Press and hold to confirm\n \n \n ) : null}\n {children}\n \n );\n}\nAlertDialogAction.displayName = 'AlertDialog.Action';\n\nexport const AlertDialog = Object.assign(AlertDialogRoot, {\n Trigger: AlertDialogTrigger,\n Content: AlertDialogContent,\n Media: AlertDialogMedia,\n Title: AlertDialogTitle,\n Description: AlertDialogDescription,\n Footer: AlertDialogFooter,\n Cancel: AlertDialogCancel,\n Action: AlertDialogAction,\n});\n\nexport {\n AlertDialogTrigger,\n AlertDialogContent,\n AlertDialogMedia,\n AlertDialogTitle,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogCancel,\n AlertDialogAction,\n};\n" } ] } diff --git a/apps/docs/public/r/web/dialog.json b/apps/docs/public/r/web/dialog.json index 10a2dd62c..cd08c03bd 100644 --- a/apps/docs/public/r/web/dialog.json +++ b/apps/docs/public/r/web/dialog.json @@ -14,7 +14,7 @@ "path": "components/panelui/dialog.tsx", "type": "registry:component", "target": "@components/panelui/dialog.tsx", - "content": "'use client';\n\n/**\n * Dialog — a task taken out of the page and done in a box over it: renaming\n * something, inviting people, filling in a short form.\n *\n * ```tsx\n *

\n * }>Rename\n * \n * \n * \n * \n * \n * \n * }>Cancel\n * \n * \n * \n * \n * ```\n *\n * ## A panel in a band\n *\n * The task sits in a panel, and `Dialog.Footer` moves out of it into the\n * darker band underneath, where the buttons are equal pills: the same shape\n * as Feedback and Alert Dialog, so the three read as one family.\n * `variant=\"plain\"` is a single surface with the footer ruled off at its\n * foot, for a dense form where the band would be one more edge.\n *\n * ## The middle scrolls\n *\n * The dialog is never taller than the screen. The header and footer stay\n * put and `Dialog.Body` scrolls between them, with a thin drawn thumb that\n * shows while it moves.\n *\n * ## Focus goes to the task\n *\n * The close button is drawn in the corner but comes last in the order, so\n * opening the dialog puts focus on its first field rather than on the way\n * out. A press outside, Escape or the close button dismiss it.\n *\n * ## Lists to choose from\n *\n * `Dialog.List` is a list of one-line choices for the body, such as a time\n * zone or a project to move something to: grouped under headings that stay\n * pinned while it scrolls, moved through with the arrow keys, with a check\n * on the chosen one. It opens scrolled to the chosen one.\n *\n * ## On a phone it sits low\n *\n * Under 640px wide it rests at the bottom of the screen, a few pixels clear\n * of the edges, where a thumb reaches its buttons.\n */\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n Children,\n createContext,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n type ComponentProps,\n type KeyboardEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={
}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\nconst dialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-hidden p-4 max-sm:items-end max-sm:p-2',\n shell: [\n 'relative flex max-h-[calc(100dvh-2rem)] w-full min-h-0 flex-col text-popover-foreground outline-none max-sm:max-h-[calc(100dvh-1rem)]',\n // A dialog opened from this one steps it back, smaller and veiled, so\n // the depth can be counted.\n 'origin-top scale-[calc(1-0.05*var(--nested-dialogs,0))] translate-y-[calc(-0.75rem*var(--nested-dialogs,0))]',\n \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/0 after:transition-colors after:duration-200 after:content-[''] data-nested-dialog-open:after:bg-black/10 dark:data-nested-dialog-open:after:bg-black/30\",\n ],\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'relative flex min-h-0 flex-1 flex-col',\n header: 'flex shrink-0 flex-col gap-1',\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n body: [\n 'relative flex min-h-0 flex-1 flex-col',\n // A hairline at an edge the content has been scrolled past, so what is\n // cut off reads as going under the header, not as stopping there.\n \"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-20 before:h-px before:bg-border before:opacity-0 before:transition-opacity before:content-[''] data-overflow-y-start:before:opacity-100\",\n ],\n bodyViewport: 'min-h-0 flex-1 overscroll-contain rounded-[inherit] outline-none',\n bodyContent: 'flex flex-col gap-4',\n scrollbar:\n 'my-2 me-1 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n footer: [\n 'flex shrink-0 items-center',\n // The footer sits on a tinted surface, and the secondary fill is a tint\n // too, so the button all but disappears into it. There it takes the\n // panel's own surface instead, which keeps it lighter than the band.\n '*:data-[variant=secondary]:bg-popover *:data-[variant=secondary]:shadow-xs',\n '*:data-[variant=secondary]:hover:not-disabled:bg-[color-mix(in_oklab,var(--popover),var(--foreground)_5%)]',\n ],\n close: [\n 'absolute end-4 top-4 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full',\n 'bg-muted text-muted-foreground outline-none transition-[background-color,color,scale] duration-150',\n 'after:absolute after:-inset-1.5 hover:bg-foreground/10 hover:text-foreground active:scale-95',\n 'focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-4',\n ],\n // Pulled out by the rows' own padding, so a row's text lines up with the\n // header's, and no further: the scrollbar runs in the 12px left at each\n // side, and a heading or a row reaching into it shows through the thumb.\n list: '-mx-2 flex flex-col outline-none',\n listGroup: 'flex flex-col [&+&]:mt-2',\n listGroupLabel: 'sticky top-0 z-10 bg-popover px-2 pb-1.5 pt-2 text-xs font-medium text-muted-foreground',\n listItem: [\n 'flex h-10 shrink-0 cursor-pointer select-none items-center gap-3 rounded-xl px-2 text-sm outline-none',\n // Room for the pinned heading above it when the arrow keys scroll it into view.\n 'scroll-mb-2 scroll-mt-10 transition-colors duration-100 hover:bg-foreground/[0.05]',\n 'focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/60',\n 'data-selected:bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] data-selected:font-medium',\n 'aria-disabled:cursor-default aria-disabled:opacity-50 aria-disabled:hover:bg-transparent motion-reduce:transition-none',\n ],\n listItemLabel: 'min-w-0 flex-1 truncate',\n listItemMeta: 'shrink-0 text-xs font-normal tabular-nums text-muted-foreground',\n listItemCheck: 'flex size-4 shrink-0 items-center justify-center text-primary [&_svg]:size-4',\n },\n variants: {\n variant: {\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in either theme. 38 and 30 are one corner: the\n * panel's radius is the band's less the 8px between them.\n */\n inset: {\n shell:\n 'rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 shadow-xl ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n panel: 'overflow-hidden rounded-[30px] bg-popover shadow-xs',\n header: 'px-5 pb-3 pt-5',\n body: 'first:pt-5',\n bodyContent: 'px-5 pb-5',\n // Held in from the band's edge further than the panel is, with the\n // buttons as equal pills: a row of decisions, not a third edge.\n footer: 'gap-3 px-[10px] pb-0.5 pt-2.5 *:h-11 *:min-w-0 *:flex-1 *:rounded-full',\n },\n plain: {\n shell: 'overflow-hidden rounded-[28px] border bg-popover shadow-xl',\n header: 'px-6 pb-4 pt-6',\n body: 'first:pt-6',\n bodyContent: 'px-6 pb-6',\n footer: 'justify-end gap-2 border-t bg-muted/50 px-6 py-3.5',\n },\n },\n size: {\n sm: { shell: 'max-w-sm' },\n md: { shell: 'max-w-md' },\n lg: { shell: 'max-w-xl' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n },\n defaultVariants: { variant: 'inset', size: 'md' },\n});\n\ninterface DialogContextValue {\n /** Set by `Dialog.Content`. */\n inline: boolean;\n showClose: boolean;\n variant: 'inset' | 'plain';\n}\n\nconst DialogContext = createContext({ inline: false, showClose: true, variant: 'inset' });\n\nexport interface DialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /**\n * Whether a press outside it closes it. Turn it off while a form in it has\n * changes that would be lost; Escape and the close button still work.\n * Default `true`.\n */\n dismissible?: boolean;\n}\n\nfunction DialogRoot({\n children,\n open,\n defaultOpen,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: DialogProps) {\n return (\n onOpenChange(next) : undefined}\n onOpenChangeComplete={onOpenChangeComplete}\n disablePointerDismissal={!dismissible}\n >\n {children}\n \n );\n}\nDialogRoot.displayName = 'Dialog';\n\nexport interface DialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction DialogTrigger({ render, children, ...props }: DialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nDialogTrigger.displayName = 'Dialog.Trigger';\n\nexport interface DialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * `inset` sets the task in a panel inside a darker band that holds the\n * footer. `plain` is one surface with the footer ruled off. Default `inset`.\n */\n variant?: 'inset' | 'plain';\n /** How wide it may grow: `sm` 24rem, `md` 28rem, `lg` 36rem. Default `md`. */\n size?: 'sm' | 'md' | 'lg';\n /** Show the round close button in the top trailing corner. Default `true`. */\n showClose?: boolean;\n /** Frost the page behind it instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\nconst isFooter = (child: ReactNode) => isValidElement(child) && child.type === DialogFooter;\n\n/** The dialog itself, with the dimmed page behind it. */\nfunction DialogContent({\n variant = 'inset',\n size = 'md',\n showClose = true,\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: DialogContentProps) {\n const slots = dialogVariants({ variant, size, blur });\n const context = useMemo(() => ({ inline, showClose, variant }), [inline, showClose, variant]);\n\n // In the inset look the footer leaves the panel for the band under it.\n const all = Children.toArray(children);\n const footer = variant === 'inset' ? all.filter(isFooter) : [];\n const rest = variant === 'inset' ? all.filter((child) => !isFooter(child)) : all;\n const inside = (\n \n
\n {rest}\n {/* Last in the order, so focus starts on the task, not the way out. */}\n {showClose ? : null}\n
\n {footer}\n
\n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nDialogContent.displayName = 'Dialog.Content';\n\nexport interface DialogHeaderProps extends Omit, 'title'> {\n /** The heading. It is also what the dialog is announced as. */\n title?: ReactNode;\n /** A line under the heading. */\n description?: ReactNode;\n}\n\n/** The heading block. It leaves room for the close button beside the title. */\nfunction DialogHeader({\n title,\n description,\n className,\n children,\n render,\n ...props\n}: DialogHeaderProps & RenderProp<'div'>) {\n const { showClose, variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'dialog-header',\n className: slots.header({ className: [showClose && 'pe-14', className] }),\n children: (\n <>\n {title !== undefined ? {title} : null}\n {description !== undefined ? {description} : null}\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogHeader.displayName = 'Dialog.Header';\n\nexport interface DialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The dialog's name, announced when it opens. */\nfunction DialogTitle({ className, ...props }: DialogTitleProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return ;\n}\nDialogTitle.displayName = 'Dialog.Title';\n\nexport interface DialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** A sentence under the title, read out after it. */\nfunction DialogDescription({ className, ...props }: DialogDescriptionProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nDialogDescription.displayName = 'Dialog.Description';\n\nexport interface DialogBodyProps extends ComponentProps<'div'> {}\n\n/**\n * The part that scrolls, between the header and the footer. Its children are\n * laid out in a column with a gap, so a form's fields need no wrapper.\n */\nfunction DialogBody({ className, children, ...props }: DialogBodyProps) {\n const { variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return (\n // With no header above it, it keeps the dialog's top padding itself.\n \n \n \n {children}\n \n \n \n \n \n \n );\n}\nDialogBody.displayName = 'Dialog.Body';\n\nexport interface DialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The buttons. In the inset look they sit in the band under the panel as\n * equal pills; in `plain` they sit at the end of a ruled strip. Keep it a\n * direct child of `Dialog.Content`, so the band can find it.\n */\nfunction DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) {\n const { variant } = useContext(DialogContext);\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'dialog-footer', className: dialogVariants({ variant }).footer({ className }) },\n props\n ),\n });\n}\nDialogFooter.displayName = 'Dialog.Footer';\n\nexport interface DialogCloseProps extends ComponentProps<'button'> {\n /** Your own element to close the dialog with, such as a Cancel button. */\n render?: ReactElement;\n /** Draw the cross glyph. Set for you on the built-in close button. */\n icon?: boolean;\n}\n\n/** Closes the dialog. */\nfunction DialogClose({ render, icon = false, children, ...props }: DialogCloseProps) {\n const { inline } = useContext(DialogContext);\n const content = icon ? (\n \n \n \n ) : (\n children\n );\n if (inline) {\n return (\n \n );\n }\n return (\n \n {content}\n \n );\n}\nDialogClose.displayName = 'Dialog.Close';\n\nexport interface DialogListProps extends ComponentProps<'div'> {}\n\n/**\n * A list of one-line choices in the body. The arrow keys, Home and End move\n * between its items, and it opens scrolled to the selected one.\n */\nfunction DialogList({ className, onKeyDown, children, render, ...props }: DialogListProps & RenderProp<'div'>) {\n const list = useRef(null);\n const items = () => [\n ...(list.current?.querySelectorAll('[data-slot=dialog-list-item]:not([aria-disabled=true])') ?? []),\n ];\n\n useEffect(() => {\n const all = items();\n const selected = all.find((item) => item.dataset.selected !== undefined);\n // One item takes Tab: the chosen one, or the first when none is.\n if (!selected && all[0]) all[0].tabIndex = 0;\n selected?.scrollIntoView({ block: 'center' });\n }, []);\n\n const move = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n const all = items();\n const index = all.indexOf(document.activeElement as HTMLElement);\n const next =\n event.key === 'ArrowDown'\n ? all[index < 0 ? 0 : Math.min(index + 1, all.length - 1)]\n : event.key === 'ArrowUp'\n ? all[Math.max(index - 1, 0)]\n : event.key === 'Home'\n ? all[0]\n : event.key === 'End'\n ? all[all.length - 1]\n : undefined;\n if (!next) return;\n event.preventDefault();\n next.focus();\n };\n\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n ref: list,\n role: 'listbox',\n 'data-slot': 'dialog-list',\n className: dialogVariants().list({ className }),\n onKeyDown: move,\n children: <>{children},\n },\n props\n ),\n });\n}\nDialogList.displayName = 'Dialog.List';\n\nexport interface DialogListGroupProps extends Omit, 'title'> {\n /** The heading over the group, which stays pinned while its items scroll under it. */\n label: ReactNode;\n}\n\n/** A run of items in `Dialog.List` under a heading of their own. */\nfunction DialogListGroup({ label, className, children, render, ...props }: DialogListGroupProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n const id = useId();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'group',\n 'aria-labelledby': id,\n 'data-slot': 'dialog-list-group',\n className: slots.listGroup({ className }),\n children: (\n <>\n

\n {label}\n
\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogListGroup.displayName = 'Dialog.ListGroup';\n\nexport interface DialogListItemProps extends Omit, 'onSelect'> {\n /** Whether it is the chosen one: it is tinted and checked. */\n selected?: boolean;\n /** Called when it is pressed, or chosen with Enter or Space. */\n onSelect?: () => void;\n /** Quieter text at its end, in figures that line up: a time, a count. */\n meta?: ReactNode;\n /** Shown, but cannot be chosen. */\n disabled?: boolean;\n}\n\n/** One choice in `Dialog.List`, on one line. */\nfunction DialogListItem({\n selected = false,\n onSelect,\n meta,\n disabled = false,\n className,\n onClick,\n onKeyDown,\n children,\n render,\n ...props\n}: DialogListItemProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'option',\n 'aria-selected': selected,\n 'aria-disabled': disabled || undefined,\n tabIndex: selected ? 0 : -1,\n 'data-slot': 'dialog-list-item',\n 'data-selected': selected ? '' : undefined,\n className: slots.listItem({ className }),\n onClick: (event: ReactTypes.MouseEvent) => {\n onClick?.(event);\n if (!disabled) onSelect?.();\n },\n onKeyDown: (event: ReactTypes.KeyboardEvent) => {\n onKeyDown?.(event);\n if ((event.key === 'Enter' || event.key === ' ') && !disabled) {\n event.preventDefault();\n onSelect?.();\n }\n },\n children: (\n <>\n {children}\n {meta !== undefined ? {meta} : null}\n \n {selected ? (\n \n \n \n ) : null}\n \n \n ),\n },\n props\n ),\n });\n}\nDialogListItem.displayName = 'Dialog.ListItem';\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Header: DialogHeader,\n Title: DialogTitle,\n Description: DialogDescription,\n Body: DialogBody,\n Footer: DialogFooter,\n Close: DialogClose,\n List: DialogList,\n ListGroup: DialogListGroup,\n ListItem: DialogListItem,\n});\n\nexport {\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogBody,\n DialogFooter,\n DialogClose,\n DialogList,\n DialogListGroup,\n DialogListItem,\n};\n" + "content": "'use client';\n\n/**\n * Dialog — a task taken out of the page and done in a box over it: renaming\n * something, inviting people, filling in a short form.\n *\n * ```tsx\n * \n * }>Rename\n * \n * \n * \n * \n * \n * \n * }>Cancel\n * \n * \n * \n * \n * ```\n *\n * ## A panel in a band\n *\n * The task sits in a panel, and `Dialog.Footer` moves out of it into the\n * darker band underneath, where the buttons are equal pills: the same shape\n * as Feedback and Alert Dialog, so the three read as one family.\n * `variant=\"plain\"` is a single surface with the footer ruled off at its\n * foot, for a dense form where the band would be one more edge.\n *\n * ## The middle scrolls\n *\n * The dialog is never taller than the screen. The header and footer stay\n * put and `Dialog.Body` scrolls between them, with a thin drawn thumb that\n * shows while it moves.\n *\n * ## Focus goes to the task\n *\n * The close button is drawn in the corner but comes last in the order, so\n * opening the dialog puts focus on its first field rather than on the way\n * out. A press outside, Escape or the close button dismiss it.\n *\n * ## Lists to choose from\n *\n * `Dialog.List` is a list of one-line choices for the body, such as a time\n * zone or a project to move something to: grouped under headings that stay\n * pinned while it scrolls, moved through with the arrow keys, with a check\n * on the chosen one. It opens scrolled to the chosen one.\n *\n * ## On a phone it sits low\n *\n * Under 640px wide it rests at the bottom of the screen, a few pixels clear\n * of the edges, where a thumb reaches its buttons.\n */\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n createContext,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={
}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\nconst dialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-hidden p-4 max-sm:items-end max-sm:p-2',\n shell: [\n 'relative flex max-h-[calc(100dvh-2rem)] w-full min-h-0 flex-col text-popover-foreground outline-none max-sm:max-h-[calc(100dvh-1rem)]',\n // A dialog opened from this one steps it back, smaller and veiled, so\n // the depth can be counted.\n 'origin-top scale-[calc(1-0.05*var(--nested-dialogs,0))] translate-y-[calc(-0.75rem*var(--nested-dialogs,0))]',\n \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/0 after:transition-colors after:duration-200 after:content-[''] data-nested-dialog-open:after:bg-black/10 dark:data-nested-dialog-open:after:bg-black/30\",\n ],\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'relative flex min-h-0 flex-1 flex-col',\n header: 'flex shrink-0 flex-col gap-1',\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n body: [\n 'relative flex min-h-0 flex-1 flex-col',\n // A hairline at an edge the content has been scrolled past, so what is\n // cut off reads as going under the header, not as stopping there.\n \"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-20 before:h-px before:bg-border before:opacity-0 before:transition-opacity before:content-[''] data-overflow-y-start:before:opacity-100\",\n ],\n bodyViewport: 'min-h-0 flex-1 overscroll-contain rounded-[inherit] outline-none',\n bodyContent: 'flex flex-col gap-4',\n scrollbar:\n 'my-2 me-1 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n footer: [\n 'flex shrink-0 items-center',\n // The footer sits on a tinted surface, and the secondary fill is a tint\n // too, so the button all but disappears into it. There it takes the\n // panel's own surface instead, which keeps it lighter than the band.\n '*:data-[variant=secondary]:bg-popover *:data-[variant=secondary]:shadow-xs',\n '*:data-[variant=secondary]:hover:not-disabled:bg-[color-mix(in_oklab,var(--popover),var(--foreground)_5%)]',\n ],\n close: [\n 'absolute end-4 top-4 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full',\n 'bg-muted text-muted-foreground outline-none transition-[background-color,color,scale] duration-150',\n 'after:absolute after:-inset-1.5 hover:bg-foreground/10 hover:text-foreground active:scale-95',\n 'focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-4',\n ],\n // Pulled out by the rows' own padding, so a row's text lines up with the\n // header's, and no further: the scrollbar runs in the 12px left at each\n // side, and a heading or a row reaching into it shows through the thumb.\n list: '-mx-2 flex flex-col outline-none',\n listGroup: 'flex flex-col [&+&]:mt-2',\n listGroupLabel: 'sticky top-0 z-10 bg-popover px-2 pb-1.5 pt-2 text-xs font-medium text-muted-foreground',\n listItem: [\n 'flex h-10 shrink-0 cursor-pointer select-none items-center gap-3 rounded-xl px-2 text-sm outline-none',\n // Room for the pinned heading above it when the arrow keys scroll it into view.\n 'scroll-mb-2 scroll-mt-10 transition-colors duration-100 hover:bg-foreground/[0.05]',\n 'focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/60',\n 'data-selected:bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] data-selected:font-medium',\n 'aria-disabled:cursor-default aria-disabled:opacity-50 aria-disabled:hover:bg-transparent motion-reduce:transition-none',\n ],\n listItemLabel: 'min-w-0 flex-1 truncate',\n listItemMeta: 'shrink-0 text-xs font-normal tabular-nums text-muted-foreground',\n listItemCheck: 'flex size-4 shrink-0 items-center justify-center text-primary [&_svg]:size-4',\n },\n variants: {\n variant: {\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in either theme. 38 and 30 are one corner: the\n * panel's radius is the band's less the 8px between them.\n */\n inset: {\n shell:\n 'rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 shadow-xl ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n panel: 'overflow-hidden rounded-[30px] bg-popover shadow-xs',\n header: 'px-5 pb-3 pt-5',\n body: 'first:pt-5',\n bodyContent: 'px-5 pb-5',\n // Held in from the band's edge further than the panel is, with the\n // buttons as equal pills: a row of decisions, not a third edge.\n footer: 'gap-3 px-[10px] pb-0.5 pt-2.5 *:h-11 *:min-w-0 *:flex-1 *:rounded-full',\n },\n plain: {\n shell: 'overflow-hidden rounded-[28px] border bg-popover shadow-xl',\n header: 'px-6 pb-4 pt-6',\n body: 'first:pt-6',\n bodyContent: 'px-6 pb-6',\n footer: 'justify-end gap-2 border-t bg-muted/50 px-6 py-3.5',\n },\n },\n size: {\n sm: { shell: 'max-w-sm' },\n md: { shell: 'max-w-md' },\n lg: { shell: 'max-w-xl' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n },\n defaultVariants: { variant: 'inset', size: 'md' },\n});\n\ninterface DialogContextValue {\n /** Set by `Dialog.Content`. */\n inline: boolean;\n showClose: boolean;\n variant: 'inset' | 'plain';\n /** Where an inset footer portals to. `undefined` outside `Dialog.Content`. */\n band?: HTMLElement | null;\n}\n\nconst DialogContext = createContext({ inline: false, showClose: true, variant: 'inset' });\n\nexport interface DialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /**\n * Whether a press outside it closes it. Turn it off while a form in it has\n * changes that would be lost; Escape and the close button still work.\n * Default `true`.\n */\n dismissible?: boolean;\n}\n\nfunction DialogRoot({\n children,\n open,\n defaultOpen,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: DialogProps) {\n return (\n onOpenChange(next) : undefined}\n onOpenChangeComplete={onOpenChangeComplete}\n disablePointerDismissal={!dismissible}\n >\n {children}\n \n );\n}\nDialogRoot.displayName = 'Dialog';\n\nexport interface DialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction DialogTrigger({ render, children, ...props }: DialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nDialogTrigger.displayName = 'Dialog.Trigger';\n\nexport interface DialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * `inset` sets the task in a panel inside a darker band that holds the\n * footer. `plain` is one surface with the footer ruled off. Default `inset`.\n */\n variant?: 'inset' | 'plain';\n /** How wide it may grow: `sm` 24rem, `md` 28rem, `lg` 36rem. Default `md`. */\n size?: 'sm' | 'md' | 'lg';\n /** Show the round close button in the top trailing corner. Default `true`. */\n showClose?: boolean;\n /** Frost the page behind it instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\n/** The dialog itself, with the dimmed page behind it. */\nfunction DialogContent({\n variant = 'inset',\n size = 'md',\n showClose = true,\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: DialogContentProps) {\n const slots = dialogVariants({ variant, size, blur });\n const [band, setBand] = useState(null);\n const context = useMemo(() => ({ inline, showClose, variant, band }), [inline, showClose, variant, band]);\n\n // In the inset look the footer leaves the panel for the band under it, from any depth.\n const inside = (\n \n
\n {children}\n {/* Last in the order, so focus starts on the task, not the way out. */}\n {showClose ? : null}\n
\n {variant === 'inset' ?
: null}\n \n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nDialogContent.displayName = 'Dialog.Content';\n\nexport interface DialogHeaderProps extends Omit, 'title'> {\n /** The heading. It is also what the dialog is announced as. */\n title?: ReactNode;\n /** A line under the heading. */\n description?: ReactNode;\n}\n\n/** The heading block. It leaves room for the close button beside the title. */\nfunction DialogHeader({\n title,\n description,\n className,\n children,\n render,\n ...props\n}: DialogHeaderProps & RenderProp<'div'>) {\n const { showClose, variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'dialog-header',\n className: slots.header({ className: [showClose && 'pe-14', className] }),\n children: (\n <>\n {title !== undefined ? {title} : null}\n {description !== undefined ? {description} : null}\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogHeader.displayName = 'Dialog.Header';\n\nexport interface DialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The dialog's name, announced when it opens. */\nfunction DialogTitle({ className, ...props }: DialogTitleProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return ;\n}\nDialogTitle.displayName = 'Dialog.Title';\n\nexport interface DialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** A sentence under the title, read out after it. */\nfunction DialogDescription({ className, ...props }: DialogDescriptionProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nDialogDescription.displayName = 'Dialog.Description';\n\nexport interface DialogBodyProps extends ComponentProps<'div'> {}\n\n/**\n * The part that scrolls, between the header and the footer. Its children are\n * laid out in a column with a gap, so a form's fields need no wrapper.\n */\nfunction DialogBody({ className, children, ...props }: DialogBodyProps) {\n const { variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return (\n // With no header above it, it keeps the dialog's top padding itself.\n \n \n \n {children}\n \n \n \n \n \n \n );\n}\nDialogBody.displayName = 'Dialog.Body';\n\nexport interface DialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The buttons. In the inset look they sit in the band under the panel as\n * equal pills, wherever they are written inside `Dialog.Content`; in `plain`\n * they sit at the end of a ruled strip.\n */\nfunction DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) {\n const { variant, band } = useContext(DialogContext);\n const footer = useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'dialog-footer', className: dialogVariants({ variant }).footer({ className }) },\n props\n ),\n });\n if (variant !== 'inset' || band === undefined) return footer;\n // The band mounts in the same commit, so the footer lands there before the first paint.\n return band ? createPortal(footer, band) : null;\n}\nDialogFooter.displayName = 'Dialog.Footer';\n\nexport interface DialogCloseProps extends ComponentProps<'button'> {\n /** Your own element to close the dialog with, such as a Cancel button. */\n render?: ReactElement;\n /** Draw the cross glyph. Set for you on the built-in close button. */\n icon?: boolean;\n}\n\n/** Closes the dialog. */\nfunction DialogClose({ render, icon = false, children, ...props }: DialogCloseProps) {\n const { inline } = useContext(DialogContext);\n const content = icon ? (\n \n \n \n ) : (\n children\n );\n if (inline) {\n return (\n \n );\n }\n return (\n \n {content}\n \n );\n}\nDialogClose.displayName = 'Dialog.Close';\n\nexport interface DialogListProps extends ComponentProps<'div'> {}\n\n/**\n * A list of one-line choices in the body. The arrow keys, Home and End move\n * between its items, and it opens scrolled to the selected one.\n */\nfunction DialogList({ className, onKeyDown, children, render, ...props }: DialogListProps & RenderProp<'div'>) {\n const list = useRef(null);\n const items = () => [\n ...(list.current?.querySelectorAll('[data-slot=dialog-list-item]:not([aria-disabled=true])') ?? []),\n ];\n\n useEffect(() => {\n const all = items();\n const selected = all.find((item) => item.dataset.selected !== undefined);\n // One item takes Tab: the chosen one, or the first when none is.\n if (!selected && all[0]) all[0].tabIndex = 0;\n selected?.scrollIntoView({ block: 'center' });\n }, []);\n\n const move = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n const all = items();\n const index = all.indexOf(document.activeElement as HTMLElement);\n const next =\n event.key === 'ArrowDown'\n ? all[index < 0 ? 0 : Math.min(index + 1, all.length - 1)]\n : event.key === 'ArrowUp'\n ? all[Math.max(index - 1, 0)]\n : event.key === 'Home'\n ? all[0]\n : event.key === 'End'\n ? all[all.length - 1]\n : undefined;\n if (!next) return;\n event.preventDefault();\n next.focus();\n };\n\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n ref: list,\n role: 'listbox',\n 'data-slot': 'dialog-list',\n className: dialogVariants().list({ className }),\n onKeyDown: move,\n children: <>{children},\n },\n props\n ),\n });\n}\nDialogList.displayName = 'Dialog.List';\n\nexport interface DialogListGroupProps extends Omit, 'title'> {\n /** The heading over the group, which stays pinned while its items scroll under it. */\n label: ReactNode;\n}\n\n/** A run of items in `Dialog.List` under a heading of their own. */\nfunction DialogListGroup({ label, className, children, render, ...props }: DialogListGroupProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n const id = useId();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'group',\n 'aria-labelledby': id,\n 'data-slot': 'dialog-list-group',\n className: slots.listGroup({ className }),\n children: (\n <>\n

\n {label}\n
\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogListGroup.displayName = 'Dialog.ListGroup';\n\nexport interface DialogListItemProps extends Omit, 'onSelect'> {\n /** Whether it is the chosen one: it is tinted and checked. */\n selected?: boolean;\n /** Called when it is pressed, or chosen with Enter or Space. */\n onSelect?: () => void;\n /** Quieter text at its end, in figures that line up: a time, a count. */\n meta?: ReactNode;\n /** Shown, but cannot be chosen. */\n disabled?: boolean;\n}\n\n/** One choice in `Dialog.List`, on one line. */\nfunction DialogListItem({\n selected = false,\n onSelect,\n meta,\n disabled = false,\n className,\n onClick,\n onKeyDown,\n children,\n render,\n ...props\n}: DialogListItemProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'option',\n 'aria-selected': selected,\n 'aria-disabled': disabled || undefined,\n tabIndex: selected ? 0 : -1,\n 'data-slot': 'dialog-list-item',\n 'data-selected': selected ? '' : undefined,\n className: slots.listItem({ className }),\n onClick: (event: ReactTypes.MouseEvent) => {\n onClick?.(event);\n if (!disabled) onSelect?.();\n },\n onKeyDown: (event: ReactTypes.KeyboardEvent) => {\n onKeyDown?.(event);\n if ((event.key === 'Enter' || event.key === ' ') && !disabled) {\n event.preventDefault();\n onSelect?.();\n }\n },\n children: (\n <>\n {children}\n {meta !== undefined ? {meta} : null}\n \n {selected ? (\n \n \n \n ) : null}\n \n \n ),\n },\n props\n ),\n });\n}\nDialogListItem.displayName = 'Dialog.ListItem';\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Header: DialogHeader,\n Title: DialogTitle,\n Description: DialogDescription,\n Body: DialogBody,\n Footer: DialogFooter,\n Close: DialogClose,\n List: DialogList,\n ListGroup: DialogListGroup,\n ListItem: DialogListItem,\n});\n\nexport {\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogBody,\n DialogFooter,\n DialogClose,\n DialogList,\n DialogListGroup,\n DialogListItem,\n};\n" } ] } diff --git a/apps/docs/public/skills/panelui-web/components/alert-dialog.md b/apps/docs/public/skills/panelui-web/components/alert-dialog.md index 1f2ba96cd..f598fe6e4 100644 --- a/apps/docs/public/skills/panelui-web/components/alert-dialog.md +++ b/apps/docs/public/skills/panelui-web/components/alert-dialog.md @@ -54,7 +54,7 @@ For a task rather than a yes or no, such as a form to fill in, use [Dialog](/doc - `AlertDialog.Media` — A glyph in a disc of the dialog's tone, above the title. - `AlertDialog.Title` — The question. The dialog is announced by it. - `AlertDialog.Description` — What follows from each answer, read out after the title. -- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`. +- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`. - `AlertDialog.Cancel` — Closes the dialog and does nothing else. It reads "Cancel" unless given children. - `AlertDialog.Action` — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held. diff --git a/apps/docs/public/skills/panelui-web/components/dialog.md b/apps/docs/public/skills/panelui-web/components/dialog.md index 511dd9c88..bfbbeed4a 100644 --- a/apps/docs/public/skills/panelui-web/components/dialog.md +++ b/apps/docs/public/skills/panelui-web/components/dialog.md @@ -69,7 +69,7 @@ For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog). - `Dialog.List` — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - `Dialog.ListGroup` — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - `Dialog.ListItem` — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`. +- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. - `Dialog.Close` — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Props @@ -165,7 +165,11 @@ When it opens, focus goes to the first field in it. The close button is drawn in #### Forms -Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. + +#### State that starts fresh + +Everything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate. #### On a phone diff --git a/apps/docs/scripts/web-usage/alert-dialog.json b/apps/docs/scripts/web-usage/alert-dialog.json index 11326576e..15550a655 100644 --- a/apps/docs/scripts/web-usage/alert-dialog.json +++ b/apps/docs/scripts/web-usage/alert-dialog.json @@ -13,7 +13,7 @@ "Media": "A glyph in a disc of the dialog's tone, above the title.", "Title": "The question. The dialog is announced by it.", "Description": "What follows from each answer, read out after the title.", - "Footer": "The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`.", + "Footer": "The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`.", "Cancel": "Closes the dialog and does nothing else. It reads \"Cancel\" unless given children.", "Action": "Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant=\"soft\"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held." }, diff --git a/apps/docs/scripts/web-usage/dialog.json b/apps/docs/scripts/web-usage/dialog.json index 56513d4be..c0bb23788 100644 --- a/apps/docs/scripts/web-usage/dialog.json +++ b/apps/docs/scripts/web-usage/dialog.json @@ -17,7 +17,7 @@ "List": "A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.", "ListGroup": "A run of items under a `label`, which stays pinned at the top while its items scroll under it.", "ListItem": "One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.", - "Footer": "The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`.", + "Footer": "The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`.", "Close": "Closes the dialog. Pass your own button as `render`, such as Cancel." }, "examples": [ @@ -52,5 +52,5 @@ "height": 180 } ], - "notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap." + "notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it.\n\n### State that starts fresh\n\nEverything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap." } diff --git a/packages/web/src/components/alert-dialog/index.tsx b/packages/web/src/components/alert-dialog/index.tsx index b02891f5f..5dad164d0 100644 --- a/packages/web/src/components/alert-dialog/index.tsx +++ b/packages/web/src/components/alert-dialog/index.tsx @@ -45,9 +45,7 @@ // press outside; the role is still `alertdialog`, set on the popup. import { Dialog as Primitive } from '@base-ui/react/dialog'; import { - Children, createContext, - isValidElement, useCallback, useContext, useEffect, @@ -62,6 +60,7 @@ import { type ReactElement, type ReactNode, } from 'react'; +import { createPortal } from 'react-dom'; import { tv } from 'tailwind-variants'; import { Button, type ButtonProps } from '../button'; import { mergeProps } from '@base-ui/react/merge-props'; @@ -167,6 +166,8 @@ interface AlertDialogContextValue { tone: AlertDialogTone; /** Set by `AlertDialog.Content`. */ inline: boolean; + /** Where the footer portals to. `undefined` outside `AlertDialog.Content`. */ + band?: HTMLElement | null; } const AlertDialogContext = createContext(null); @@ -275,8 +276,6 @@ export interface AlertDialogContentProps extends Omit, 'ch inline?: boolean; } -const isFooter = (child: ReactNode) => isValidElement(child) && child.type === AlertDialogFooter; - /** The dialog: the panel with the question, in a band that holds the answers. */ function AlertDialogContent({ tone = 'neutral', @@ -289,17 +288,16 @@ function AlertDialogContent({ }: AlertDialogContentProps) { const dialog = useAlertDialog('AlertDialog.Content'); const slots = alertDialogVariants({ palette: tone, size, blur }); - const context = useMemo(() => ({ ...dialog, tone, inline }), [dialog, tone, inline]); + const [band, setBand] = useState(null); + const context = useMemo(() => ({ ...dialog, tone, inline, band }), [dialog, tone, inline, band]); - // The footer leaves the panel for the band under it. - const all = Children.toArray(children); - const footer = all.filter(isFooter); + // The footer leaves the panel for the band under it, from any depth. const inside = (
- {all.filter((child) => !isFooter(child))} + {children}
- {footer} +
); @@ -380,11 +378,12 @@ AlertDialogDescription.displayName = 'AlertDialog.Description'; export interface AlertDialogFooterProps extends ComponentProps<'div'> {} /** - * The answers, side by side at equal widths in the band under the panel. - * Keep it a direct child of `AlertDialog.Content`, so the band can find it. + * The answers, side by side at equal widths in the band under the panel, + * wherever they are written inside `AlertDialog.Content`. */ function AlertDialogFooter({ className, render, ...props }: AlertDialogFooterProps & RenderProp<'div'>) { - return useRender({ + const band = useContext(AlertDialogContext)?.band; + const footer = useRender({ defaultTagName: 'div', render, props: mergeProps( @@ -392,6 +391,8 @@ function AlertDialogFooter({ className, render, ...props }: AlertDialogFooterPro props ), }); + if (band === undefined) return footer; + return band ? createPortal(footer, band) : null; } AlertDialogFooter.displayName = 'AlertDialog.Footer'; diff --git a/packages/web/src/components/dialog/index.tsx b/packages/web/src/components/dialog/index.tsx index 7102d0d5e..176dbd156 100644 --- a/packages/web/src/components/dialog/index.tsx +++ b/packages/web/src/components/dialog/index.tsx @@ -55,19 +55,19 @@ import { Dialog as Primitive } from '@base-ui/react/dialog'; import { ScrollArea } from '@base-ui/react/scroll-area'; import { - Children, createContext, - isValidElement, useContext, useEffect, useId, useMemo, useRef, + useState, type ComponentProps, type KeyboardEvent, type ReactElement, type ReactNode, } from 'react'; +import { createPortal } from 'react-dom'; import { tv } from 'tailwind-variants'; import { mergeProps } from '@base-ui/react/merge-props'; import { useRender } from '@base-ui/react/use-render'; @@ -181,6 +181,8 @@ interface DialogContextValue { inline: boolean; showClose: boolean; variant: 'inset' | 'plain'; + /** Where an inset footer portals to. `undefined` outside `Dialog.Content`. */ + band?: HTMLElement | null; } const DialogContext = createContext({ inline: false, showClose: true, variant: 'inset' }); @@ -268,8 +270,6 @@ export interface DialogContentProps extends Omit, 'childre inline?: boolean; } -const isFooter = (child: ReactNode) => isValidElement(child) && child.type === DialogFooter; - /** The dialog itself, with the dimmed page behind it. */ function DialogContent({ variant = 'inset', @@ -282,20 +282,18 @@ function DialogContent({ ...props }: DialogContentProps) { const slots = dialogVariants({ variant, size, blur }); - const context = useMemo(() => ({ inline, showClose, variant }), [inline, showClose, variant]); + const [band, setBand] = useState(null); + const context = useMemo(() => ({ inline, showClose, variant, band }), [inline, showClose, variant, band]); - // In the inset look the footer leaves the panel for the band under it. - const all = Children.toArray(children); - const footer = variant === 'inset' ? all.filter(isFooter) : []; - const rest = variant === 'inset' ? all.filter((child) => !isFooter(child)) : all; + // In the inset look the footer leaves the panel for the band under it, from any depth. const inside = (
- {rest} + {children} {/* Last in the order, so focus starts on the task, not the way out. */} {showClose ? : null}
- {footer} + {variant === 'inset' ?
: null} ); @@ -417,12 +415,12 @@ export interface DialogFooterProps extends ComponentProps<'div'> {} /** * The buttons. In the inset look they sit in the band under the panel as - * equal pills; in `plain` they sit at the end of a ruled strip. Keep it a - * direct child of `Dialog.Content`, so the band can find it. + * equal pills, wherever they are written inside `Dialog.Content`; in `plain` + * they sit at the end of a ruled strip. */ function DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) { - const { variant } = useContext(DialogContext); - return useRender({ + const { variant, band } = useContext(DialogContext); + const footer = useRender({ defaultTagName: 'div', render, props: mergeProps( @@ -430,6 +428,9 @@ function DialogFooter({ className, render, ...props }: DialogFooterProps & Rende props ), }); + if (variant !== 'inset' || band === undefined) return footer; + // The band mounts in the same commit, so the footer lands there before the first paint. + return band ? createPortal(footer, band) : null; } DialogFooter.displayName = 'Dialog.Footer'; diff --git a/skills/panelui-web/components/alert-dialog.md b/skills/panelui-web/components/alert-dialog.md index 1f2ba96cd..f598fe6e4 100644 --- a/skills/panelui-web/components/alert-dialog.md +++ b/skills/panelui-web/components/alert-dialog.md @@ -54,7 +54,7 @@ For a task rather than a yes or no, such as a form to fill in, use [Dialog](/doc - `AlertDialog.Media` — A glyph in a disc of the dialog's tone, above the title. - `AlertDialog.Title` — The question. The dialog is announced by it. - `AlertDialog.Description` — What follows from each answer, read out after the title. -- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. Keep it a direct child of `AlertDialog.Content`. +- `AlertDialog.Footer` — The answers, side by side at equal widths in the band under the panel. It can sit at any depth inside `AlertDialog.Content`. - `AlertDialog.Cancel` — Closes the dialog and does nothing else. It reads "Cancel" unless given children. - `AlertDialog.Action` — Does what the dialog asks about, then closes it. It takes a [Button](/docs/web/components/button)'s props, a `tone`, and `variant="soft"` for an answer that is not the main one. When `onClick` returns a promise, it waits for it. `hold` makes it an action that must be pressed and held. diff --git a/skills/panelui-web/components/dialog.md b/skills/panelui-web/components/dialog.md index 511dd9c88..bfbbeed4a 100644 --- a/skills/panelui-web/components/dialog.md +++ b/skills/panelui-web/components/dialog.md @@ -69,7 +69,7 @@ For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog). - `Dialog.List` — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - `Dialog.ListGroup` — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - `Dialog.ListItem` — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. Keep it a direct child of `Dialog.Content`. +- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. - `Dialog.Close` — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Props @@ -165,7 +165,11 @@ When it opens, focus goes to the first field in it. The close button is drawn in #### Forms -Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it, and the footer can stay a direct child of the content. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. + +#### State that starts fresh + +Everything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate. #### On a phone From 0234db6d2d9d72b53ed536120504bfca9e111a4f Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Mon, 9 Nov 2026 19:51:23 -0400 Subject: [PATCH 2/3] docs(web): plain dialog footers stay where written --- apps/docs/content/docs/web/components/dialog.mdx | 2 +- apps/docs/public/skills/panelui-web/components/dialog.md | 2 +- apps/docs/scripts/web-usage/dialog.json | 2 +- skills/panelui-web/components/dialog.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/web/components/dialog.mdx b/apps/docs/content/docs/web/components/dialog.mdx index 567efe1f4..6241b82dd 100644 --- a/apps/docs/content/docs/web/components/dialog.mdx +++ b/apps/docs/content/docs/web/components/dialog.mdx @@ -164,7 +164,7 @@ import { Dialog } from "@/components/panelui/dialog" - **`Dialog.List`** — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - **`Dialog.ListGroup`** — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - **`Dialog.ListItem`** — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. +- **`Dialog.Footer`** — The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll. - **`Dialog.Close`** — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Examples diff --git a/apps/docs/public/skills/panelui-web/components/dialog.md b/apps/docs/public/skills/panelui-web/components/dialog.md index bfbbeed4a..773d2697f 100644 --- a/apps/docs/public/skills/panelui-web/components/dialog.md +++ b/apps/docs/public/skills/panelui-web/components/dialog.md @@ -69,7 +69,7 @@ For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog). - `Dialog.List` — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - `Dialog.ListGroup` — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - `Dialog.ListItem` — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. +- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll. - `Dialog.Close` — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Props diff --git a/apps/docs/scripts/web-usage/dialog.json b/apps/docs/scripts/web-usage/dialog.json index c0bb23788..08375cebe 100644 --- a/apps/docs/scripts/web-usage/dialog.json +++ b/apps/docs/scripts/web-usage/dialog.json @@ -17,7 +17,7 @@ "List": "A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one.", "ListGroup": "A run of items under a `label`, which stays pinned at the top while its items scroll under it.", "ListItem": "One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up.", - "Footer": "The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`.", + "Footer": "The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll.", "Close": "Closes the dialog. Pass your own button as `render`, such as Cancel." }, "examples": [ diff --git a/skills/panelui-web/components/dialog.md b/skills/panelui-web/components/dialog.md index bfbbeed4a..773d2697f 100644 --- a/skills/panelui-web/components/dialog.md +++ b/skills/panelui-web/components/dialog.md @@ -69,7 +69,7 @@ For a yes-or-no question, use [Alert Dialog](/docs/web/components/alert-dialog). - `Dialog.List` — A list of one-line choices, such as time zones or the projects to move something to. The arrow keys, Home and End move between its items, and it opens scrolled to the selected one. - `Dialog.ListGroup` — A run of items under a `label`, which stays pinned at the top while its items scroll under it. - `Dialog.ListItem` — One choice. `selected` tints and checks it, `onSelect` is called when it is pressed or chosen with Enter, and `meta` puts quieter text at its end in figures that line up. -- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills; in `plain` they sit at the end of a ruled strip. It can sit at any depth inside `Dialog.Content`. +- `Dialog.Footer` — The buttons. In the inset look they sit in the band as equal pills, from any depth inside `Dialog.Content`. In `plain` they stay where written, at the end of a ruled strip, so keep the footer after `Dialog.Body` to hold it outside the scroll. - `Dialog.Close` — Closes the dialog. Pass your own button as `render`, such as Cancel. ## Props From 00dff0af4cf3649300cd556961b970a3e08540ce Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Mon, 9 Nov 2026 19:52:17 -0400 Subject: [PATCH 3/3] docs(web): inset dialog footers need form on submit buttons --- apps/docs/content/docs/web/components/dialog.mdx | 2 +- apps/docs/public/r/web/dialog.json | 2 +- apps/docs/public/skills/panelui-web/components/dialog.md | 2 +- apps/docs/scripts/web-usage/dialog.json | 2 +- packages/web/src/components/dialog/index.tsx | 3 ++- skills/panelui-web/components/dialog.md | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/web/components/dialog.mdx b/apps/docs/content/docs/web/components/dialog.mdx index 6241b82dd..da7df73e7 100644 --- a/apps/docs/content/docs/web/components/dialog.mdx +++ b/apps/docs/content/docs/web/components/dialog.mdx @@ -829,7 +829,7 @@ When it opens, focus goes to the first field in it. The close button is drawn in ### Forms -Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the ``, so it always needs `form`. ### State that starts fresh diff --git a/apps/docs/public/r/web/dialog.json b/apps/docs/public/r/web/dialog.json index cd08c03bd..07dde1abd 100644 --- a/apps/docs/public/r/web/dialog.json +++ b/apps/docs/public/r/web/dialog.json @@ -14,7 +14,7 @@ "path": "components/panelui/dialog.tsx", "type": "registry:component", "target": "@components/panelui/dialog.tsx", - "content": "'use client';\n\n/**\n * Dialog — a task taken out of the page and done in a box over it: renaming\n * something, inviting people, filling in a short form.\n *\n * ```tsx\n * \n * }>Rename\n * \n * \n * \n * \n * \n * \n * }>Cancel\n * \n * \n * \n * \n * ```\n *\n * ## A panel in a band\n *\n * The task sits in a panel, and `Dialog.Footer` moves out of it into the\n * darker band underneath, where the buttons are equal pills: the same shape\n * as Feedback and Alert Dialog, so the three read as one family.\n * `variant=\"plain\"` is a single surface with the footer ruled off at its\n * foot, for a dense form where the band would be one more edge.\n *\n * ## The middle scrolls\n *\n * The dialog is never taller than the screen. The header and footer stay\n * put and `Dialog.Body` scrolls between them, with a thin drawn thumb that\n * shows while it moves.\n *\n * ## Focus goes to the task\n *\n * The close button is drawn in the corner but comes last in the order, so\n * opening the dialog puts focus on its first field rather than on the way\n * out. A press outside, Escape or the close button dismiss it.\n *\n * ## Lists to choose from\n *\n * `Dialog.List` is a list of one-line choices for the body, such as a time\n * zone or a project to move something to: grouped under headings that stay\n * pinned while it scrolls, moved through with the arrow keys, with a check\n * on the chosen one. It opens scrolled to the chosen one.\n *\n * ## On a phone it sits low\n *\n * Under 640px wide it rests at the bottom of the screen, a few pixels clear\n * of the edges, where a thumb reaches its buttons.\n */\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n createContext,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\nconst dialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-hidden p-4 max-sm:items-end max-sm:p-2',\n shell: [\n 'relative flex max-h-[calc(100dvh-2rem)] w-full min-h-0 flex-col text-popover-foreground outline-none max-sm:max-h-[calc(100dvh-1rem)]',\n // A dialog opened from this one steps it back, smaller and veiled, so\n // the depth can be counted.\n 'origin-top scale-[calc(1-0.05*var(--nested-dialogs,0))] translate-y-[calc(-0.75rem*var(--nested-dialogs,0))]',\n \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/0 after:transition-colors after:duration-200 after:content-[''] data-nested-dialog-open:after:bg-black/10 dark:data-nested-dialog-open:after:bg-black/30\",\n ],\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'relative flex min-h-0 flex-1 flex-col',\n header: 'flex shrink-0 flex-col gap-1',\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n body: [\n 'relative flex min-h-0 flex-1 flex-col',\n // A hairline at an edge the content has been scrolled past, so what is\n // cut off reads as going under the header, not as stopping there.\n \"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-20 before:h-px before:bg-border before:opacity-0 before:transition-opacity before:content-[''] data-overflow-y-start:before:opacity-100\",\n ],\n bodyViewport: 'min-h-0 flex-1 overscroll-contain rounded-[inherit] outline-none',\n bodyContent: 'flex flex-col gap-4',\n scrollbar:\n 'my-2 me-1 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n footer: [\n 'flex shrink-0 items-center',\n // The footer sits on a tinted surface, and the secondary fill is a tint\n // too, so the button all but disappears into it. There it takes the\n // panel's own surface instead, which keeps it lighter than the band.\n '*:data-[variant=secondary]:bg-popover *:data-[variant=secondary]:shadow-xs',\n '*:data-[variant=secondary]:hover:not-disabled:bg-[color-mix(in_oklab,var(--popover),var(--foreground)_5%)]',\n ],\n close: [\n 'absolute end-4 top-4 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full',\n 'bg-muted text-muted-foreground outline-none transition-[background-color,color,scale] duration-150',\n 'after:absolute after:-inset-1.5 hover:bg-foreground/10 hover:text-foreground active:scale-95',\n 'focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-4',\n ],\n // Pulled out by the rows' own padding, so a row's text lines up with the\n // header's, and no further: the scrollbar runs in the 12px left at each\n // side, and a heading or a row reaching into it shows through the thumb.\n list: '-mx-2 flex flex-col outline-none',\n listGroup: 'flex flex-col [&+&]:mt-2',\n listGroupLabel: 'sticky top-0 z-10 bg-popover px-2 pb-1.5 pt-2 text-xs font-medium text-muted-foreground',\n listItem: [\n 'flex h-10 shrink-0 cursor-pointer select-none items-center gap-3 rounded-xl px-2 text-sm outline-none',\n // Room for the pinned heading above it when the arrow keys scroll it into view.\n 'scroll-mb-2 scroll-mt-10 transition-colors duration-100 hover:bg-foreground/[0.05]',\n 'focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/60',\n 'data-selected:bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] data-selected:font-medium',\n 'aria-disabled:cursor-default aria-disabled:opacity-50 aria-disabled:hover:bg-transparent motion-reduce:transition-none',\n ],\n listItemLabel: 'min-w-0 flex-1 truncate',\n listItemMeta: 'shrink-0 text-xs font-normal tabular-nums text-muted-foreground',\n listItemCheck: 'flex size-4 shrink-0 items-center justify-center text-primary [&_svg]:size-4',\n },\n variants: {\n variant: {\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in either theme. 38 and 30 are one corner: the\n * panel's radius is the band's less the 8px between them.\n */\n inset: {\n shell:\n 'rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 shadow-xl ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n panel: 'overflow-hidden rounded-[30px] bg-popover shadow-xs',\n header: 'px-5 pb-3 pt-5',\n body: 'first:pt-5',\n bodyContent: 'px-5 pb-5',\n // Held in from the band's edge further than the panel is, with the\n // buttons as equal pills: a row of decisions, not a third edge.\n footer: 'gap-3 px-[10px] pb-0.5 pt-2.5 *:h-11 *:min-w-0 *:flex-1 *:rounded-full',\n },\n plain: {\n shell: 'overflow-hidden rounded-[28px] border bg-popover shadow-xl',\n header: 'px-6 pb-4 pt-6',\n body: 'first:pt-6',\n bodyContent: 'px-6 pb-6',\n footer: 'justify-end gap-2 border-t bg-muted/50 px-6 py-3.5',\n },\n },\n size: {\n sm: { shell: 'max-w-sm' },\n md: { shell: 'max-w-md' },\n lg: { shell: 'max-w-xl' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n },\n defaultVariants: { variant: 'inset', size: 'md' },\n});\n\ninterface DialogContextValue {\n /** Set by `Dialog.Content`. */\n inline: boolean;\n showClose: boolean;\n variant: 'inset' | 'plain';\n /** Where an inset footer portals to. `undefined` outside `Dialog.Content`. */\n band?: HTMLElement | null;\n}\n\nconst DialogContext = createContext({ inline: false, showClose: true, variant: 'inset' });\n\nexport interface DialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /**\n * Whether a press outside it closes it. Turn it off while a form in it has\n * changes that would be lost; Escape and the close button still work.\n * Default `true`.\n */\n dismissible?: boolean;\n}\n\nfunction DialogRoot({\n children,\n open,\n defaultOpen,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: DialogProps) {\n return (\n onOpenChange(next) : undefined}\n onOpenChangeComplete={onOpenChangeComplete}\n disablePointerDismissal={!dismissible}\n >\n {children}\n \n );\n}\nDialogRoot.displayName = 'Dialog';\n\nexport interface DialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction DialogTrigger({ render, children, ...props }: DialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nDialogTrigger.displayName = 'Dialog.Trigger';\n\nexport interface DialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * `inset` sets the task in a panel inside a darker band that holds the\n * footer. `plain` is one surface with the footer ruled off. Default `inset`.\n */\n variant?: 'inset' | 'plain';\n /** How wide it may grow: `sm` 24rem, `md` 28rem, `lg` 36rem. Default `md`. */\n size?: 'sm' | 'md' | 'lg';\n /** Show the round close button in the top trailing corner. Default `true`. */\n showClose?: boolean;\n /** Frost the page behind it instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\n/** The dialog itself, with the dimmed page behind it. */\nfunction DialogContent({\n variant = 'inset',\n size = 'md',\n showClose = true,\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: DialogContentProps) {\n const slots = dialogVariants({ variant, size, blur });\n const [band, setBand] = useState(null);\n const context = useMemo(() => ({ inline, showClose, variant, band }), [inline, showClose, variant, band]);\n\n // In the inset look the footer leaves the panel for the band under it, from any depth.\n const inside = (\n \n
\n {children}\n {/* Last in the order, so focus starts on the task, not the way out. */}\n {showClose ? : null}\n
\n {variant === 'inset' ?
: null}\n \n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nDialogContent.displayName = 'Dialog.Content';\n\nexport interface DialogHeaderProps extends Omit, 'title'> {\n /** The heading. It is also what the dialog is announced as. */\n title?: ReactNode;\n /** A line under the heading. */\n description?: ReactNode;\n}\n\n/** The heading block. It leaves room for the close button beside the title. */\nfunction DialogHeader({\n title,\n description,\n className,\n children,\n render,\n ...props\n}: DialogHeaderProps & RenderProp<'div'>) {\n const { showClose, variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'dialog-header',\n className: slots.header({ className: [showClose && 'pe-14', className] }),\n children: (\n <>\n {title !== undefined ? {title} : null}\n {description !== undefined ? {description} : null}\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogHeader.displayName = 'Dialog.Header';\n\nexport interface DialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The dialog's name, announced when it opens. */\nfunction DialogTitle({ className, ...props }: DialogTitleProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return ;\n}\nDialogTitle.displayName = 'Dialog.Title';\n\nexport interface DialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** A sentence under the title, read out after it. */\nfunction DialogDescription({ className, ...props }: DialogDescriptionProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nDialogDescription.displayName = 'Dialog.Description';\n\nexport interface DialogBodyProps extends ComponentProps<'div'> {}\n\n/**\n * The part that scrolls, between the header and the footer. Its children are\n * laid out in a column with a gap, so a form's fields need no wrapper.\n */\nfunction DialogBody({ className, children, ...props }: DialogBodyProps) {\n const { variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return (\n // With no header above it, it keeps the dialog's top padding itself.\n \n \n \n {children}\n \n \n \n \n \n \n );\n}\nDialogBody.displayName = 'Dialog.Body';\n\nexport interface DialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The buttons. In the inset look they sit in the band under the panel as\n * equal pills, wherever they are written inside `Dialog.Content`; in `plain`\n * they sit at the end of a ruled strip.\n */\nfunction DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) {\n const { variant, band } = useContext(DialogContext);\n const footer = useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'dialog-footer', className: dialogVariants({ variant }).footer({ className }) },\n props\n ),\n });\n if (variant !== 'inset' || band === undefined) return footer;\n // The band mounts in the same commit, so the footer lands there before the first paint.\n return band ? createPortal(footer, band) : null;\n}\nDialogFooter.displayName = 'Dialog.Footer';\n\nexport interface DialogCloseProps extends ComponentProps<'button'> {\n /** Your own element to close the dialog with, such as a Cancel button. */\n render?: ReactElement;\n /** Draw the cross glyph. Set for you on the built-in close button. */\n icon?: boolean;\n}\n\n/** Closes the dialog. */\nfunction DialogClose({ render, icon = false, children, ...props }: DialogCloseProps) {\n const { inline } = useContext(DialogContext);\n const content = icon ? (\n \n \n \n ) : (\n children\n );\n if (inline) {\n return (\n \n );\n }\n return (\n \n {content}\n \n );\n}\nDialogClose.displayName = 'Dialog.Close';\n\nexport interface DialogListProps extends ComponentProps<'div'> {}\n\n/**\n * A list of one-line choices in the body. The arrow keys, Home and End move\n * between its items, and it opens scrolled to the selected one.\n */\nfunction DialogList({ className, onKeyDown, children, render, ...props }: DialogListProps & RenderProp<'div'>) {\n const list = useRef(null);\n const items = () => [\n ...(list.current?.querySelectorAll('[data-slot=dialog-list-item]:not([aria-disabled=true])') ?? []),\n ];\n\n useEffect(() => {\n const all = items();\n const selected = all.find((item) => item.dataset.selected !== undefined);\n // One item takes Tab: the chosen one, or the first when none is.\n if (!selected && all[0]) all[0].tabIndex = 0;\n selected?.scrollIntoView({ block: 'center' });\n }, []);\n\n const move = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n const all = items();\n const index = all.indexOf(document.activeElement as HTMLElement);\n const next =\n event.key === 'ArrowDown'\n ? all[index < 0 ? 0 : Math.min(index + 1, all.length - 1)]\n : event.key === 'ArrowUp'\n ? all[Math.max(index - 1, 0)]\n : event.key === 'Home'\n ? all[0]\n : event.key === 'End'\n ? all[all.length - 1]\n : undefined;\n if (!next) return;\n event.preventDefault();\n next.focus();\n };\n\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n ref: list,\n role: 'listbox',\n 'data-slot': 'dialog-list',\n className: dialogVariants().list({ className }),\n onKeyDown: move,\n children: <>{children},\n },\n props\n ),\n });\n}\nDialogList.displayName = 'Dialog.List';\n\nexport interface DialogListGroupProps extends Omit, 'title'> {\n /** The heading over the group, which stays pinned while its items scroll under it. */\n label: ReactNode;\n}\n\n/** A run of items in `Dialog.List` under a heading of their own. */\nfunction DialogListGroup({ label, className, children, render, ...props }: DialogListGroupProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n const id = useId();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'group',\n 'aria-labelledby': id,\n 'data-slot': 'dialog-list-group',\n className: slots.listGroup({ className }),\n children: (\n <>\n

\n {label}\n
\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogListGroup.displayName = 'Dialog.ListGroup';\n\nexport interface DialogListItemProps extends Omit, 'onSelect'> {\n /** Whether it is the chosen one: it is tinted and checked. */\n selected?: boolean;\n /** Called when it is pressed, or chosen with Enter or Space. */\n onSelect?: () => void;\n /** Quieter text at its end, in figures that line up: a time, a count. */\n meta?: ReactNode;\n /** Shown, but cannot be chosen. */\n disabled?: boolean;\n}\n\n/** One choice in `Dialog.List`, on one line. */\nfunction DialogListItem({\n selected = false,\n onSelect,\n meta,\n disabled = false,\n className,\n onClick,\n onKeyDown,\n children,\n render,\n ...props\n}: DialogListItemProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'option',\n 'aria-selected': selected,\n 'aria-disabled': disabled || undefined,\n tabIndex: selected ? 0 : -1,\n 'data-slot': 'dialog-list-item',\n 'data-selected': selected ? '' : undefined,\n className: slots.listItem({ className }),\n onClick: (event: ReactTypes.MouseEvent) => {\n onClick?.(event);\n if (!disabled) onSelect?.();\n },\n onKeyDown: (event: ReactTypes.KeyboardEvent) => {\n onKeyDown?.(event);\n if ((event.key === 'Enter' || event.key === ' ') && !disabled) {\n event.preventDefault();\n onSelect?.();\n }\n },\n children: (\n <>\n {children}\n {meta !== undefined ? {meta} : null}\n \n {selected ? (\n \n \n \n ) : null}\n \n \n ),\n },\n props\n ),\n });\n}\nDialogListItem.displayName = 'Dialog.ListItem';\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Header: DialogHeader,\n Title: DialogTitle,\n Description: DialogDescription,\n Body: DialogBody,\n Footer: DialogFooter,\n Close: DialogClose,\n List: DialogList,\n ListGroup: DialogListGroup,\n ListItem: DialogListItem,\n});\n\nexport {\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogBody,\n DialogFooter,\n DialogClose,\n DialogList,\n DialogListGroup,\n DialogListItem,\n};\n" + "content": "'use client';\n\n/**\n * Dialog — a task taken out of the page and done in a box over it: renaming\n * something, inviting people, filling in a short form.\n *\n * ```tsx\n * \n * }>Rename\n * \n * \n * \n * \n * \n * \n * }>Cancel\n * \n * \n * \n * \n * ```\n *\n * ## A panel in a band\n *\n * The task sits in a panel, and `Dialog.Footer` moves out of it into the\n * darker band underneath, where the buttons are equal pills: the same shape\n * as Feedback and Alert Dialog, so the three read as one family.\n * `variant=\"plain\"` is a single surface with the footer ruled off at its\n * foot, for a dense form where the band would be one more edge.\n *\n * ## The middle scrolls\n *\n * The dialog is never taller than the screen. The header and footer stay\n * put and `Dialog.Body` scrolls between them, with a thin drawn thumb that\n * shows while it moves.\n *\n * ## Focus goes to the task\n *\n * The close button is drawn in the corner but comes last in the order, so\n * opening the dialog puts focus on its first field rather than on the way\n * out. A press outside, Escape or the close button dismiss it.\n *\n * ## Lists to choose from\n *\n * `Dialog.List` is a list of one-line choices for the body, such as a time\n * zone or a project to move something to: grouped under headings that stay\n * pinned while it scrolls, moved through with the arrow keys, with a check\n * on the chosen one. It opens scrolled to the chosen one.\n *\n * ## On a phone it sits low\n *\n * Under 640px wide it rests at the bottom of the screen, a few pixels clear\n * of the edges, where a thumb reaches its buttons.\n */\nimport { Dialog as Primitive } from '@base-ui/react/dialog';\nimport { ScrollArea } from '@base-ui/react/scroll-area';\nimport {\n createContext,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type KeyboardEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type * as ReactTypes from 'react';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={
}`. */\ntype RenderProp = { render?: useRender.ComponentProps['render'] };\n\nconst dialogVariants = tv({\n slots: {\n backdrop:\n 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',\n viewport: 'fixed inset-0 z-50 grid place-items-center overflow-hidden p-4 max-sm:items-end max-sm:p-2',\n shell: [\n 'relative flex max-h-[calc(100dvh-2rem)] w-full min-h-0 flex-col text-popover-foreground outline-none max-sm:max-h-[calc(100dvh-1rem)]',\n // A dialog opened from this one steps it back, smaller and veiled, so\n // the depth can be counted.\n 'origin-top scale-[calc(1-0.05*var(--nested-dialogs,0))] translate-y-[calc(-0.75rem*var(--nested-dialogs,0))]',\n \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-black/0 after:transition-colors after:duration-200 after:content-[''] data-nested-dialog-open:after:bg-black/10 dark:data-nested-dialog-open:after:bg-black/30\",\n ],\n popup:\n 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',\n panel: 'relative flex min-h-0 flex-1 flex-col',\n header: 'flex shrink-0 flex-col gap-1',\n title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',\n description: 'text-[15px] leading-[22px] text-muted-foreground',\n body: [\n 'relative flex min-h-0 flex-1 flex-col',\n // A hairline at an edge the content has been scrolled past, so what is\n // cut off reads as going under the header, not as stopping there.\n \"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-20 before:h-px before:bg-border before:opacity-0 before:transition-opacity before:content-[''] data-overflow-y-start:before:opacity-100\",\n ],\n bodyViewport: 'min-h-0 flex-1 overscroll-contain rounded-[inherit] outline-none',\n bodyContent: 'flex flex-col gap-4',\n scrollbar:\n 'my-2 me-1 flex w-1.5 opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-scrolling:opacity-100 data-scrolling:delay-0',\n thumb: 'relative flex-1 rounded-full bg-foreground/20',\n footer: [\n 'flex shrink-0 items-center',\n // The footer sits on a tinted surface, and the secondary fill is a tint\n // too, so the button all but disappears into it. There it takes the\n // panel's own surface instead, which keeps it lighter than the band.\n '*:data-[variant=secondary]:bg-popover *:data-[variant=secondary]:shadow-xs',\n '*:data-[variant=secondary]:hover:not-disabled:bg-[color-mix(in_oklab,var(--popover),var(--foreground)_5%)]',\n ],\n close: [\n 'absolute end-4 top-4 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full',\n 'bg-muted text-muted-foreground outline-none transition-[background-color,color,scale] duration-150',\n 'after:absolute after:-inset-1.5 hover:bg-foreground/10 hover:text-foreground active:scale-95',\n 'focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-4',\n ],\n // Pulled out by the rows' own padding, so a row's text lines up with the\n // header's, and no further: the scrollbar runs in the 12px left at each\n // side, and a heading or a row reaching into it shows through the thumb.\n list: '-mx-2 flex flex-col outline-none',\n listGroup: 'flex flex-col [&+&]:mt-2',\n listGroupLabel: 'sticky top-0 z-10 bg-popover px-2 pb-1.5 pt-2 text-xs font-medium text-muted-foreground',\n listItem: [\n 'flex h-10 shrink-0 cursor-pointer select-none items-center gap-3 rounded-xl px-2 text-sm outline-none',\n // Room for the pinned heading above it when the arrow keys scroll it into view.\n 'scroll-mb-2 scroll-mt-10 transition-colors duration-100 hover:bg-foreground/[0.05]',\n 'focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/60',\n 'data-selected:bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] data-selected:font-medium',\n 'aria-disabled:cursor-default aria-disabled:opacity-50 aria-disabled:hover:bg-transparent motion-reduce:transition-none',\n ],\n listItemLabel: 'min-w-0 flex-1 truncate',\n listItemMeta: 'shrink-0 text-xs font-normal tabular-nums text-muted-foreground',\n listItemCheck: 'flex size-4 shrink-0 items-center justify-center text-primary [&_svg]:size-4',\n },\n variants: {\n variant: {\n /*\n * The band is the popover surface with black mixed in, so it is the\n * darker of the two in either theme. 38 and 30 are one corner: the\n * panel's radius is the band's less the 8px between them.\n */\n inset: {\n shell:\n 'rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 shadow-xl ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',\n panel: 'overflow-hidden rounded-[30px] bg-popover shadow-xs',\n header: 'px-5 pb-3 pt-5',\n body: 'first:pt-5',\n bodyContent: 'px-5 pb-5',\n // Held in from the band's edge further than the panel is, with the\n // buttons as equal pills: a row of decisions, not a third edge.\n footer: 'gap-3 px-[10px] pb-0.5 pt-2.5 *:h-11 *:min-w-0 *:flex-1 *:rounded-full',\n },\n plain: {\n shell: 'overflow-hidden rounded-[28px] border bg-popover shadow-xl',\n header: 'px-6 pb-4 pt-6',\n body: 'first:pt-6',\n bodyContent: 'px-6 pb-6',\n footer: 'justify-end gap-2 border-t bg-muted/50 px-6 py-3.5',\n },\n },\n size: {\n sm: { shell: 'max-w-sm' },\n md: { shell: 'max-w-md' },\n lg: { shell: 'max-w-xl' },\n },\n blur: {\n true: { backdrop: 'bg-black/20 backdrop-blur-md' },\n },\n },\n defaultVariants: { variant: 'inset', size: 'md' },\n});\n\ninterface DialogContextValue {\n /** Set by `Dialog.Content`. */\n inline: boolean;\n showClose: boolean;\n variant: 'inset' | 'plain';\n /** Where an inset footer portals to. `undefined` outside `Dialog.Content`. */\n band?: HTMLElement | null;\n}\n\nconst DialogContext = createContext({ inline: false, showClose: true, variant: 'inset' });\n\nexport interface DialogProps {\n children?: ReactNode;\n /** Whether it is open, when the caller holds that. */\n open?: boolean;\n /** Whether it starts open, when the caller does not hold it. */\n defaultOpen?: boolean;\n /** Called when it opens or closes. */\n onOpenChange?: (open: boolean) => void;\n /**\n * Called once it has finished opening or closing, after its transition has\n * ended. `false` means the exit is over, so a dialog mounted from state can\n * be unmounted.\n */\n onOpenChangeComplete?: (open: boolean) => void;\n /**\n * Whether a press outside it closes it. Turn it off while a form in it has\n * changes that would be lost; Escape and the close button still work.\n * Default `true`.\n */\n dismissible?: boolean;\n}\n\nfunction DialogRoot({\n children,\n open,\n defaultOpen,\n onOpenChange,\n onOpenChangeComplete,\n dismissible = true,\n}: DialogProps) {\n return (\n onOpenChange(next) : undefined}\n onOpenChangeComplete={onOpenChangeComplete}\n disablePointerDismissal={!dismissible}\n >\n {children}\n \n );\n}\nDialogRoot.displayName = 'Dialog';\n\nexport interface DialogTriggerProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * Your own element to be the trigger, such as a styled button. It is given\n * the click handler and the attributes that tie it to the dialog.\n */\n render?: ReactElement;\n}\n\n/** What opens the dialog. A plain button unless `render` supplies one. */\nfunction DialogTrigger({ render, children, ...props }: DialogTriggerProps) {\n return (\n \n {children}\n \n );\n}\nDialogTrigger.displayName = 'Dialog.Trigger';\n\nexport interface DialogContentProps extends Omit, 'children'> {\n children?: ReactNode;\n /**\n * `inset` sets the task in a panel inside a darker band that holds the\n * footer. `plain` is one surface with the footer ruled off. Default `inset`.\n */\n variant?: 'inset' | 'plain';\n /** How wide it may grow: `sm` 24rem, `md` 28rem, `lg` 36rem. Default `md`. */\n size?: 'sm' | 'md' | 'lg';\n /** Show the round close button in the top trailing corner. Default `true`. */\n showClose?: boolean;\n /** Frost the page behind it instead of only dimming it. */\n blur?: boolean;\n /**\n * Draw it in the page instead of over it: no backdrop, no trigger, always\n * there. For previews; `open` is ignored.\n */\n inline?: boolean;\n}\n\n/** The dialog itself, with the dimmed page behind it. */\nfunction DialogContent({\n variant = 'inset',\n size = 'md',\n showClose = true,\n blur = false,\n inline = false,\n className,\n children,\n ...props\n}: DialogContentProps) {\n const slots = dialogVariants({ variant, size, blur });\n const [band, setBand] = useState(null);\n const context = useMemo(() => ({ inline, showClose, variant, band }), [inline, showClose, variant, band]);\n\n // In the inset look the footer leaves the panel for the band under it, from any depth.\n const inside = (\n \n
\n {children}\n {/* Last in the order, so focus starts on the task, not the way out. */}\n {showClose ? : null}\n
\n {variant === 'inset' ?
: null}\n \n );\n\n if (inline) {\n return (\n
\n {inside}\n
\n );\n }\n\n return (\n \n \n \n \n {inside}\n \n \n \n );\n}\nDialogContent.displayName = 'Dialog.Content';\n\nexport interface DialogHeaderProps extends Omit, 'title'> {\n /** The heading. It is also what the dialog is announced as. */\n title?: ReactNode;\n /** A line under the heading. */\n description?: ReactNode;\n}\n\n/** The heading block. It leaves room for the close button beside the title. */\nfunction DialogHeader({\n title,\n description,\n className,\n children,\n render,\n ...props\n}: DialogHeaderProps & RenderProp<'div'>) {\n const { showClose, variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n 'data-slot': 'dialog-header',\n className: slots.header({ className: [showClose && 'pe-14', className] }),\n children: (\n <>\n {title !== undefined ? {title} : null}\n {description !== undefined ? {description} : null}\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogHeader.displayName = 'Dialog.Header';\n\nexport interface DialogTitleProps extends ComponentProps<'h2'> {}\n\n/** The dialog's name, announced when it opens. */\nfunction DialogTitle({ className, ...props }: DialogTitleProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return ;\n}\nDialogTitle.displayName = 'Dialog.Title';\n\nexport interface DialogDescriptionProps extends ComponentProps<'p'> {}\n\n/** A sentence under the title, read out after it. */\nfunction DialogDescription({ className, ...props }: DialogDescriptionProps) {\n const { inline } = useContext(DialogContext);\n const slots = dialogVariants();\n if (inline) return

;\n return (\n \n );\n}\nDialogDescription.displayName = 'Dialog.Description';\n\nexport interface DialogBodyProps extends ComponentProps<'div'> {}\n\n/**\n * The part that scrolls, between the header and the footer. Its children are\n * laid out in a column with a gap, so a form's fields need no wrapper.\n */\nfunction DialogBody({ className, children, ...props }: DialogBodyProps) {\n const { variant } = useContext(DialogContext);\n const slots = dialogVariants({ variant });\n return (\n // With no header above it, it keeps the dialog's top padding itself.\n \n \n \n {children}\n \n \n \n \n \n \n );\n}\nDialogBody.displayName = 'Dialog.Body';\n\nexport interface DialogFooterProps extends ComponentProps<'div'> {}\n\n/**\n * The buttons. In the inset look they sit in the band under the panel as\n * equal pills, wherever they are written inside `Dialog.Content`; in `plain`\n * they sit at the end of a ruled strip. Moving to the band takes them out of\n * any ``, so a submit button needs `form` set to the form's `id`.\n */\nfunction DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) {\n const { variant, band } = useContext(DialogContext);\n const footer = useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n { 'data-slot': 'dialog-footer', className: dialogVariants({ variant }).footer({ className }) },\n props\n ),\n });\n if (variant !== 'inset' || band === undefined) return footer;\n // The band mounts in the same commit, so the footer lands there before the first paint.\n return band ? createPortal(footer, band) : null;\n}\nDialogFooter.displayName = 'Dialog.Footer';\n\nexport interface DialogCloseProps extends ComponentProps<'button'> {\n /** Your own element to close the dialog with, such as a Cancel button. */\n render?: ReactElement;\n /** Draw the cross glyph. Set for you on the built-in close button. */\n icon?: boolean;\n}\n\n/** Closes the dialog. */\nfunction DialogClose({ render, icon = false, children, ...props }: DialogCloseProps) {\n const { inline } = useContext(DialogContext);\n const content = icon ? (\n \n \n \n ) : (\n children\n );\n if (inline) {\n return (\n \n );\n }\n return (\n \n {content}\n \n );\n}\nDialogClose.displayName = 'Dialog.Close';\n\nexport interface DialogListProps extends ComponentProps<'div'> {}\n\n/**\n * A list of one-line choices in the body. The arrow keys, Home and End move\n * between its items, and it opens scrolled to the selected one.\n */\nfunction DialogList({ className, onKeyDown, children, render, ...props }: DialogListProps & RenderProp<'div'>) {\n const list = useRef(null);\n const items = () => [\n ...(list.current?.querySelectorAll('[data-slot=dialog-list-item]:not([aria-disabled=true])') ?? []),\n ];\n\n useEffect(() => {\n const all = items();\n const selected = all.find((item) => item.dataset.selected !== undefined);\n // One item takes Tab: the chosen one, or the first when none is.\n if (!selected && all[0]) all[0].tabIndex = 0;\n selected?.scrollIntoView({ block: 'center' });\n }, []);\n\n const move = (event: KeyboardEvent) => {\n onKeyDown?.(event);\n const all = items();\n const index = all.indexOf(document.activeElement as HTMLElement);\n const next =\n event.key === 'ArrowDown'\n ? all[index < 0 ? 0 : Math.min(index + 1, all.length - 1)]\n : event.key === 'ArrowUp'\n ? all[Math.max(index - 1, 0)]\n : event.key === 'Home'\n ? all[0]\n : event.key === 'End'\n ? all[all.length - 1]\n : undefined;\n if (!next) return;\n event.preventDefault();\n next.focus();\n };\n\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n ref: list,\n role: 'listbox',\n 'data-slot': 'dialog-list',\n className: dialogVariants().list({ className }),\n onKeyDown: move,\n children: <>{children},\n },\n props\n ),\n });\n}\nDialogList.displayName = 'Dialog.List';\n\nexport interface DialogListGroupProps extends Omit, 'title'> {\n /** The heading over the group, which stays pinned while its items scroll under it. */\n label: ReactNode;\n}\n\n/** A run of items in `Dialog.List` under a heading of their own. */\nfunction DialogListGroup({ label, className, children, render, ...props }: DialogListGroupProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n const id = useId();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'group',\n 'aria-labelledby': id,\n 'data-slot': 'dialog-list-group',\n className: slots.listGroup({ className }),\n children: (\n <>\n

\n {label}\n
\n {children}\n \n ),\n },\n props\n ),\n });\n}\nDialogListGroup.displayName = 'Dialog.ListGroup';\n\nexport interface DialogListItemProps extends Omit, 'onSelect'> {\n /** Whether it is the chosen one: it is tinted and checked. */\n selected?: boolean;\n /** Called when it is pressed, or chosen with Enter or Space. */\n onSelect?: () => void;\n /** Quieter text at its end, in figures that line up: a time, a count. */\n meta?: ReactNode;\n /** Shown, but cannot be chosen. */\n disabled?: boolean;\n}\n\n/** One choice in `Dialog.List`, on one line. */\nfunction DialogListItem({\n selected = false,\n onSelect,\n meta,\n disabled = false,\n className,\n onClick,\n onKeyDown,\n children,\n render,\n ...props\n}: DialogListItemProps & RenderProp<'div'>) {\n const slots = dialogVariants();\n return useRender({\n defaultTagName: 'div',\n render,\n props: mergeProps(\n {\n role: 'option',\n 'aria-selected': selected,\n 'aria-disabled': disabled || undefined,\n tabIndex: selected ? 0 : -1,\n 'data-slot': 'dialog-list-item',\n 'data-selected': selected ? '' : undefined,\n className: slots.listItem({ className }),\n onClick: (event: ReactTypes.MouseEvent) => {\n onClick?.(event);\n if (!disabled) onSelect?.();\n },\n onKeyDown: (event: ReactTypes.KeyboardEvent) => {\n onKeyDown?.(event);\n if ((event.key === 'Enter' || event.key === ' ') && !disabled) {\n event.preventDefault();\n onSelect?.();\n }\n },\n children: (\n <>\n {children}\n {meta !== undefined ? {meta} : null}\n \n {selected ? (\n \n \n \n ) : null}\n \n \n ),\n },\n props\n ),\n });\n}\nDialogListItem.displayName = 'Dialog.ListItem';\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Header: DialogHeader,\n Title: DialogTitle,\n Description: DialogDescription,\n Body: DialogBody,\n Footer: DialogFooter,\n Close: DialogClose,\n List: DialogList,\n ListGroup: DialogListGroup,\n ListItem: DialogListItem,\n});\n\nexport {\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogBody,\n DialogFooter,\n DialogClose,\n DialogList,\n DialogListGroup,\n DialogListItem,\n};\n" } ] } diff --git a/apps/docs/public/skills/panelui-web/components/dialog.md b/apps/docs/public/skills/panelui-web/components/dialog.md index 773d2697f..27a865f52 100644 --- a/apps/docs/public/skills/panelui-web/components/dialog.md +++ b/apps/docs/public/skills/panelui-web/components/dialog.md @@ -165,7 +165,7 @@ When it opens, focus goes to the first field in it. The close button is drawn in #### Forms -Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the ``, so it always needs `form`. #### State that starts fresh diff --git a/apps/docs/scripts/web-usage/dialog.json b/apps/docs/scripts/web-usage/dialog.json index 08375cebe..4a9f79cdc 100644 --- a/apps/docs/scripts/web-usage/dialog.json +++ b/apps/docs/scripts/web-usage/dialog.json @@ -52,5 +52,5 @@ "height": 180 } ], - "notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it.\n\n### State that starts fresh\n\nEverything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap." + "notes": "### Closing\n\nA press outside, Escape and the close button close it, and so does any `Dialog.Close`. `dismissible={false}` stops a press outside from closing it, for a form whose changes would be lost; Escape still works. Hold `open` yourself with `onOpenChange` to close it from code, for example once a form has saved.\n\n### Focus\n\nWhen it opens, focus goes to the first field in it. The close button is drawn in the corner but comes last in the order, so the way out is not the first thing focused. Focus returns to the trigger when it closes.\n\n### Forms\n\nPut the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type=\"submit\"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the ``, so it always needs `form`.\n\n### State that starts fresh\n\nEverything inside `Dialog.Content` unmounts once the dialog has closed, and `Dialog.Footer` still reaches the band from inside a component of your own. Keep a form's state in a component rendered inside `Dialog.Content` and it starts empty on every opening, while the root stays mounted so opening and closing both animate.\n\n### On a phone\n\nUnder 640px wide the dialog sits at the bottom of the screen, 8px clear of the edges, and can be as tall as the screen less that gap." } diff --git a/packages/web/src/components/dialog/index.tsx b/packages/web/src/components/dialog/index.tsx index 176dbd156..428db69f0 100644 --- a/packages/web/src/components/dialog/index.tsx +++ b/packages/web/src/components/dialog/index.tsx @@ -416,7 +416,8 @@ export interface DialogFooterProps extends ComponentProps<'div'> {} /** * The buttons. In the inset look they sit in the band under the panel as * equal pills, wherever they are written inside `Dialog.Content`; in `plain` - * they sit at the end of a ruled strip. + * they sit at the end of a ruled strip. Moving to the band takes them out of + * any ``, so a submit button needs `form` set to the form's `id`. */ function DialogFooter({ className, render, ...props }: DialogFooterProps & RenderProp<'div'>) { const { variant, band } = useContext(DialogContext); diff --git a/skills/panelui-web/components/dialog.md b/skills/panelui-web/components/dialog.md index 773d2697f..27a865f52 100644 --- a/skills/panelui-web/components/dialog.md +++ b/skills/panelui-web/components/dialog.md @@ -165,7 +165,7 @@ When it opens, focus goes to the first field in it. The close button is drawn in #### Forms -Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. +Put the `` in `Dialog.Body` with an `id`, and give the submit button in the footer `type="submit"` and `form` set to that `id`. Enter in a field then submits it. The inset footer moves to the band even when it is written inside the ``, so it always needs `form`. #### State that starts fresh