@a11y-context/mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/corpus/web/react/components/accordion.basic.md +157 -0
- package/corpus/web/react/components/button.basic.md +91 -0
- package/corpus/web/react/components/button.toggle.md +109 -0
- package/corpus/web/react/components/carousel.dots.md +376 -0
- package/corpus/web/react/components/carousel.thumbnails.md +395 -0
- package/corpus/web/react/components/collection-row.basic.md +179 -0
- package/corpus/web/react/components/combobox.autocomplete.md +293 -0
- package/corpus/web/react/components/dialog.modal.md +202 -0
- package/corpus/web/react/components/dialog.nonmodal.md +184 -0
- package/corpus/web/react/components/disclosure.basic.md +97 -0
- package/corpus/web/react/components/grid.channel-guide.md +453 -0
- package/corpus/web/react/components/link.basic.md +105 -0
- package/corpus/web/react/components/listbox.basic.md +263 -0
- package/corpus/web/react/components/menu.basic.md +294 -0
- package/corpus/web/react/components/menu.menubar.md +296 -0
- package/corpus/web/react/components/navigation-menu.basic.md +349 -0
- package/corpus/web/react/components/navigation-menu.dropdown.md +220 -0
- package/corpus/web/react/components/select.basic.md +318 -0
- package/corpus/web/react/components/select.native.md +108 -0
- package/corpus/web/react/components/switch.basic.md +151 -0
- package/corpus/web/react/components/toast.basic.md +112 -0
- package/corpus/web/react/components/tooltip.basic.md +139 -0
- package/corpus/web/react/global/global_rules.md +292 -0
- package/corpus/web/react/patterns.json +946 -0
- package/dist/config.js +35 -0
- package/dist/contracts/v1/types.js +6 -0
- package/dist/http.js +103 -0
- package/dist/index.js +8 -0
- package/dist/mcp/createServer.js +122 -0
- package/dist/mcp/response.js +7 -0
- package/dist/mcp/server.js +15 -0
- package/dist/repo/cache.js +27 -0
- package/dist/repo/globalRules.js +304 -0
- package/dist/repo/index.js +178 -0
- package/dist/repo/paths.js +25 -0
- package/dist/repo/sections.js +166 -0
- package/dist/smoke/smoke-remote-mcp.js +43 -0
- package/dist/telemetry.js +15 -0
- package/dist/telemetryWrap.js +48 -0
- package/dist/tools/getGlobalRules.js +33 -0
- package/dist/tools/getPattern.js +54 -0
- package/dist/tools/listPatterns.js +33 -0
- package/dist/utils/fs.js +18 -0
- package/dist/utils/hash.js +4 -0
- package/package.json +68 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: combobox.autocomplete
|
|
3
|
+
title: Autocomplete Combobox
|
|
4
|
+
stack: web/react
|
|
5
|
+
status: beta
|
|
6
|
+
latest_version: 0.1.0
|
|
7
|
+
tags: [combobox, autocomplete, listbox, filter, aria-activedescendant, form, search]
|
|
8
|
+
aliases: [typeahead, type-ahead, autosuggest, auto-suggest, search select, filterable dropdown, predictive input, combo box]
|
|
9
|
+
summary: An editable text input that filters a listbox of options as the user types; uses role="combobox" with aria-autocomplete, aria-expanded, aria-controls, and aria-activedescendant while DOM focus stays on the input.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Autocomplete Combobox
|
|
13
|
+
|
|
14
|
+
Pattern ID: `combobox.autocomplete`
|
|
15
|
+
|
|
16
|
+
An editable text input that filters a listbox of options as the user types; uses `role="combobox"` with `aria-autocomplete`, `aria-expanded`, `aria-controls`, and `aria-activedescendant` while DOM focus stays on the input.
|
|
17
|
+
|
|
18
|
+
## Use When
|
|
19
|
+
- Use when the option set is large enough that typing to filter or search is faster than scanning a list (e.g., "Country", "City", "Assign to a user").
|
|
20
|
+
- Use when the user narrows the available options by typing (e.g., "Search shows", "Add a tag").
|
|
21
|
+
- Use when free-text entry is allowed alongside suggestions (e.g., a tag field that accepts new values while suggesting existing ones).
|
|
22
|
+
|
|
23
|
+
## Do Not Use When
|
|
24
|
+
- Do not use when no typing is needed and the set is small or preset (use `select.native`, or for a rich custom-styled picker use `select.basic`).
|
|
25
|
+
- Do not use when the options must be permanently visible with no popup (use `listbox.basic`).
|
|
26
|
+
- Do not use when the control performs actions rather than picking a value (use `menu.basic`).
|
|
27
|
+
- Do not use when the trigger reveals navigation links (use `navigation-menu.dropdown`).
|
|
28
|
+
|
|
29
|
+
## Must Haves
|
|
30
|
+
|
|
31
|
+
### Roles & structure
|
|
32
|
+
- Use a native `<input>` element with `role="combobox"` as the editable control.
|
|
33
|
+
- Set `aria-autocomplete="list"` on the input to indicate that typing filters a list of options.
|
|
34
|
+
- Render the popup as an element with `role="listbox"` and a stable ID (the same one referenced by `aria-controls`).
|
|
35
|
+
- Give each option `role="option"`, a stable ID, and `aria-selected="true|false"`.
|
|
36
|
+
|
|
37
|
+
### Accessible name
|
|
38
|
+
- Provide a visible native `<label>` associated with the input via `for`/`id`.
|
|
39
|
+
|
|
40
|
+
### State & properties
|
|
41
|
+
- Set `aria-expanded="true|false"` on the input reflecting whether the listbox is displayed.
|
|
42
|
+
- Set `aria-controls="IDREF"` on the input pointing to the listbox element.
|
|
43
|
+
- The listbox is shown/hidden in the DOM (e.g., via the `hidden` attribute), so that when closed, its options cannot be reached by keyboard or screen readers.
|
|
44
|
+
|
|
45
|
+
### Keyboard
|
|
46
|
+
- Arrow Down moves to the first option, opening the listbox first if it is closed.
|
|
47
|
+
- Arrow Up moves to the last option, opening the listbox first if it is closed.
|
|
48
|
+
- Alt+Arrow Down opens the listbox without moving the active option.
|
|
49
|
+
- When the listbox is open, Arrow Up and Arrow Down move the active option and wrap at the ends.
|
|
50
|
+
- Enter sets the input value to the active option and closes the listbox.
|
|
51
|
+
- Home, End, Left, and Right move the caret within the input text rather than the active option.
|
|
52
|
+
- A printable character returns focus to the input, inserts the character, and filters the listbox to matching options.
|
|
53
|
+
- Do not capture the keys the browser uses for text editing (character keys, caret movement, selection, and deletion).
|
|
54
|
+
|
|
55
|
+
### Focus
|
|
56
|
+
- Keep DOM focus on the input at all times so native text editing keeps working.
|
|
57
|
+
- Indicate the active option by setting `aria-activedescendant="{optionId}"` on the input, pointing to the currently highlighted option.
|
|
58
|
+
- When no option is active, remove `aria-activedescendant` (do not point it at a stale or nonexistent ID).
|
|
59
|
+
- Scroll the active option into view via scripting when it changes so it is visible.
|
|
60
|
+
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the input, and a distinct visual highlight on the active option.
|
|
61
|
+
|
|
62
|
+
### Dismissal
|
|
63
|
+
- Esc closes the listbox if it is open, or clears the input if the listbox is already closed.
|
|
64
|
+
|
|
65
|
+
## Customizable
|
|
66
|
+
- `aria-autocomplete` may be `"none"`, `"list"` (the default for this pattern), or `"both"`.
|
|
67
|
+
- With `"both"`, an inline completion string is inserted and selected after the caret; this can be confusing with some screen readers, so test it with assistive technology before shipping.
|
|
68
|
+
- Selection behavior may be manual (the active option is committed only on Enter) or automatic (the first suggestion is pre-selected as the user types). Choose one and test with keyboard and screen readers.
|
|
69
|
+
- An optional trigger button that opens the listbox may be included; give it `tabindex="-1"` so it does not add a stop in the tab order, and keep the input as the accessible combobox.
|
|
70
|
+
- Whether the list filters to matches or auto-selects the first match is at the engineer's discretion, as long as the announced options reflect what is visible.
|
|
71
|
+
|
|
72
|
+
## Don'ts
|
|
73
|
+
- Do not capture the keys used for text editing (typing, caret movement, selection, deletion); the input must behave as a normal text field.
|
|
74
|
+
- Do not move DOM focus into the options; it breaks native text editing. Track the active option with `aria-activedescendant` instead.
|
|
75
|
+
- Do not use `role="menu"` or `role="menuitem"` for the options; use `role="listbox"` and `role="option"`.
|
|
76
|
+
- Do not forget to scroll the active option into view when it changes.
|
|
77
|
+
- Do not leave the listbox visible while `aria-expanded="false"` (and vice versa).
|
|
78
|
+
|
|
79
|
+
## Golden Pattern
|
|
80
|
+
|
|
81
|
+
Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
|
|
82
|
+
|
|
83
|
+
```jsx
|
|
84
|
+
"use client";
|
|
85
|
+
|
|
86
|
+
export function Combobox({ label = "Country", options = COUNTRIES }) {
|
|
87
|
+
const [query, setQuery] = useState("");
|
|
88
|
+
const [open, setOpen] = useState(false);
|
|
89
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
90
|
+
|
|
91
|
+
const inputRef = useRef(null);
|
|
92
|
+
const listRef = useRef(null);
|
|
93
|
+
|
|
94
|
+
const baseId = useId();
|
|
95
|
+
const labelId = `${baseId}-label`;
|
|
96
|
+
const listboxId = `${baseId}-listbox`;
|
|
97
|
+
const optionId = (idx) => `${baseId}-opt-${idx}`;
|
|
98
|
+
|
|
99
|
+
const matches = useMemo(() => {
|
|
100
|
+
const q = query.trim().toLowerCase();
|
|
101
|
+
if (!q) return options;
|
|
102
|
+
return options.filter((o) => o.toLowerCase().includes(q));
|
|
103
|
+
}, [query, options]);
|
|
104
|
+
|
|
105
|
+
// Keep the active option in view whenever it changes.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open || activeIndex < 0) return;
|
|
108
|
+
const el = listRef.current?.querySelector(`#${CSS.escape(optionId(activeIndex))}`);
|
|
109
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
110
|
+
}, [open, activeIndex]);
|
|
111
|
+
|
|
112
|
+
function openList() {
|
|
113
|
+
setOpen(true);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function closeList() {
|
|
117
|
+
setOpen(false);
|
|
118
|
+
setActiveIndex(-1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function commit(idx) {
|
|
122
|
+
const value = matches[idx];
|
|
123
|
+
if (value == null) return;
|
|
124
|
+
setQuery(value);
|
|
125
|
+
closeList();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onChange(e) {
|
|
129
|
+
setQuery(e.target.value);
|
|
130
|
+
setOpen(true);
|
|
131
|
+
setActiveIndex(-1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onKeyDown(e) {
|
|
135
|
+
switch (e.key) {
|
|
136
|
+
case "ArrowDown":
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
if (e.altKey) {
|
|
139
|
+
openList();
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
// Never move the active option when there is nothing to point at,
|
|
143
|
+
// or aria-activedescendant would reference a nonexistent option.
|
|
144
|
+
if (matches.length === 0) {
|
|
145
|
+
openList();
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (!open) {
|
|
149
|
+
openList();
|
|
150
|
+
setActiveIndex(0);
|
|
151
|
+
} else {
|
|
152
|
+
setActiveIndex((i) => (i + 1) % matches.length);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
case "ArrowUp":
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (matches.length === 0) {
|
|
158
|
+
openList();
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (!open) {
|
|
162
|
+
openList();
|
|
163
|
+
setActiveIndex(matches.length - 1);
|
|
164
|
+
} else {
|
|
165
|
+
setActiveIndex((i) => (i <= 0 ? matches.length - 1 : i - 1));
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case "Enter":
|
|
169
|
+
if (open && activeIndex >= 0) {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
commit(activeIndex);
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
case "Escape":
|
|
175
|
+
if (open) {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
closeList();
|
|
178
|
+
} else if (query) {
|
|
179
|
+
setQuery("");
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
// Home, End, ArrowLeft, and ArrowRight are intentionally not handled,
|
|
183
|
+
// so the browser moves the text caret as usual.
|
|
184
|
+
default:
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const listVisible = open && matches.length > 0;
|
|
190
|
+
const activeDescendant =
|
|
191
|
+
listVisible && activeIndex >= 0 ? optionId(activeIndex) : undefined;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div style={{ position: "relative", display: "inline-block" }}>
|
|
195
|
+
<label id={labelId} htmlFor={`${baseId}-input`} style={{ display: "block" }}>
|
|
196
|
+
{label}
|
|
197
|
+
</label>
|
|
198
|
+
|
|
199
|
+
<input
|
|
200
|
+
ref={inputRef}
|
|
201
|
+
id={`${baseId}-input`}
|
|
202
|
+
type="text"
|
|
203
|
+
role="combobox"
|
|
204
|
+
// Suppress native autofill so it does not overlay the custom listbox.
|
|
205
|
+
autoComplete="off"
|
|
206
|
+
aria-autocomplete="list"
|
|
207
|
+
// Expanded state must track actual listbox visibility, not just intent.
|
|
208
|
+
aria-expanded={listVisible ? "true" : "false"}
|
|
209
|
+
aria-controls={listboxId}
|
|
210
|
+
aria-activedescendant={activeDescendant}
|
|
211
|
+
value={query}
|
|
212
|
+
onChange={onChange}
|
|
213
|
+
onKeyDown={onKeyDown}
|
|
214
|
+
onFocus={() => query && openList()}
|
|
215
|
+
onBlur={(e) => {
|
|
216
|
+
if (!e.currentTarget.parentNode.contains(e.relatedTarget)) closeList();
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
|
|
220
|
+
<ul
|
|
221
|
+
ref={listRef}
|
|
222
|
+
id={listboxId}
|
|
223
|
+
role="listbox"
|
|
224
|
+
aria-labelledby={labelId}
|
|
225
|
+
hidden={!listVisible}
|
|
226
|
+
style={{
|
|
227
|
+
position: "absolute",
|
|
228
|
+
zIndex: 10,
|
|
229
|
+
left: 0,
|
|
230
|
+
right: 0,
|
|
231
|
+
margin: 0,
|
|
232
|
+
padding: 0,
|
|
233
|
+
listStyle: "none",
|
|
234
|
+
maxHeight: 200,
|
|
235
|
+
overflow: "auto",
|
|
236
|
+
border: "1px solid GrayText",
|
|
237
|
+
background: "Canvas",
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{matches.map((value, idx) => (
|
|
241
|
+
<li
|
|
242
|
+
key={value}
|
|
243
|
+
id={optionId(idx)}
|
|
244
|
+
role="option"
|
|
245
|
+
aria-selected={idx === activeIndex ? "true" : "false"}
|
|
246
|
+
// Keep DOM focus on the input; do not steal it on pointer down.
|
|
247
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
248
|
+
onMouseEnter={() => setActiveIndex(idx)}
|
|
249
|
+
onClick={() => commit(idx)}
|
|
250
|
+
style={{
|
|
251
|
+
padding: "6px 8px",
|
|
252
|
+
outline: idx === activeIndex ? "2px solid Highlight" : "none",
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{value}
|
|
256
|
+
</li>
|
|
257
|
+
))}
|
|
258
|
+
</ul>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const COUNTRIES = [
|
|
264
|
+
"Argentina",
|
|
265
|
+
"Australia",
|
|
266
|
+
"Brazil",
|
|
267
|
+
"Canada",
|
|
268
|
+
"France",
|
|
269
|
+
"Germany",
|
|
270
|
+
"India",
|
|
271
|
+
"Japan",
|
|
272
|
+
"Mexico",
|
|
273
|
+
"Nigeria",
|
|
274
|
+
"United Kingdom",
|
|
275
|
+
"United States",
|
|
276
|
+
];
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Acceptance Checks
|
|
280
|
+
|
|
281
|
+
Keyboard
|
|
282
|
+
- Typing a character filters the listbox to matching options.
|
|
283
|
+
- Arrow Down and Arrow Up move the active option and wrap at the ends.
|
|
284
|
+
- Alt+Arrow Down opens the listbox without moving the active option.
|
|
285
|
+
- Enter sets the input value to the active option and closes the listbox.
|
|
286
|
+
- Esc closes the listbox when open, and clears the input when the listbox is already closed.
|
|
287
|
+
- Home, End, Left, and Right move the text caret and do not move the active option.
|
|
288
|
+
- Text editing (typing, selecting, and deleting) works normally while the listbox is open.
|
|
289
|
+
|
|
290
|
+
Screen Reader
|
|
291
|
+
- The input is announced as a combobox with its label and expanded/collapsed state (from `role="combobox"` + `aria-expanded`).
|
|
292
|
+
- The listbox and its options are announced as a listbox and options.
|
|
293
|
+
- The currently active option is conveyed as the user navigates (via `aria-activedescendant`).
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: dialog.modal
|
|
3
|
+
title: "Dialog (Modal)"
|
|
4
|
+
stack: web/react
|
|
5
|
+
status: beta
|
|
6
|
+
latest_version: 0.3.1
|
|
7
|
+
tags: [dialog, modal, pop-up, overlay, focus-trap, blocking, native-dialog, show-modal]
|
|
8
|
+
aliases: [dialog, modal, pop-up]
|
|
9
|
+
summary: User-initiated blocking dialog. Uses the native <dialog> element with .showModal() so the browser handles focus trap, background inertness, Escape dismissal, focus restoration, and top-layer rendering.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Dialog (Modal)
|
|
13
|
+
|
|
14
|
+
Pattern ID: `dialog.modal`
|
|
15
|
+
|
|
16
|
+
User-initiated blocking dialog. Uses the native `<dialog>` element with `.showModal()` so the browser handles focus trap, background inertness, Escape dismissal, focus restoration, and top-layer rendering. When the native element cannot be used, a manual `<div role="dialog">` fallback is documented under Customizable with the full behavior contract that must be implemented by hand.
|
|
17
|
+
|
|
18
|
+
## Use When
|
|
19
|
+
- Use when content appears in an overlay that blocks interaction with the underlying page.
|
|
20
|
+
- Use when keyboard focus must move into the dialog and remain contained until dismissal.
|
|
21
|
+
- Use when the user must explicitly complete or dismiss the dialog before returning to the main interface.
|
|
22
|
+
|
|
23
|
+
## Do Not Use When
|
|
24
|
+
- Do not use when the content is part of the normal page flow and does not block background interaction.
|
|
25
|
+
- Do not use when presenting brief, non-blocking status messages that do not require focus movement (use `toast` or `snackbar`).
|
|
26
|
+
- Do not use when the message is urgent and requires immediate acknowledgment (use `dialog.alert`).
|
|
27
|
+
- Do not use when the interaction involves complex, multi-step workflows spanning multiple screens.
|
|
28
|
+
|
|
29
|
+
## Must Haves
|
|
30
|
+
- Use the native `<dialog>` element (preferred). When `<dialog>` cannot be used, `<div role="dialog">` is the documented fallback — see Customizable → Manual fallback.
|
|
31
|
+
- **Open with `.showModal()`, close with `.close()`.** A `<dialog>` present in the DOM without `.showModal()` is non-modal — no focus trap, no background inertness, no `aria-modal`, no top-layer rendering. Unmounting a `<dialog>` while open (rather than calling `.close()`) leaves focus stranded because the browser's focus-restoration to the invoker runs on `.close()` (or on the browser's implicit `.close()` from Esc's `cancel` event and from `<form method="dialog">` submits). Both lifecycle endpoints must be exercised.
|
|
32
|
+
- Dialog surface has an accessible name via `aria-labelledby` (preferred) or `aria-label`. If `aria-labelledby` is used, it references a visible title element (e.g., `<h2 id="...">`).
|
|
33
|
+
- If a description is rendered, it is referenced by `aria-describedby`. Do not rely on incidental reading order.
|
|
34
|
+
- Dialog width is fluid so content reflows at 400% zoom (WCAG 1.4.10 Reflow). Prefer `max-width: min(<Npx>, 100%)`; do not set fixed pixel widths that would exceed the 320-CSS-pixel viewport at 400% zoom.
|
|
35
|
+
- Provide a visible close control (`<button type="button">`) with an accessible name (e.g., `aria-label="Close dialog"`).
|
|
36
|
+
- The invoking control declares dialog-trigger semantics — apply `aria-haspopup="dialog"` to the trigger element.
|
|
37
|
+
- The dialog satisfies the following behavior contract (all six required):
|
|
38
|
+
- Focus moves into the dialog on open.
|
|
39
|
+
- Focus is trapped within the dialog while open.
|
|
40
|
+
- Escape closes the dialog.
|
|
41
|
+
- Focus returns to the invoking element on close.
|
|
42
|
+
- Background content is not focusable or reachable by keyboard or screen readers while open.
|
|
43
|
+
- Body scroll is prevented while open.
|
|
44
|
+
- Under native `<dialog>` + `.showModal()` (the Golden Pattern), these six behaviors are provided automatically by the browser. Under the manual `<div role="dialog">` fallback (Customizable → Manual fallback), each is the implementation's responsibility.
|
|
45
|
+
- Focus indicators on the dialog surface, close button, and any focusable content follow the [Foundations focus rule](/web/react/foundations#rule-focus-states).
|
|
46
|
+
|
|
47
|
+
## Customizable
|
|
48
|
+
- **Manual `<div role="dialog">` fallback** when native `<dialog>` cannot be used (portal / stacking-context conflicts, legacy target matrix). Implement all six behaviors by hand: `aria-modal="true"`, focus trap, `inert` on the app content root (never `body` or `documentElement`), body scroll lock (on iOS Safari use `position: fixed; top: -${scrollY}px`), Escape listener, focus restoration, and render via portal to `document.body`.
|
|
49
|
+
- **Backdrop click closes** by default; may be intentionally disabled for destructive confirmations so users must Cancel or Confirm explicitly.
|
|
50
|
+
- **Initial focus target** — three acceptable defaults:
|
|
51
|
+
- the dialog surface (`tabIndex={-1}`) — accessible name announces, then Tab to first control (default under `.showModal()`);
|
|
52
|
+
- a safe-default control (Cancel / Close) for destructive confirmations, to prevent inadvertent Enter-confirm;
|
|
53
|
+
- the first interactive element for form-shaped dialogs.
|
|
54
|
+
- **`<form method="dialog">`** may be used for zero-JS form dismiss — the submit button's `value` becomes `dialog.returnValue` on the `close` event.
|
|
55
|
+
|
|
56
|
+
## Don'ts
|
|
57
|
+
- Do not render `<dialog>` without calling `.showModal()` and expect modal behavior. A bare `<dialog>` produces a non-modal reveal with none of the modal contract.
|
|
58
|
+
- Do not implement the manual `<div role="dialog">` fallback without every one of its Must Haves. Partial implementations produce broken screen-reader announcements and stranded focus.
|
|
59
|
+
- Do not rely on `aria-modal="true"` to block background interaction; it is announcement metadata, not an interaction gate. Background isolation is a separate implementation concern.
|
|
60
|
+
- Do not render the manual fallback inside containers that create clipping or stacking contexts (e.g., `overflow: hidden/auto`, `transform`), and do not rely on `z-index` alone to "make it work."
|
|
61
|
+
- Do not inert `document.body` or `document.documentElement` on the manual fallback; inert only the application content root.
|
|
62
|
+
- Do not omit focus restoration; closing a dialog must return the user to the element that invoked it.
|
|
63
|
+
- Do not set fixed pixel widths on the dialog surface that exceed the 320-CSS-pixel viewport at 400% zoom.
|
|
64
|
+
|
|
65
|
+
## Golden Pattern
|
|
66
|
+
|
|
67
|
+
Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
|
|
68
|
+
|
|
69
|
+
```jsx
|
|
70
|
+
"use client";
|
|
71
|
+
|
|
72
|
+
export function ModalDialog({
|
|
73
|
+
open,
|
|
74
|
+
title,
|
|
75
|
+
description,
|
|
76
|
+
onClose,
|
|
77
|
+
children,
|
|
78
|
+
}) {
|
|
79
|
+
const dialogRef = useRef(null);
|
|
80
|
+
const titleId = useId();
|
|
81
|
+
|
|
82
|
+
// Open / close the native dialog imperatively when `open` changes.
|
|
83
|
+
// .showModal() is what produces the modal contract: focus trap, background
|
|
84
|
+
// inertness, Escape dismissal, focus restoration, and top-layer rendering.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const el = dialogRef.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
if (open && !el.open) el.showModal();
|
|
89
|
+
if (!open && el.open) el.close();
|
|
90
|
+
}, [open]);
|
|
91
|
+
|
|
92
|
+
// Route the browser's `cancel` (Escape) and `close` events through onClose
|
|
93
|
+
// so callers see a single close pathway. preventDefault on `cancel` lets
|
|
94
|
+
// the effect above handle el.close() for consistency.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const el = dialogRef.current;
|
|
97
|
+
if (!el) return;
|
|
98
|
+
const handleCancel = (e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
onClose();
|
|
101
|
+
};
|
|
102
|
+
const handleClose = () => onClose();
|
|
103
|
+
el.addEventListener("cancel", handleCancel);
|
|
104
|
+
el.addEventListener("close", handleClose);
|
|
105
|
+
return () => {
|
|
106
|
+
el.removeEventListener("cancel", handleCancel);
|
|
107
|
+
el.removeEventListener("close", handleClose);
|
|
108
|
+
};
|
|
109
|
+
}, [onClose]);
|
|
110
|
+
|
|
111
|
+
// Backdrop click closes. A click that lands on the <dialog> element itself
|
|
112
|
+
// (not on a content descendant) is a click on the padding around the
|
|
113
|
+
// content — treat that as a backdrop dismiss.
|
|
114
|
+
const handleClick = (e) => {
|
|
115
|
+
if (e.target === dialogRef.current) onClose();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<dialog
|
|
120
|
+
ref={dialogRef}
|
|
121
|
+
aria-labelledby={titleId}
|
|
122
|
+
onClick={handleClick}
|
|
123
|
+
style={{
|
|
124
|
+
maxWidth: "min(560px, 100%)",
|
|
125
|
+
border: 0,
|
|
126
|
+
borderRadius: 12,
|
|
127
|
+
padding: 16,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<div
|
|
131
|
+
style={{
|
|
132
|
+
display: "flex",
|
|
133
|
+
justifyContent: "space-between",
|
|
134
|
+
alignItems: "flex-start",
|
|
135
|
+
gap: 12,
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<div style={{ minWidth: 0 }}>
|
|
139
|
+
<h2 id={titleId} style={{ margin: 0 }}>
|
|
140
|
+
{title}
|
|
141
|
+
</h2>
|
|
142
|
+
{description ? (
|
|
143
|
+
<p style={{ marginTop: 8, marginBottom: 0 }}>{description}</p>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={onClose}
|
|
150
|
+
aria-label="Close dialog"
|
|
151
|
+
style={{
|
|
152
|
+
width: 36,
|
|
153
|
+
height: 36,
|
|
154
|
+
borderRadius: 8,
|
|
155
|
+
border: "1px solid rgba(0,0,0,0.2)",
|
|
156
|
+
background: "transparent",
|
|
157
|
+
display: "inline-grid",
|
|
158
|
+
placeItems: "center",
|
|
159
|
+
lineHeight: 1,
|
|
160
|
+
cursor: "pointer",
|
|
161
|
+
flex: "0 0 auto",
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<span aria-hidden="true" style={{ fontSize: 18 }}>
|
|
165
|
+
×
|
|
166
|
+
</span>
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div style={{ marginTop: 16 }}>{children}</div>
|
|
171
|
+
</dialog>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Acceptance Checks
|
|
177
|
+
- On open:
|
|
178
|
+
- Focus moves into the dialog.
|
|
179
|
+
- Dialog is announced with its accessible name.
|
|
180
|
+
- Background content is not reachable by keyboard or screen reader.
|
|
181
|
+
- Body scroll is prevented.
|
|
182
|
+
|
|
183
|
+
- While open:
|
|
184
|
+
- Tab and Shift+Tab remain within the dialog.
|
|
185
|
+
- Escape closes the dialog.
|
|
186
|
+
- Clicking the backdrop closes the dialog (unless the pattern has intentionally opted out per Customizable → Backdrop click contract).
|
|
187
|
+
- Clicking inside the dialog content does not close it.
|
|
188
|
+
- Focus indicators on all interactive elements follow the Foundations focus rule.
|
|
189
|
+
|
|
190
|
+
- On close:
|
|
191
|
+
- Focus returns to the invoking element.
|
|
192
|
+
- Background content becomes interactive again.
|
|
193
|
+
- Body scroll is restored.
|
|
194
|
+
|
|
195
|
+
- Semantics:
|
|
196
|
+
- Dialog has an accessible name.
|
|
197
|
+
- Close button has an accessible name.
|
|
198
|
+
- Under native `<dialog>` + `.showModal()`, the modal state is conveyed by the top-layer/modal contract; under the manual fallback, `aria-modal="true"` is set explicitly.
|
|
199
|
+
- The invoking control declares dialog-trigger semantics (e.g., `aria-haspopup="dialog"`).
|
|
200
|
+
|
|
201
|
+
- Reflow:
|
|
202
|
+
- At 400% browser zoom (~320 CSS pixels wide), dialog content reflows within the viewport without horizontal scrolling.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: dialog.nonmodal
|
|
3
|
+
title: "Dialog (Non-Modal)"
|
|
4
|
+
stack: web/react
|
|
5
|
+
status: beta
|
|
6
|
+
latest_version: 0.1.0
|
|
7
|
+
tags: [dialog, non-modal, overlay, popover, focus-restoration, modeless]
|
|
8
|
+
aliases: [non-modal dialog, modeless dialog, non-blocking dialog, popover dialog, editor popover, composer panel, floating panel]
|
|
9
|
+
summary: A dialog that does not block the page; uses role="dialog" with an accessible name, moves focus in on open and restores it on close, but does not trap focus or inert the background.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Dialog (Non-Modal)
|
|
13
|
+
|
|
14
|
+
Pattern ID: `dialog.nonmodal`
|
|
15
|
+
|
|
16
|
+
A dialog that does not block the page; uses `role="dialog"` with an accessible name, moves focus in on open and restores it on close, but does not trap focus or `inert` the background.
|
|
17
|
+
|
|
18
|
+
This is the counterpart to `dialog.modal`; the defining contrast is the absence of background isolation and focus trapping.
|
|
19
|
+
|
|
20
|
+
## Use When
|
|
21
|
+
- Use when an overlay presents content or a task without blocking the page (e.g., a non-blocking editor popover, a composer, a rich panel with interactive controls).
|
|
22
|
+
- Use when the user can keep interacting with the rest of the page while the overlay is open.
|
|
23
|
+
- Use when focus should move into the dialog on open but not be trapped there.
|
|
24
|
+
|
|
25
|
+
## Do Not Use When
|
|
26
|
+
- Do not use when the user must complete or dismiss the overlay before returning to the page (use `dialog.modal`).
|
|
27
|
+
- Do not use when a control simply shows and hides inline content (use `disclosure.basic`).
|
|
28
|
+
- Do not use when the overlay is a menu of commands (use `menu.basic`).
|
|
29
|
+
- Do not use when the message is a brief, non-interactive status update (use `toast.basic`).
|
|
30
|
+
- Do not use when the content is a plain-text supplementary hint (use `tooltip.basic`).
|
|
31
|
+
|
|
32
|
+
## Must Haves
|
|
33
|
+
- The dialog surface uses `role="dialog"`, or a native `<dialog>` element shown non-modally (rendered in the DOM or opened with `.show()`, never `.showModal()`).
|
|
34
|
+
- The dialog has an accessible name via `aria-labelledby` (preferred), referencing a visible title element (e.g., `<h2 id="...">`), or `aria-label`.
|
|
35
|
+
- Focus moves into the dialog on open, landing on the dialog container or its first interactive control.
|
|
36
|
+
- Esc closes the dialog, regardless of where focus currently sits, since focus is not trapped and may have moved into the page.
|
|
37
|
+
- Focus is restored to the invoking element on close.
|
|
38
|
+
- Capture the invoking element at open time, and restore focus with `requestAnimationFrame` after the dialog is unmounted.
|
|
39
|
+
- The dialog does not trap focus. Tab and Shift+Tab move out of the dialog into the rest of the page, which stays interactive.
|
|
40
|
+
- The dialog does not set `aria-modal="true"` and does not apply `inert` to the background.
|
|
41
|
+
- Provide a visible close control. Use a native `<button>` (preferred), or `role="button"` only when a native button cannot be used.
|
|
42
|
+
- If `role="button"` is used instead of a native `<button>`, add `tabindex="0"` and keyboard support for Enter and Space, ensuring Space prevents page scrolling while activating the control.
|
|
43
|
+
- The close control has an accessible name that describes its purpose or action (e.g., `aria-label="Close"`).
|
|
44
|
+
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the trigger, the close control, and any focusable content within the dialog.
|
|
45
|
+
|
|
46
|
+
## Customizable
|
|
47
|
+
- Delivery is at the engineer's discretion as long as the non-modal contract above holds. Acceptable options:
|
|
48
|
+
- A native `<dialog>` element shown non-modally.
|
|
49
|
+
- The `popover` attribute, which adds light dismiss on outside click and Esc for free.
|
|
50
|
+
- A `<div role="dialog">` with explicit positioning.
|
|
51
|
+
- Whether an outside click also dismisses the dialog is optional. The `popover` attribute provides it automatically; a `role="dialog"` div may add it via an outside-pointer handler, or omit it.
|
|
52
|
+
- Initial focus may land on the dialog container (`tabindex="-1"`) so the accessible name is announced before the user Tabs to the first control, or on the first interactive control when the user's next action is to type or select. Either is acceptable.
|
|
53
|
+
|
|
54
|
+
## Don'ts
|
|
55
|
+
- Do not set `aria-modal="true"` on a non-modal dialog; it falsely tells assistive technology the background is inert.
|
|
56
|
+
- Do not trap focus within the dialog.
|
|
57
|
+
- Do not apply `inert` to, or otherwise block, the background content.
|
|
58
|
+
- Do not omit focus restoration; closing the dialog must return focus to the element that invoked it.
|
|
59
|
+
- Do not bind Esc only to a container-level handler on the dialog surface; once focus leaves the untrapped dialog, that handler stops firing and Esc no longer closes.
|
|
60
|
+
|
|
61
|
+
## Golden Pattern
|
|
62
|
+
|
|
63
|
+
Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
|
|
64
|
+
|
|
65
|
+
```jsx
|
|
66
|
+
"use client";
|
|
67
|
+
|
|
68
|
+
export function NonModalDialog() {
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
const openerRef = useRef(null);
|
|
71
|
+
const dialogRef = useRef(null);
|
|
72
|
+
const titleId = useId();
|
|
73
|
+
|
|
74
|
+
const openDialog = () => {
|
|
75
|
+
// Capture the opener at the moment we open, so focus can be restored on close.
|
|
76
|
+
openerRef.current = document.activeElement;
|
|
77
|
+
setOpen(true);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const closeDialog = () => {
|
|
81
|
+
setOpen(false);
|
|
82
|
+
// Defer until the dialog has unmounted and the opener is focusable again.
|
|
83
|
+
const opener = openerRef.current;
|
|
84
|
+
requestAnimationFrame(() => opener?.focus());
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Move focus into the dialog on open. No focus trap: Tab leaves the dialog
|
|
88
|
+
// into the rest of the page, which stays interactive (no inert, no aria-modal).
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (open) dialogRef.current?.focus();
|
|
91
|
+
}, [open]);
|
|
92
|
+
|
|
93
|
+
// Esc must close from anywhere, because focus is not trapped and may sit in
|
|
94
|
+
// the page. A container-level onKeyDown would miss those keystrokes, so bind
|
|
95
|
+
// Esc at the document level while open.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!open) return;
|
|
98
|
+
const onKeyDown = (e) => {
|
|
99
|
+
if (e.key === "Escape") closeDialog();
|
|
100
|
+
};
|
|
101
|
+
document.addEventListener("keydown", onKeyDown);
|
|
102
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
103
|
+
}, [open]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
<button type="button" onClick={openDialog}>
|
|
108
|
+
Edit note
|
|
109
|
+
</button>
|
|
110
|
+
|
|
111
|
+
{/* The rest of the page stays interactive while the dialog is open. */}
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => alert("Background control still works")}
|
|
115
|
+
>
|
|
116
|
+
Background action
|
|
117
|
+
</button>
|
|
118
|
+
|
|
119
|
+
{open
|
|
120
|
+
? createPortal(
|
|
121
|
+
<div
|
|
122
|
+
ref={dialogRef}
|
|
123
|
+
role="dialog"
|
|
124
|
+
aria-labelledby={titleId}
|
|
125
|
+
tabIndex={-1}
|
|
126
|
+
style={{
|
|
127
|
+
position: "fixed",
|
|
128
|
+
top: 80,
|
|
129
|
+
right: 24,
|
|
130
|
+
width: "min(360px, 100%)",
|
|
131
|
+
border: "1px solid rgba(0,0,0,0.3)",
|
|
132
|
+
borderRadius: 12,
|
|
133
|
+
padding: 16,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<div
|
|
137
|
+
style={{
|
|
138
|
+
display: "flex",
|
|
139
|
+
justifyContent: "space-between",
|
|
140
|
+
gap: 12,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<h2 id={titleId} style={{ margin: 0 }}>
|
|
144
|
+
Edit note
|
|
145
|
+
</h2>
|
|
146
|
+
<button type="button" onClick={closeDialog} aria-label="Close">
|
|
147
|
+
<span aria-hidden="true">×</span>
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<label>
|
|
152
|
+
Note
|
|
153
|
+
<textarea defaultValue={DEFAULT_NOTES[0]} rows={4} />
|
|
154
|
+
</label>
|
|
155
|
+
|
|
156
|
+
<button type="button" onClick={closeDialog}>
|
|
157
|
+
Save
|
|
158
|
+
</button>
|
|
159
|
+
</div>,
|
|
160
|
+
document.body
|
|
161
|
+
)
|
|
162
|
+
: null}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const DEFAULT_NOTES = [
|
|
168
|
+
"Reminder: finish the S2 finale review before it leaves the service on Friday.",
|
|
169
|
+
];
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Acceptance Checks
|
|
173
|
+
- On open:
|
|
174
|
+
- Focus moves into the dialog.
|
|
175
|
+
- The dialog is announced with its accessible name.
|
|
176
|
+
- Background content remains reachable and interactive.
|
|
177
|
+
|
|
178
|
+
- While open:
|
|
179
|
+
- Esc closes the dialog, including when focus has moved out of the dialog into the page.
|
|
180
|
+
- Tab and Shift+Tab move focus out of the dialog into the page, confirming focus is not trapped.
|
|
181
|
+
- The trigger, the close control, and any focusable content within the dialog show a visible focus indicator (e.g., a 2px solid outline) when focused.
|
|
182
|
+
|
|
183
|
+
- On close:
|
|
184
|
+
- Focus returns to the invoking element.
|