@loke/ui
Version:
232 lines (186 loc) • 6.75 kB
Markdown
---
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