|
| 1 | +# Dialog — Headless Spec |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Dialog is a modal or non-modal overlay container that presents focused content or a confirmation action. It manages focus trapping, backdrop interaction, dismissal, and keyboard navigation. Dialog can operate in modal mode (blocking background interaction) or non-modal mode (allowing background interaction). It composes a header (optional with close button and optional subtitle/image), body (scrollable content area), and footer (action buttons). |
| 6 | + |
| 7 | +## Composition |
| 8 | + |
| 9 | +``` |
| 10 | +Dialog |
| 11 | +├── DialogTrigger (optional — opens dialog) |
| 12 | +├── DialogSurface |
| 13 | +│ ├── DialogHeader |
| 14 | +│ │ ├── DialogTitle |
| 15 | +│ │ ├── DialogSubtitle (optional) |
| 16 | +│ │ ├── DialogImage (optional — can be thumbnail or full-width inset) |
| 17 | +│ │ └── DialogCloseButton (optional) |
| 18 | +│ ├── DialogBody |
| 19 | +│ │ └── children / content slots |
| 20 | +│ └── DialogActions |
| 21 | +│ └── action buttons (1–3 actions) |
| 22 | +└── DialogBackdrop (when modal) |
| 23 | +``` |
| 24 | + |
| 25 | +Dialog is a compound component. DialogTrigger is optional if opened programmatically via `open` prop. |
| 26 | + |
| 27 | +## Props API |
| 28 | + |
| 29 | +| Prop | Type | Default | Description | |
| 30 | +| ---------------- | ------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------- | |
| 31 | +| `open` | `boolean` | `undefined` | Controlled: whether dialog is visible. Omit for uncontrolled. | |
| 32 | +| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility state. | |
| 33 | +| `onOpenChange` | `(open: boolean, data?: DialogOpenChangeData) => void` | — | Fires when open state changes (user action or programmatic). | |
| 34 | +| `modal` | `boolean` | `true` | If `true`, backdrop prevents background interaction. If `false`, non-modal (can interact with page). | |
| 35 | +| `inertTrapFocus` | `boolean` | `true` | When `true`, focus is trapped inside dialog (modal behaviour). | |
| 36 | +| `onDismiss` | `() => void` | — | Fires when dialog requests closure (Escape key, close button, backdrop click in modal mode). | |
| 37 | + |
| 38 | +## States |
| 39 | + |
| 40 | +| State | Trigger | Behaviour | ARIA attribute | |
| 41 | +| ---------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 42 | +| **Rest** | Dialog mounted with `open={true}` or `defaultOpen={true}` | Dialog visible, focus initially set to first focusable element or explicitly via `initialFocusRef`. Backdrop rendered (if modal). | `role="dialog"` (or `alertdialog` for alerts); `aria-labelledby` points to DialogTitle; `aria-modal="true"` (modal) or `aria-modal="false"` (non-modal). | |
| 43 | +| **Closed** | `open={false}` or user dismisses | Dialog removed from DOM or hidden; focus restored to trigger element (if DialogTrigger used). | No dialog role attributes present. | |
| 44 | +| **Escape Pressed** | User presses Escape key | If `inertTrapFocus={true}`, `onDismiss()` fires → typically closes dialog. Non-modal dialogs do not close on Escape by default. | No ARIA change; behaviour is prop-driven. | |
| 45 | +| **Backdrop Click** | User clicks backdrop overlay (modal only) | In modal mode, `onDismiss()` fires. Non-modal dialogs have no backdrop. | No ARIA change. | |
| 46 | +| **Focus Trapped** | Dialog is modal (`modal={true}` and `inertTrapFocus={true}`) | Tab/Shift+Tab wrap within dialog; focus cannot leave dialog boundary. | `aria-modal="true"`. | |
| 47 | +| **Scrollable Content** | Body content exceeds container height | Body region becomes scrollable. Header and Footer remain fixed. | Body container has `overflow: auto` (visual only). Announce scrollable region via `aria-live="polite"` if content changes dynamically. | |
| 48 | + |
| 49 | +## Keyboard Navigation |
| 50 | + |
| 51 | +| Key | Action | |
| 52 | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 53 | +| **Tab** | Move focus to next focusable element within dialog (or wrap to first if at last). Blocked from leaving dialog boundary when `inertTrapFocus={true}`. | |
| 54 | +| **Shift + Tab** | Move focus to previous focusable element within dialog (or wrap to last if at first). Blocked from leaving dialog boundary. | |
| 55 | +| **Escape** | Close dialog by triggering `onDismiss()` (if modal or if configured). Non-modal dialogs may not close on Escape depending on use case. | |
| 56 | +| **Enter / Space** | Activate focused button in DialogActions. Standard button activation. | |
| 57 | +| **Home / End** | No special handling at dialog level. Passed through to content (e.g., list inside body). | |
| 58 | + |
| 59 | +## Content Slots |
| 60 | + |
| 61 | +### DialogHeader |
| 62 | + |
| 63 | +- **DialogTitle** (text slot) — primary heading. Required for accessibility (used in `aria-labelledby`). |
| 64 | +- **DialogSubtitle** (optional text slot) — secondary heading or description. |
| 65 | +- **DialogImage** (optional element slot) — image element. Can be thumbnail (start-aligned) or full-width inset (top-aligned, spans width). |
| 66 | +- **DialogCloseButton** (optional element) — typically an icon button with `aria-label="Close"`. Triggers `onDismiss()` on click. |
| 67 | + |
| 68 | +### DialogBody |
| 69 | + |
| 70 | +- **children** / **content** (element slot) — main content. Can include text, images, forms, lists. Scrollable if content exceeds container height. |
| 71 | + |
| 72 | +### DialogActions |
| 73 | + |
| 74 | +- **action buttons** (element slot(s)) — 1–3 button elements, typically: |
| 75 | + |
| 76 | + - **1 action**: Single primary or dismiss button. |
| 77 | + - **2 actions**: Primary + Secondary (e.g., "Confirm" + "Cancel"). |
| 78 | + - **3 actions**: Primary + Secondary + Tertiary (e.g., "Save" + "Discard" + "Cancel"). |
| 79 | + |
| 80 | + Button layout may reflow from row (≥480px) to column (<480px) based on viewport. |
| 81 | + |
| 82 | +## Events |
| 83 | + |
| 84 | +| Event | Signature | When it fires | |
| 85 | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 86 | +| `onOpenChange` | `(open: boolean, data?: { trigger?: 'trigger' \| 'escapeKey' \| 'backdropClick' \| 'closeButton' \| 'programmatic' }) => void` | When dialog open state changes. Fires on mount/unmount, Escape key, backdrop click, close button click, or programmatic `open` prop change. | |
| 87 | +| `onDismiss` | `() => void` | When dialog receives a close request (Escape key, backdrop click in modal mode, or close button click). Does not close automatically; consumer must set `open={false}`. | |
| 88 | + |
| 89 | +## Accessibility |
| 90 | + |
| 91 | +### ARIA Pattern |
| 92 | + |
| 93 | +```tsx |
| 94 | +<div |
| 95 | + role="dialog" // or "alertdialog" for alert dialogs |
| 96 | + aria-modal={modal} // "true" (modal) or "false" (non-modal) |
| 97 | + aria-labelledby="dialog-title-id" // required; points to DialogTitle |
| 98 | + aria-describedby="dialog-description-id" // optional; points to DialogBody or subtitle |
| 99 | +> |
| 100 | + {/* header, body, footer */} |
| 101 | +</div> |
| 102 | +``` |
| 103 | + |
| 104 | +### Role Selection |
| 105 | + |
| 106 | +- **dialog**: General-purpose dialog (confirmation, settings, input forms). |
| 107 | +- **alertdialog**: Alerts or warnings requiring immediate user acknowledgment (ARIA 1.2 spec). |
| 108 | + |
| 109 | +### Focus Management |
| 110 | + |
| 111 | +- **Initial focus**: Set to first focusable element (button, input) or explicit `initialFocusRef` prop if provided. |
| 112 | +- **Focus trap**: When `inertTrapFocus={true}`, Tab/Shift+Tab wrap within dialog boundary; prevent focus escape. |
| 113 | +- **Restore focus**: On close, restore focus to DialogTrigger or document.activeElement predecessor. |
| 114 | + |
| 115 | +### Live Regions |
| 116 | + |
| 117 | +- If DialogBody contains dynamic content (e.g., async-loaded messages), wrap in `aria-live="polite" aria-atomic="false"` to announce changes. |
| 118 | + |
| 119 | +### Close Button |
| 120 | + |
| 121 | +- DialogCloseButton must have `aria-label="Close"` or equivalent accessible name. |
| 122 | +- Keyboard-accessible: Enter/Space keys activate. |
| 123 | + |
| 124 | +### Labeling |
| 125 | + |
| 126 | +- DialogTitle (`aria-labelledby`) is mandatory for accessible dialogs. |
| 127 | +- DialogSubtitle or additional context can use `aria-describedby` pointing to body or subtitle element. |
| 128 | + |
| 129 | +## Controlled vs Uncontrolled |
| 130 | + |
| 131 | +### Uncontrolled (recommended for simple cases) |
| 132 | + |
| 133 | +Dialog manages its own `open` state internally: |
| 134 | + |
| 135 | +```tsx |
| 136 | +<Dialog defaultOpen={false}> |
| 137 | + <DialogTrigger> |
| 138 | + <Button>Open Dialog</Button> |
| 139 | + </DialogTrigger> |
| 140 | + <DialogSurface> |
| 141 | + <DialogHeader> |
| 142 | + <DialogTitle>Confirm Action</DialogTitle> |
| 143 | + <DialogCloseButton /> |
| 144 | + </DialogHeader> |
| 145 | + <DialogBody>Are you sure?</DialogBody> |
| 146 | + <DialogActions> |
| 147 | + <Button onClick={() => /* handle confirm */}>Confirm</Button> |
| 148 | + <Button onClick={() => /* dismiss will be called */}>Cancel</Button> |
| 149 | + </DialogActions> |
| 150 | + </DialogSurface> |
| 151 | +</Dialog> |
| 152 | +``` |
| 153 | + |
| 154 | +### Controlled (when parent component manages state) |
| 155 | + |
| 156 | +Parent controls `open` prop and responds to `onOpenChange`: |
| 157 | + |
| 158 | +```tsx |
| 159 | +const [isOpen, setIsOpen] = useState(false); |
| 160 | + |
| 161 | +<Dialog open={isOpen} onOpenChange={newOpen => setIsOpen(newOpen)} onDismiss={() => setIsOpen(false)}> |
| 162 | + <DialogTrigger> |
| 163 | + <Button onClick={() => setIsOpen(true)}>Open</Button> |
| 164 | + </DialogTrigger> |
| 165 | + <DialogSurface> |
| 166 | + <DialogHeader> |
| 167 | + <DialogTitle>Title</DialogTitle> |
| 168 | + </DialogHeader> |
| 169 | + <DialogBody>Content</DialogBody> |
| 170 | + <DialogActions> |
| 171 | + <Button onClick={() => setIsOpen(false)}>Close</Button> |
| 172 | + </DialogActions> |
| 173 | + </DialogSurface> |
| 174 | +</Dialog>; |
| 175 | +``` |
| 176 | + |
| 177 | +## RTL Support |
| 178 | + |
| 179 | +Dialog layout mirrors in RTL (right-to-left) contexts: |
| 180 | + |
| 181 | +- **DialogHeader**: Close button moves from top-right to top-left. |
| 182 | +- **DialogActions**: Button order reverses (rightmost becomes leftmost). |
| 183 | +- **DialogImage**: Full-width inset images remain unaffected (visual content); thumbnails move from start to end position. |
| 184 | +- Implement via CSS `direction: rtl` or logical properties (`inset-inline-start`, `inset-inline-end`). |
| 185 | + |
| 186 | +## Positioning |
| 187 | + |
| 188 | +Dialog positioning is **not** a headless concern (purely visual). However, the component must: |
| 189 | + |
| 190 | +- Expose a `containerRef` or `dialogSurfaceRef` prop to allow consumers to position/portal the dialog via CSS or JS (e.g., centering, fixed overlay). |
| 191 | +- Support React `createPortal()` or custom mounting logic. |
| 192 | +- Manage **backdrop** (visual layer behind dialog preventing background interaction in modal mode) — backstop click triggers `onDismiss()` in modal dialogs. |
| 193 | + |
| 194 | +Example portal structure (visual rendering omitted): |
| 195 | + |
| 196 | +```tsx |
| 197 | +<Portal> |
| 198 | + <DialogBackdrop onClick={onBackdropClick} /> |
| 199 | + <DialogSurface ref={dialogSurfaceRef} role="dialog" aria-modal={modal}> |
| 200 | + {/* header, body, footer */} |
| 201 | + </DialogSurface> |
| 202 | +</Portal> |
| 203 | +``` |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +## Notes |
| 208 | + |
| 209 | +- **Modal vs Non-Modal**: Modal dialogs block background interaction and trap focus. Non-modal dialogs allow background interaction but may still trap focus depending on `inertTrapFocus`. |
| 210 | +- **Dismissal**: Dialog does not auto-close on action button clicks; parent must call `setOpen(false)` or equivalent after receiving the action event. |
| 211 | +- **Scrolling**: Body content is independently scrollable when it exceeds container bounds. Header/Footer remain fixed (visual, not headless concern). |
| 212 | +- **Responsive**: Footer button layout (row vs column) is responsive and controlled via layout logic, not headless state. |
0 commit comments