Skip to content

Commit 4756919

Browse files
dmytrokirpacursoragentCopilot
authored
feat(react-headless-components-preview): add Dialog component (#35996)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Dmytro Kirpa <dmytrokirpa@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com>
1 parent faf6311 commit 4756919

59 files changed

Lines changed: 2555 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/react-components/react-headless-components-preview/library/config/tests.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,26 @@ global.ResizeObserver = class ResizeObserver {
1313
// no-op for jsdom
1414
}
1515
};
16+
// JSDOM does not implement native <dialog> APIs yet.
17+
// Provide a minimal test shim so components using showModal/show/close can run in Jest.
18+
if (typeof HTMLDialogElement !== 'undefined') {
19+
const proto = HTMLDialogElement.prototype;
20+
21+
if (!proto.showModal) {
22+
proto.showModal = function showModal() {
23+
this.setAttribute('open', '');
24+
};
25+
}
26+
27+
if (!proto.show) {
28+
proto.show = function show() {
29+
this.setAttribute('open', '');
30+
};
31+
}
32+
33+
if (!proto.close) {
34+
proto.close = function close() {
35+
this.removeAttribute('open');
36+
};
37+
}
38+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { baseConfig } from '@fluentui/scripts-cypress';
2+
3+
export default baseConfig;
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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

Comments
 (0)