@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,296 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: menu.menubar
|
|
3
|
+
title: Menubar
|
|
4
|
+
stack: web/react
|
|
5
|
+
status: beta
|
|
6
|
+
latest_version: 0.1.0
|
|
7
|
+
tags: [menubar, menu, roving-tabindex, command-bar, application, keyboard-navigation]
|
|
8
|
+
aliases: [menu bar, application menu bar, command bar, app menu bar, editor menu bar, file edit view menu, desktop menu bar]
|
|
9
|
+
summary: A persistent horizontal bar of application command menus using role="menubar", role="menu", and role="menuitem", for desktop-application command surfaces only, never site navigation.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Menubar
|
|
13
|
+
|
|
14
|
+
Pattern ID: `menu.menubar`
|
|
15
|
+
|
|
16
|
+
A persistent horizontal bar of application command menus using `role="menubar"`, `role="menu"`, and `role="menuitem"`, for desktop-application command surfaces only, never site navigation.
|
|
17
|
+
|
|
18
|
+
This pattern is rarely appropriate on the web. Almost every horizontal bar of links is site navigation, which must not use menu roles (use `navigation-menu.basic`). Reach for a menubar only when replicating a desktop application command bar.
|
|
19
|
+
|
|
20
|
+
## Use When
|
|
21
|
+
- Use when building a genuine desktop-application-style command bar that replicates an operating-system menu bar of commands (e.g., a web-based document editor, IDE, or design tool with "File", "Edit", "View").
|
|
22
|
+
- Use when the items are application commands, not navigation destinations (e.g., "New File", "Undo", "Toggle Sidebar").
|
|
23
|
+
- Use when the surface is a persistent bar of multiple always-visible top-level menus, not a single button that opens one menu (e.g., a File/Edit/View bar fixed at the top of the editor).
|
|
24
|
+
|
|
25
|
+
## Do Not Use When
|
|
26
|
+
- Do not use when the bar is site or app navigation of any kind, including a nav bar styled to look like a menu bar or with fly-out submenus; this is the overwhelmingly common case (use `navigation-menu.basic`).
|
|
27
|
+
- Do not use when a single button opens one action menu (use `menu.basic`).
|
|
28
|
+
- Do not use when the user picks a value from a set (use `select.native`).
|
|
29
|
+
- Do not use when a single trigger shows and hides a region of content (use `disclosure.basic`).
|
|
30
|
+
|
|
31
|
+
## Must Haves
|
|
32
|
+
|
|
33
|
+
### Roles & structure
|
|
34
|
+
- The container has `role="menubar"`.
|
|
35
|
+
- Horizontal orientation is implicit for `role="menubar"`; no `aria-orientation` is required.
|
|
36
|
+
- Only menu-related elements appear inside the menubar (top-level items, submenus, and separators). No links, no arbitrary content.
|
|
37
|
+
- Each top-level item has `role="menuitem"`.
|
|
38
|
+
- Each submenu is a container with `role="menu"` and an accessible name that matches its top-level item (e.g., `aria-label="File"`).
|
|
39
|
+
- Submenu commands have `role="menuitem"`, or `role="menuitemcheckbox"` or `role="menuitemradio"` for stateful commands.
|
|
40
|
+
- Dividers within a submenu use `role="separator"`.
|
|
41
|
+
- The submenu contains only menu parts (`menuitem`, `menuitemcheckbox`, `menuitemradio`, and `separator`), no links or arbitrary content.
|
|
42
|
+
|
|
43
|
+
### Accessible name
|
|
44
|
+
- The container has an accessible name (e.g., `aria-label="Application"`).
|
|
45
|
+
|
|
46
|
+
### State & properties
|
|
47
|
+
- Each top-level item that opens a submenu has `aria-haspopup="menu"` and `aria-expanded="true|false"` reflecting open/closed.
|
|
48
|
+
- Each submenu is shown/hidden in the DOM (e.g., via the `hidden` attribute), so that when closed, its commands cannot be reached by keyboard or screen readers.
|
|
49
|
+
|
|
50
|
+
### Keyboard
|
|
51
|
+
- Arrow Left and Arrow Right move focus between top-level items and wrap at the ends.
|
|
52
|
+
- Arrow Down, Enter, or Space on a top-level item opens its submenu and moves focus to the first submenu item.
|
|
53
|
+
- Within an open submenu, Arrow Up and Arrow Down move focus between commands and wrap at the ends.
|
|
54
|
+
- Within an open submenu, Arrow Right moves to the adjacent top-level menu, and Arrow Left moves to the previous top-level menu (opening that menu).
|
|
55
|
+
- Within an open submenu, Home moves focus to the first command and End moves focus to the last command.
|
|
56
|
+
- Enter or Space activates the focused submenu command.
|
|
57
|
+
- Type-ahead moves focus to the next item whose label starts with the typed character, both across top-level items and within an open submenu.
|
|
58
|
+
|
|
59
|
+
### Focus
|
|
60
|
+
- Focus is managed with roving tabindex across the top-level items.
|
|
61
|
+
- The first top-level item starts at `tabindex="0"`; all other top-level items start at `tabindex="-1"`.
|
|
62
|
+
- Moving between top-level items updates tabindex so exactly one top-level item is at `tabindex="0"` at a time.
|
|
63
|
+
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the top-level items and submenu commands.
|
|
64
|
+
|
|
65
|
+
### Dismissal
|
|
66
|
+
- Esc closes the current submenu and returns focus to its top-level item.
|
|
67
|
+
- The menubar closes any open submenu when focus moves outside the menubar (e.g., via Tab or an outside pointer click), so no submenu stays visible while its top-level item is at `aria-expanded="false"`.
|
|
68
|
+
|
|
69
|
+
## Customizable
|
|
70
|
+
- Stateful commands may use `role="menuitemcheckbox"` (with `aria-checked="true|false"`) or `role="menuitemradio"` (with `aria-checked="true|false"` within a grouped set) instead of plain `role="menuitem"`.
|
|
71
|
+
- Roving tabindex is the default focus model. `aria-activedescendant` on the menubar is an acceptable alternative, with the caveat that its assistive-technology support is weaker than roving tabindex and it must be tested against target screen readers.
|
|
72
|
+
- Submenu depth is at the engineer's discretion; nested submenus are permitted as long as each nested `role="menu"` carries an accessible name and the same keyboard contract applies at every level.
|
|
73
|
+
|
|
74
|
+
## Don'ts
|
|
75
|
+
- Do not use a menubar for site or app navigation; a horizontal bar of links is navigation and must use `navigation-menu.basic`, not menu roles.
|
|
76
|
+
- Do not place links (`<a href>`) inside a menubar or its submenus; menubar children are commands, not destinations.
|
|
77
|
+
- Do not half-implement the keyboard contract; a menubar without arrow-key traversal, submenu open/close, and type-ahead is broken for keyboard and screen reader users.
|
|
78
|
+
- Do not make every top-level item and command tabbable; use roving tabindex so Tab enters and exits the menubar as a single stop.
|
|
79
|
+
- Do not leave a submenu visible while its top-level item has `aria-expanded="false"` (and vice versa).
|
|
80
|
+
|
|
81
|
+
## Golden Pattern
|
|
82
|
+
|
|
83
|
+
Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
|
|
84
|
+
|
|
85
|
+
```jsx
|
|
86
|
+
"use client";
|
|
87
|
+
|
|
88
|
+
export function MenubarDemo() {
|
|
89
|
+
// Index of the top-level menu that currently holds roving focus.
|
|
90
|
+
const [activeTop, setActiveTop] = useState(0);
|
|
91
|
+
// Index of the open submenu, or null when all submenus are closed.
|
|
92
|
+
const [openTop, setOpenTop] = useState(null);
|
|
93
|
+
// Index of the focused command within the open submenu.
|
|
94
|
+
const [activeItem, setActiveItem] = useState(0);
|
|
95
|
+
|
|
96
|
+
const topRefs = useRef(new Map());
|
|
97
|
+
const itemRefs = useRef(new Map());
|
|
98
|
+
|
|
99
|
+
function focusTop(index) {
|
|
100
|
+
setActiveTop(index);
|
|
101
|
+
requestAnimationFrame(() => topRefs.current.get(index)?.focus());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function openMenu(index, itemIndex = 0) {
|
|
105
|
+
setActiveTop(index);
|
|
106
|
+
setOpenTop(index);
|
|
107
|
+
setActiveItem(itemIndex);
|
|
108
|
+
requestAnimationFrame(() => itemRefs.current.get(`${index}-${itemIndex}`)?.focus());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function closeMenu(restoreToTop = true) {
|
|
112
|
+
const top = openTop;
|
|
113
|
+
setOpenTop(null);
|
|
114
|
+
if (restoreToTop && top != null) {
|
|
115
|
+
requestAnimationFrame(() => topRefs.current.get(top)?.focus());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function activate(command) {
|
|
120
|
+
// Real handlers dispatch the command; alert() shows the item fires.
|
|
121
|
+
alert(`Command: ${command}`);
|
|
122
|
+
closeMenu();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Type-ahead: focus the next sibling whose label starts with the typed key.
|
|
126
|
+
function typeAhead(labels, current, key, onMatch) {
|
|
127
|
+
const lower = key.toLowerCase();
|
|
128
|
+
for (let offset = 1; offset <= labels.length; offset += 1) {
|
|
129
|
+
const idx = (current + offset) % labels.length;
|
|
130
|
+
if (labels[idx].toLowerCase().startsWith(lower)) {
|
|
131
|
+
onMatch(idx);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A single printing character with no command/system modifier is type-ahead;
|
|
138
|
+
// Cmd/Ctrl/Alt combos are shortcuts and must pass through untouched.
|
|
139
|
+
function isTypeAheadKey(event) {
|
|
140
|
+
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onTopKeyDown(index, event) {
|
|
144
|
+
const count = MENUS.length;
|
|
145
|
+
switch (event.key) {
|
|
146
|
+
case "ArrowRight":
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
focusTop((index + 1) % count);
|
|
149
|
+
break;
|
|
150
|
+
case "ArrowLeft":
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
focusTop((index - 1 + count) % count);
|
|
153
|
+
break;
|
|
154
|
+
case "ArrowDown":
|
|
155
|
+
case "Enter":
|
|
156
|
+
case " ":
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
openMenu(index, 0);
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
if (isTypeAheadKey(event)) {
|
|
162
|
+
typeAhead(MENUS.map((m) => m.label), index, event.key, focusTop);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function onItemKeyDown(topIndex, itemIndex, event) {
|
|
168
|
+
const items = MENUS[topIndex].items;
|
|
169
|
+
const count = items.length;
|
|
170
|
+
switch (event.key) {
|
|
171
|
+
case "ArrowDown":
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
openMenu(topIndex, (itemIndex + 1) % count);
|
|
174
|
+
break;
|
|
175
|
+
case "ArrowUp":
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
openMenu(topIndex, (itemIndex - 1 + count) % count);
|
|
178
|
+
break;
|
|
179
|
+
case "Home":
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
openMenu(topIndex, 0);
|
|
182
|
+
break;
|
|
183
|
+
case "End":
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
openMenu(topIndex, count - 1);
|
|
186
|
+
break;
|
|
187
|
+
case "ArrowRight":
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
openMenu((topIndex + 1) % MENUS.length, 0);
|
|
190
|
+
break;
|
|
191
|
+
case "ArrowLeft":
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
openMenu((topIndex - 1 + MENUS.length) % MENUS.length, 0);
|
|
194
|
+
break;
|
|
195
|
+
case "Enter":
|
|
196
|
+
case " ":
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
activate(items[itemIndex]);
|
|
199
|
+
break;
|
|
200
|
+
case "Escape":
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
closeMenu();
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
if (isTypeAheadKey(event)) {
|
|
206
|
+
typeAhead(items, itemIndex, event.key, (idx) => openMenu(topIndex, idx));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
role="menubar"
|
|
214
|
+
aria-label="Editor"
|
|
215
|
+
style={{ display: "flex", gap: 4 }}
|
|
216
|
+
// Close the open submenu when focus leaves the menubar entirely (Tab-out
|
|
217
|
+
// or outside click), so no submenu lingers with aria-expanded="false".
|
|
218
|
+
onBlur={(event) => {
|
|
219
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
220
|
+
closeMenu(false);
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
{MENUS.map((menu, topIndex) => (
|
|
225
|
+
<div key={menu.id} style={{ position: "relative" }}>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
role="menuitem"
|
|
229
|
+
aria-haspopup="menu"
|
|
230
|
+
aria-expanded={openTop === topIndex ? "true" : "false"}
|
|
231
|
+
// Roving tabindex: exactly one top-level item is tabbable.
|
|
232
|
+
tabIndex={activeTop === topIndex ? 0 : -1}
|
|
233
|
+
onClick={() => (openTop === topIndex ? closeMenu() : openMenu(topIndex, 0))}
|
|
234
|
+
onKeyDown={(event) => onTopKeyDown(topIndex, event)}
|
|
235
|
+
ref={(el) => {
|
|
236
|
+
if (el) topRefs.current.set(topIndex, el);
|
|
237
|
+
else topRefs.current.delete(topIndex);
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{menu.label}
|
|
241
|
+
</button>
|
|
242
|
+
|
|
243
|
+
<div
|
|
244
|
+
role="menu"
|
|
245
|
+
aria-label={menu.label}
|
|
246
|
+
hidden={openTop !== topIndex}
|
|
247
|
+
style={{ position: "absolute", top: "100%", left: 0, minWidth: 160 }}
|
|
248
|
+
>
|
|
249
|
+
{menu.items.map((command, itemIndex) => (
|
|
250
|
+
<button
|
|
251
|
+
key={command}
|
|
252
|
+
type="button"
|
|
253
|
+
role="menuitem"
|
|
254
|
+
tabIndex={-1}
|
|
255
|
+
onClick={() => activate(command)}
|
|
256
|
+
onKeyDown={(event) => onItemKeyDown(topIndex, itemIndex, event)}
|
|
257
|
+
ref={(el) => {
|
|
258
|
+
if (el) itemRefs.current.set(`${topIndex}-${itemIndex}`, el);
|
|
259
|
+
else itemRefs.current.delete(`${topIndex}-${itemIndex}`);
|
|
260
|
+
}}
|
|
261
|
+
style={{ display: "block", width: "100%", textAlign: "left" }}
|
|
262
|
+
>
|
|
263
|
+
{command}
|
|
264
|
+
</button>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const MENUS = [
|
|
274
|
+
{ id: "file", label: "File", items: ["New File", "Open", "Save", "Export"] },
|
|
275
|
+
{ id: "edit", label: "Edit", items: ["Undo", "Redo", "Cut", "Copy", "Paste"] },
|
|
276
|
+
{ id: "view", label: "View", items: ["Zoom In", "Zoom Out", "Toggle Sidebar"] },
|
|
277
|
+
];
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Acceptance Checks
|
|
281
|
+
|
|
282
|
+
Keyboard
|
|
283
|
+
- Arrow Left and Arrow Right move focus across the top-level items and wrap at the ends.
|
|
284
|
+
- Arrow Down, Enter, or Space on a top-level item opens its submenu and focuses the first command.
|
|
285
|
+
- Within an open submenu, Home focuses the first command and End focuses the last command.
|
|
286
|
+
- Within an open submenu, Arrow Right moves to the adjacent top-level menu and Arrow Left moves to the previous top-level menu, opening that menu.
|
|
287
|
+
- Esc closes the open submenu and returns focus to its top-level item.
|
|
288
|
+
- Enter or Space activates the focused command.
|
|
289
|
+
- Type-ahead moves focus to the next item whose label starts with the typed character.
|
|
290
|
+
- Tab enters and exits the menubar as a single stop, closing any open submenu on exit, and does not visit every top-level item and command.
|
|
291
|
+
|
|
292
|
+
Screen Reader
|
|
293
|
+
- The container is announced as a menu bar with its accessible name.
|
|
294
|
+
- Each open submenu is announced as a menu, with a name matching its top-level item.
|
|
295
|
+
- Top-level items and commands are announced as menu items.
|
|
296
|
+
- A top-level item that opens a submenu announces that it has a submenu via `aria-haspopup`, and announces its expanded or collapsed state via `aria-expanded`.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: navigation-menu.basic
|
|
3
|
+
title: Navigation Menu
|
|
4
|
+
stack: web/react
|
|
5
|
+
status: beta
|
|
6
|
+
latest_version: 0.3.0
|
|
7
|
+
tags: [navigation, menu, disclosure, header, sitemap, flyout]
|
|
8
|
+
aliases: [nav dropdown, header dropdown, mega menu (simple), disclosure navigation, flyout menu]
|
|
9
|
+
summary: A non-modal header navigation pattern that supports top-level links and optional sub-menus. Uses disclosure-style toggles (buttons) with aria-expanded/controls and Tab-based navigation (no roving focus / no role=menu).
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Navigation Menu
|
|
13
|
+
|
|
14
|
+
Pattern ID: `navigation-menu.basic`
|
|
15
|
+
|
|
16
|
+
A non-modal header navigation pattern that supports top-level links and optional sub-menus. Uses disclosure-style toggles (buttons) with `aria-expanded`/`aria-controls` and Tab-based navigation (no roving focus, no `role="menu"`).
|
|
17
|
+
|
|
18
|
+
## Use When
|
|
19
|
+
- Use when a website's top-level navigation contains at least one sub-menu of links.
|
|
20
|
+
- Use for primary or secondary navigation regions (e.g., header nav, section nav) when the same link + sub-menu structure is needed.
|
|
21
|
+
- Use when the navigation structure maps to a sitemap-like set of destinations.
|
|
22
|
+
|
|
23
|
+
## Do Not Use When
|
|
24
|
+
- Do not use when you are building a desktop-application command bar with arrow-key navigation and roving tabindex (use `menu.menubar`).
|
|
25
|
+
- Do not use to select a value within a form (use `select.native` or `combobox.autocomplete` when searchable).
|
|
26
|
+
- Do not use as the mobile or responsive version of a navigation menu.
|
|
27
|
+
|
|
28
|
+
## Must Haves
|
|
29
|
+
|
|
30
|
+
### Roles & structure
|
|
31
|
+
- Navigation is contained within a `<nav>` landmark, or an element with `role="navigation"`, and an accessible name (`aria-label`) when multiple nav landmarks exist.
|
|
32
|
+
- Top-level navigation items are presented in a list structure (e.g., `<ul><li>…</li></ul>`).
|
|
33
|
+
- Each top-level item is one of the following:
|
|
34
|
+
- (A) **Simple link**: a single `<a>` to a destination.
|
|
35
|
+
- (B) **Parent link + menu button** (when the parent has its own destination page):
|
|
36
|
+
- The parent is a normal `<a>` (activates navigation).
|
|
37
|
+
- A separate adjacent **menu toggle button** opens/closes the sub-menu.
|
|
38
|
+
- (C) **Parent button** (when the parent does not have its own destination page):
|
|
39
|
+
- A `<button>` acts as the parent control and opens/closes the sub-menu.
|
|
40
|
+
- The toggle control is a native `<button>` (preferred) or `role="button"` only when a native button cannot be used.
|
|
41
|
+
- If using `role="button"`, add `tabindex="0"` and keyboard support for Enter and Space, ensuring Space prevents page scrolling while activating.
|
|
42
|
+
- The sub-menu container is positioned immediately after its toggle control in the DOM.
|
|
43
|
+
- Sub-menu items are links and/or buttons contained in a list structure (`<ul><li>…</li></ul>`).
|
|
44
|
+
|
|
45
|
+
### Accessible name
|
|
46
|
+
- If the toggle is an **icon button associated with a parent link** (case B), it has an accessible name that includes the parent link text.
|
|
47
|
+
- Example: `aria-label="Categories menu"` (text is customizable but must include the parent link label).
|
|
48
|
+
|
|
49
|
+
### State & properties
|
|
50
|
+
- If the current page corresponds to a top-level item:
|
|
51
|
+
- If the top-level item is a link, apply aria-current="page" to that link.
|
|
52
|
+
- If the top-level item is a button-only parent (no destination), do not put aria-current on the button. Instead, apply aria-current="page" to the submenu link that represents the current page.
|
|
53
|
+
- If the navigation has no explicit Home link and the site logo links to the homepage, the logo link carries `aria-current="page"` when the user is on the homepage.
|
|
54
|
+
- Ensure there is some visual change that indicates this is the current page link.
|
|
55
|
+
- The toggle reflects open state with `aria-expanded="true|false"`.
|
|
56
|
+
- The toggle references its submenu with `aria-controls="IDREF"` (recommended).
|
|
57
|
+
- The toggle does not carry `aria-haspopup`; a submenu of links is not a menu, listbox, tree, grid, or dialog, so `aria-expanded` alone is the correct state signal.
|
|
58
|
+
- Top-level items that open a sub-menu include a visible indicator (e.g., down caret) that communicates "has submenu."
|
|
59
|
+
- The indicator is decorative and does not replace the accessible name of the toggle (e.g., use an aria-label like "Categories menu" on icon-only toggles).
|
|
60
|
+
- Sub-menus all default to closed, or hidden, when the page loads.
|
|
61
|
+
- The sub-menu is shown/hidden in the DOM (e.g., via `hidden`) so that when closed, submenu links cannot be reached by keyboard or screen readers.
|
|
62
|
+
|
|
63
|
+
### Keyboard
|
|
64
|
+
- Tab boundary behavior:
|
|
65
|
+
- If focus is on the **last focusable element** in an open submenu and the user presses Tab, the submenu closes and focus moves to the next focusable element after the submenu.
|
|
66
|
+
- If focus is on the **first focusable element** in an open submenu and the user presses Shift+Tab, the submenu closes and focus moves to the toggle control.
|
|
67
|
+
|
|
68
|
+
### Focus
|
|
69
|
+
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around all links and buttons.
|
|
70
|
+
- The sub-menu is non-modal and does not trap focus.
|
|
71
|
+
- When a submenu is opened, focus remains on the toggle control.
|
|
72
|
+
- Keyboard users reach the first submenu item with Tab.
|
|
73
|
+
- Users can Tab through submenu items and continue to the rest of the page (no focus trap).
|
|
74
|
+
|
|
75
|
+
### Dismissal
|
|
76
|
+
- When focus moves outside the toggle + submenu (Tab away or click elsewhere), the submenu closes.
|
|
77
|
+
- Esc closes an open submenu and returns focus to its toggle control.
|
|
78
|
+
- If the navigation contains multiple sub-menus, opening one closes any other open sub-menu.
|
|
79
|
+
|
|
80
|
+
## Customizable
|
|
81
|
+
- Whether a submenu closes when a submenu item is activated (often yes).
|
|
82
|
+
- Whether submenu content includes non-interactive text (e.g., "Signed in as…") or separators.
|
|
83
|
+
- Positioning of submenus (left/right, below/above) provided DOM order and focus order remain logical.
|
|
84
|
+
- Whether the toggle is a caret icon button, text button, or combined button label (as long as the accessibility semantics above are met).
|
|
85
|
+
|
|
86
|
+
## Don'ts
|
|
87
|
+
- Do not open sub-menus on hover only.
|
|
88
|
+
- If hover-to-open is supported for mouse users, sub-menus must also be operable via click and keyboard, and keyboard users must not be forced to tab through submenu items unless they intentionally open them.
|
|
89
|
+
- Do not make a top-level item both a navigation link and the submenu toggle using the same element.
|
|
90
|
+
- If the parent has its own destination page and also has a submenu, use **parent link + separate toggle button**.
|
|
91
|
+
- Do not use `role="menu"` / `role="menuitem"` unless you implement the full ARIA menu widget behavior (managed focus, arrow keys, typeahead).
|
|
92
|
+
- Do not add `aria-haspopup` to the toggle; the submenu is a list of links, not a menu, so it announces a widget that is not present.
|
|
93
|
+
- Do not leave submenu content visible while `aria-expanded="false"` (and vice versa).
|
|
94
|
+
- Do not strand focus by removing the currently focused submenu item without closing and allowing focus to move naturally.
|
|
95
|
+
- Do not render sub-menus expanded/visible by default. They open only after the user activates the corresponding toggle.
|
|
96
|
+
|
|
97
|
+
## Golden Pattern
|
|
98
|
+
|
|
99
|
+
Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
|
|
100
|
+
|
|
101
|
+
```jsx
|
|
102
|
+
"use client";
|
|
103
|
+
|
|
104
|
+
export function NavigationMenuBasic() {
|
|
105
|
+
const [openId, setOpenId] = useState(null);
|
|
106
|
+
|
|
107
|
+
const togglesRef = useRef(new Map());
|
|
108
|
+
const listsRef = useRef(new Map());
|
|
109
|
+
|
|
110
|
+
function closeAll() {
|
|
111
|
+
setOpenId(null);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toggle(id) {
|
|
115
|
+
setOpenId((prev) => (prev === id ? null : id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!openId) return;
|
|
120
|
+
|
|
121
|
+
function onPointerDown(event) {
|
|
122
|
+
const btn = togglesRef.current.get(openId);
|
|
123
|
+
const list = listsRef.current.get(openId);
|
|
124
|
+
if (!btn || !list) return;
|
|
125
|
+
|
|
126
|
+
const inside = btn.contains(event.target) || list.contains(event.target);
|
|
127
|
+
if (!inside) closeAll();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
131
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
132
|
+
}, [openId]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!openId) return;
|
|
136
|
+
|
|
137
|
+
function onFocusIn(event) {
|
|
138
|
+
const btn = togglesRef.current.get(openId);
|
|
139
|
+
const list = listsRef.current.get(openId);
|
|
140
|
+
if (!btn || !list) return;
|
|
141
|
+
|
|
142
|
+
const inside = btn.contains(event.target) || list.contains(event.target);
|
|
143
|
+
if (!inside) closeAll();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
147
|
+
return () => document.removeEventListener("focusin", onFocusIn, true);
|
|
148
|
+
}, [openId]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!openId) return;
|
|
152
|
+
|
|
153
|
+
function onKeyDown(event) {
|
|
154
|
+
if (event.key !== "Escape") return;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
|
|
157
|
+
const btn = togglesRef.current.get(openId);
|
|
158
|
+
closeAll();
|
|
159
|
+
requestAnimationFrame(() => btn?.focus());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
163
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
164
|
+
}, [openId]);
|
|
165
|
+
|
|
166
|
+
function onSubmenuKeyDown(id, event) {
|
|
167
|
+
if (event.key !== "Tab") return;
|
|
168
|
+
|
|
169
|
+
const list = listsRef.current.get(id);
|
|
170
|
+
if (!list) return;
|
|
171
|
+
|
|
172
|
+
const focusables = Array.from(
|
|
173
|
+
list.querySelectorAll(
|
|
174
|
+
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!focusables.length) return;
|
|
179
|
+
|
|
180
|
+
const first = focusables[0];
|
|
181
|
+
const last = focusables[focusables.length - 1];
|
|
182
|
+
|
|
183
|
+
if (!event.shiftKey && document.activeElement === last) closeAll();
|
|
184
|
+
if (event.shiftKey && document.activeElement === first) closeAll();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<nav aria-label="Primary" className="navigation-menu-basic">
|
|
189
|
+
<ul style={{ listStyle: "none", paddingLeft: 0, display: "flex" }}>
|
|
190
|
+
<li style={{ position: "relative" }}>
|
|
191
|
+
<a href="/home" aria-current="page">Home</a>
|
|
192
|
+
</li>
|
|
193
|
+
|
|
194
|
+
<li style={{ position: "relative" }}>
|
|
195
|
+
<a href="/categories">Categories</a>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
aria-label="Categories menu"
|
|
199
|
+
aria-expanded={openId === "categories" ? "true" : "false"}
|
|
200
|
+
aria-controls="nav-submenu-categories"
|
|
201
|
+
onClick={() => toggle("categories")}
|
|
202
|
+
ref={(el) => {
|
|
203
|
+
if (el) togglesRef.current.set("categories", el);
|
|
204
|
+
else togglesRef.current.delete("categories");
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
▼
|
|
208
|
+
</button>
|
|
209
|
+
|
|
210
|
+
<ul
|
|
211
|
+
id="nav-submenu-categories"
|
|
212
|
+
aria-label="Categories"
|
|
213
|
+
hidden={openId !== "categories"}
|
|
214
|
+
style={{
|
|
215
|
+
listStyle: "none",
|
|
216
|
+
paddingLeft: 0,
|
|
217
|
+
position: "absolute",
|
|
218
|
+
top: "calc(100% + 0.25rem)",
|
|
219
|
+
left: 0,
|
|
220
|
+
}}
|
|
221
|
+
onKeyDown={(event) => onSubmenuKeyDown("categories", event)}
|
|
222
|
+
ref={(el) => {
|
|
223
|
+
if (el) listsRef.current.set("categories", el);
|
|
224
|
+
else listsRef.current.delete("categories");
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<li>
|
|
228
|
+
<a href="/categories/science">Science</a>
|
|
229
|
+
</li>
|
|
230
|
+
<li>
|
|
231
|
+
<a href="/categories/arts">Arts</a>
|
|
232
|
+
</li>
|
|
233
|
+
<li>
|
|
234
|
+
<a href="/categories/engineering">Engineering</a>
|
|
235
|
+
</li>
|
|
236
|
+
</ul>
|
|
237
|
+
</li>
|
|
238
|
+
|
|
239
|
+
<li style={{ position: "relative" }}>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
aria-expanded={openId === "faculty" ? "true" : "false"}
|
|
243
|
+
aria-controls="nav-submenu-faculty"
|
|
244
|
+
onClick={() => toggle("faculty")}
|
|
245
|
+
ref={(el) => {
|
|
246
|
+
if (el) togglesRef.current.set("faculty", el);
|
|
247
|
+
else togglesRef.current.delete("faculty");
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<span>Faculty</span>{" "}
|
|
251
|
+
<span aria-hidden="true">▼</span>
|
|
252
|
+
</button>
|
|
253
|
+
|
|
254
|
+
<ul
|
|
255
|
+
id="nav-submenu-faculty"
|
|
256
|
+
aria-label="Faculty"
|
|
257
|
+
hidden={openId !== "faculty"}
|
|
258
|
+
style={{
|
|
259
|
+
listStyle: "none",
|
|
260
|
+
paddingLeft: 0,
|
|
261
|
+
position: "absolute",
|
|
262
|
+
top: "calc(100% + 0.25rem)",
|
|
263
|
+
left: 0,
|
|
264
|
+
}}
|
|
265
|
+
onKeyDown={(event) => onSubmenuKeyDown("faculty", event)}
|
|
266
|
+
ref={(el) => {
|
|
267
|
+
if (el) listsRef.current.set("faculty", el);
|
|
268
|
+
else listsRef.current.delete("faculty");
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<li>
|
|
272
|
+
<a href="/faculty/directory">Directory</a>
|
|
273
|
+
</li>
|
|
274
|
+
<li>
|
|
275
|
+
<a href="/faculty/research">Research</a>
|
|
276
|
+
</li>
|
|
277
|
+
<li>
|
|
278
|
+
<a href="/faculty/contact">Contact</a>
|
|
279
|
+
</li>
|
|
280
|
+
</ul>
|
|
281
|
+
</li>
|
|
282
|
+
|
|
283
|
+
<li style={{ position: "relative" }}>
|
|
284
|
+
<a href="/about">About</a>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
aria-label="About menu"
|
|
288
|
+
aria-expanded={openId === "about" ? "true" : "false"}
|
|
289
|
+
aria-controls="nav-submenu-about"
|
|
290
|
+
onClick={() => toggle("about")}
|
|
291
|
+
ref={(el) => {
|
|
292
|
+
if (el) togglesRef.current.set("about", el);
|
|
293
|
+
else togglesRef.current.delete("about");
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
▼
|
|
297
|
+
</button>
|
|
298
|
+
|
|
299
|
+
<ul
|
|
300
|
+
id="nav-submenu-about"
|
|
301
|
+
aria-label="About"
|
|
302
|
+
hidden={openId !== "about"}
|
|
303
|
+
style={{
|
|
304
|
+
listStyle: "none",
|
|
305
|
+
paddingLeft: 0,
|
|
306
|
+
position: "absolute",
|
|
307
|
+
top: "calc(100% + 0.25rem)",
|
|
308
|
+
left: 0,
|
|
309
|
+
}}
|
|
310
|
+
onKeyDown={(event) => onSubmenuKeyDown("about", event)}
|
|
311
|
+
ref={(el) => {
|
|
312
|
+
if (el) listsRef.current.set("about", el);
|
|
313
|
+
else listsRef.current.delete("about");
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<li>
|
|
317
|
+
<a href="/about/mission">Mission</a>
|
|
318
|
+
</li>
|
|
319
|
+
<li>
|
|
320
|
+
<a href="/about/team">Team</a>
|
|
321
|
+
</li>
|
|
322
|
+
<li>
|
|
323
|
+
<a href="/about/contact">Contact</a>
|
|
324
|
+
</li>
|
|
325
|
+
</ul>
|
|
326
|
+
</li>
|
|
327
|
+
</ul>
|
|
328
|
+
</nav>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Acceptance Checks
|
|
334
|
+
|
|
335
|
+
Keyboard
|
|
336
|
+
- Top-level links are reachable with Tab and activate normally.
|
|
337
|
+
- If a parent has both a destination and submenu, Tab reaches the parent link, then the adjacent submenu toggle button.
|
|
338
|
+
- Activating a toggle button opens/closes its submenu; focus remains on the toggle.
|
|
339
|
+
- Tab enters submenu items only after the submenu is intentionally opened.
|
|
340
|
+
- Tab from last submenu item closes the submenu and moves to the next element after it.
|
|
341
|
+
- Shift+Tab from first submenu item closes the submenu and moves focus back to the toggle.
|
|
342
|
+
- Esc closes the submenu and moves focus to the toggle.
|
|
343
|
+
- When opening a submenu, any other open submenu closes.
|
|
344
|
+
Screen Reader
|
|
345
|
+
- Toggle announces expanded/collapsed state via `aria-expanded`.
|
|
346
|
+
- Closed submenus are not reachable.
|
|
347
|
+
- Submenu items announce as links/buttons and operate normally.
|
|
348
|
+
- Icon toggle buttons have accessible names that include the associated parent label (e.g., "Categories menu").
|
|
349
|
+
- On initial render, all sub-menus are closed (`aria-expanded="false"` on toggles; all sub-menus hidden/unreachable).
|