UNPKG

@loke/ui

Version:
232 lines (186 loc) 6.75 kB
--- name: dialog type: core domain: overlays requires: [loke-ui] description: > Modal and non-modal dialogs. Dialog, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose. Focus trapping, scroll locking, accessible labelling, forceMount animation. modal=true default. --- # Dialog ## Setup Minimum working modal dialog. Every named sub-component is required for accessibility and correct behavior. ```tsx import { Dialog, DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from "@loke/ui/dialog"; function Example() { return ( <Dialog> <DialogTrigger>Open</DialogTrigger> <DialogPortal> <DialogOverlay /> <DialogContent> <DialogTitle>Confirm changes</DialogTitle> <DialogDescription>Your changes will be saved.</DialogDescription> <DialogClose>Close</DialogClose> </DialogContent> </DialogPortal> </Dialog> ); } ``` `Dialog` defaults to `modal={true}`. `DialogOverlay` applies `RemoveScroll` to lock background scroll. `DialogPortal` renders into `document.body` via `createPortal`. ## Core Patterns ### Controlled open state ```tsx import { useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, } from "@loke/ui/dialog"; function ControlledDialog() { const [open, setOpen] = useState(false); return ( <Dialog open={open} onOpenChange={setOpen}> <button type="button" onClick={() => setOpen(true)}> Open </button> <DialogPortal> <DialogOverlay /> <DialogContent> <DialogTitle>Settings</DialogTitle> <DialogDescription>Adjust your preferences.</DialogDescription> <button type="button" onClick={() => setOpen(false)}> Save </button> </DialogContent> </DialogPortal> </Dialog> ); } ``` When `open` is provided, `Dialog` is fully controlled. `onOpenChange` fires on Escape, overlay click, and `DialogClose` clicks. ### Non-modal dialog ```tsx import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger, } from "@loke/ui/dialog"; function NonModalExample() { return ( <Dialog modal={false}> <DialogTrigger>Open panel</DialogTrigger> {/* No DialogPortal or DialogOverlay needed for non-modal */} <DialogContent> <DialogTitle>Side panel</DialogTitle> <DialogDescription>Background remains interactive.</DialogDescription> </DialogContent> </Dialog> ); } ``` `modal={false}` disables focus trapping and pointer event blocking. Background stays interactive. `DialogOverlay` renders `null` in non-modal mode so it can be omitted. ### Animated entry/exit with forceMount Without `forceMount`, elements unmount immediately on close — CSS exit animations never run. ```tsx import { Dialog, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from "@loke/ui/dialog"; function AnimatedDialog() { return ( <Dialog> <DialogTrigger>Open</DialogTrigger> <DialogPortal forceMount> <DialogOverlay forceMount className="data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" /> <DialogContent forceMount className="data-[state=open]:animate-slide-in data-[state=closed]:animate-slide-out" > <DialogTitle>Animated dialog</DialogTitle> <DialogDescription>Enters and exits with animation.</DialogDescription> </DialogContent> </DialogPortal> </Dialog> ); } ``` `forceMount` on `DialogPortal` propagates to children automatically. You can also set it individually on `DialogOverlay` and `DialogContent`. Use `data-state="open"` and `data-state="closed"` as CSS animation hooks. ## Common Mistakes ### Missing DialogTitle inside DialogContent `DialogContent` wires `aria-labelledby` to the `DialogTitle` id. Without it, a `console.error` fires in dev and the dialog is inaccessible to screen readers in production. The error check runs in `TitleWarning` after mount. ```tsx // Wrong — no title <DialogContent> <p>Are you sure?</p> <DialogClose>OK</DialogClose> </DialogContent> // Correct <DialogContent> <DialogTitle>Confirm</DialogTitle> <DialogDescription>Are you sure?</DialogDescription> <DialogClose>OK</DialogClose> </DialogContent> ``` To visually hide the title while keeping it accessible, wrap it with `@loke/ui/visually-hidden`. Source: `src/components/dialog/dialog.tsx``TitleWarning` dev check. ### Missing DialogOverlay in modal dialog `DialogOverlay` wraps content in `RemoveScroll`, which locks background scroll. Without it, the page scrolls freely behind a modal dialog. `DialogOverlay` also provides the visual backdrop. ```tsx // Wrong — no overlay <DialogPortal> <DialogContent>...</DialogContent> </DialogPortal> // Correct <DialogPortal> <DialogOverlay /> <DialogContent>...</DialogContent> </DialogPortal> ``` Source: `src/components/dialog/dialog.tsx``DialogOverlayImpl` uses `RemoveScroll`. ### Using Dialog for destructive confirmations `Dialog` allows outside dismiss by default (in non-modal mode) and does not enforce cancel/action button semantics. Use `AlertDialog` for delete, discard, or any irreversible action. `AlertDialog` forces `modal={true}`, prevents outside dismiss, and auto-focuses the cancel button. Source: `src/components/alert-dialog/alert-dialog.tsx`. ### Forgetting forceMount on animated exit Elements controlled by `Presence` unmount immediately when `open` becomes false. Exit animations require the element to stay mounted during the animation. Set `forceMount` and drive animation via `data-state`. ```tsx // Wrong — element unmounts before animation runs <DialogPortal> <DialogOverlay className="animate-fade" /> </DialogPortal> // Correct <DialogPortal forceMount> <DialogOverlay forceMount className="data-[state=closed]:animate-fade" /> </DialogPortal> ``` Source: `src/components/presence/presence.tsx` — Presence state machine. ### Skipping DialogPortal Without `DialogPortal`, `DialogContent` renders inline in the DOM. This causes z-index stacking and `overflow: hidden` clipping issues. Always portal modal content. ## Cross-references - See also: [alert-dialog](../alert-dialog/SKILL.md) — for destructive confirmations - See also: [popover](../popover/SKILL.md) — for non-blocking floating panels - See also: [overlay-infrastructure](../overlay-infrastructure/SKILL.md) — DismissableLayer, FocusScope, Presence internals