@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.
Files changed (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/corpus/web/react/components/accordion.basic.md +157 -0
  4. package/corpus/web/react/components/button.basic.md +91 -0
  5. package/corpus/web/react/components/button.toggle.md +109 -0
  6. package/corpus/web/react/components/carousel.dots.md +376 -0
  7. package/corpus/web/react/components/carousel.thumbnails.md +395 -0
  8. package/corpus/web/react/components/collection-row.basic.md +179 -0
  9. package/corpus/web/react/components/combobox.autocomplete.md +293 -0
  10. package/corpus/web/react/components/dialog.modal.md +202 -0
  11. package/corpus/web/react/components/dialog.nonmodal.md +184 -0
  12. package/corpus/web/react/components/disclosure.basic.md +97 -0
  13. package/corpus/web/react/components/grid.channel-guide.md +453 -0
  14. package/corpus/web/react/components/link.basic.md +105 -0
  15. package/corpus/web/react/components/listbox.basic.md +263 -0
  16. package/corpus/web/react/components/menu.basic.md +294 -0
  17. package/corpus/web/react/components/menu.menubar.md +296 -0
  18. package/corpus/web/react/components/navigation-menu.basic.md +349 -0
  19. package/corpus/web/react/components/navigation-menu.dropdown.md +220 -0
  20. package/corpus/web/react/components/select.basic.md +318 -0
  21. package/corpus/web/react/components/select.native.md +108 -0
  22. package/corpus/web/react/components/switch.basic.md +151 -0
  23. package/corpus/web/react/components/toast.basic.md +112 -0
  24. package/corpus/web/react/components/tooltip.basic.md +139 -0
  25. package/corpus/web/react/global/global_rules.md +292 -0
  26. package/corpus/web/react/patterns.json +946 -0
  27. package/dist/config.js +35 -0
  28. package/dist/contracts/v1/types.js +6 -0
  29. package/dist/http.js +103 -0
  30. package/dist/index.js +8 -0
  31. package/dist/mcp/createServer.js +122 -0
  32. package/dist/mcp/response.js +7 -0
  33. package/dist/mcp/server.js +15 -0
  34. package/dist/repo/cache.js +27 -0
  35. package/dist/repo/globalRules.js +304 -0
  36. package/dist/repo/index.js +178 -0
  37. package/dist/repo/paths.js +25 -0
  38. package/dist/repo/sections.js +166 -0
  39. package/dist/smoke/smoke-remote-mcp.js +43 -0
  40. package/dist/telemetry.js +15 -0
  41. package/dist/telemetryWrap.js +48 -0
  42. package/dist/tools/getGlobalRules.js +33 -0
  43. package/dist/tools/getPattern.js +54 -0
  44. package/dist/tools/listPatterns.js +33 -0
  45. package/dist/utils/fs.js +18 -0
  46. package/dist/utils/hash.js +4 -0
  47. package/package.json +68 -0
@@ -0,0 +1,263 @@
1
+ ---
2
+ id: listbox.basic
3
+ title: Listbox
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.1.0
7
+ tags: [listbox, roving-tabindex, aria-activedescendant, multi-select, selection, form]
8
+ aliases: [option list, select list, single-select list, multi-select list, transfer list source, inline listbox, choice list, selectable list]
9
+ summary: An always-visible list of selectable options using role="listbox" and role="option", supporting single or multiple selection with roving tabindex or aria-activedescendant.
10
+ ---
11
+
12
+ # Listbox
13
+
14
+ Pattern ID: `listbox.basic`
15
+
16
+ An always-visible list of selectable options using `role="listbox"` and `role="option"`, supporting single or multiple selection with roving tabindex or `aria-activedescendant`.
17
+
18
+ This pattern is a permanently visible selection list. There is no trigger button, popup, or expand and collapse, and options carry plain content only.
19
+
20
+ ## Use When
21
+ - Use when the choices are permanently visible with no popup or expansion (e.g., an inline "Playback quality" list, a settings choice shown in place).
22
+ - Use when the user selects one option or multiple options from plain-text labels (e.g., "Select genres to follow", the source column of a transfer list).
23
+
24
+ ## Do Not Use When
25
+ - Do not use when a collapsed popup value picker is expected (use `select.native`, or `select.basic` for a custom-styled one).
26
+ - Do not use when the user types to filter the options (use `combobox.autocomplete`).
27
+ - Do not use when the options must contain interactive elements such as links or buttons (use `grid.basic`).
28
+ - Do not use when the user chooses among many independent on and off settings (use `checkbox.group`).
29
+ - Do not use when the control performs actions rather than holding a selection (use `menu.basic`).
30
+
31
+ ## Must Haves
32
+
33
+ ### Roles & structure
34
+ - The container is an element with `role="listbox"` and an accessible name via `aria-label` or `aria-labelledby`.
35
+ - Each option is an element with `role="option"` and `aria-selected="true|false"`.
36
+ - Options contain no interactive child elements (no links, buttons, inputs, or nested controls).
37
+
38
+ ### State & properties
39
+ - For multiple selection, the listbox has `aria-multiselectable="true"`.
40
+
41
+ ### Keyboard
42
+ - Arrow Down and Arrow Up move the active option to the next and previous option.
43
+ - Home and End move the active option to the first and last option.
44
+ - A printable character performs type-ahead, moving the active option to the next option whose label begins with the typed characters.
45
+ - For single-select, selection is operable from the keyboard alone.
46
+ - For multi-select, the selection is operable from the keyboard alone.
47
+ - Space toggles `aria-selected` on the active option.
48
+ - Shift+Arrow Up and Shift+Arrow Down extend the selection to the adjacent option.
49
+ - Ctrl+A (Cmd+A on macOS) selects all options.
50
+ - No modifier-only click is required to select an option.
51
+
52
+ ### Focus
53
+ - Manage the active option with either roving tabindex or `aria-activedescendant`, and apply one approach consistently.
54
+ - Roving tabindex: the focused option has `tabindex="0"` and all other options have `tabindex="-1"`.
55
+ - `aria-activedescendant`: the `role="listbox"` element has `tabindex="0"` and its `aria-activedescendant` references the active option's ID.
56
+ - Scripting keeps the active option scrolled into view.
57
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the active option.
58
+
59
+ ## Customizable
60
+ - Roving tabindex and `aria-activedescendant` are equal alternatives. Choose one and apply it consistently.
61
+ - For single-select, selection may follow focus (each Arrow move updates `aria-selected`) or require an explicit Enter or Space to commit. Selection-follows-focus suits short lists where each change is inexpensive; explicit selection suits lists where moving through options should not trigger side effects.
62
+ - The multi-select keyboard model above (Space to toggle, Shift+Arrow to extend, Ctrl+A to select all) may be extended with Shift+Home, Shift+End, and Ctrl+Space, provided Space toggle and Arrow navigation remain intact.
63
+
64
+ ## Don'ts
65
+ - Do not place interactive elements (links, buttons, checkboxes, inputs) inside `role="option"` elements.
66
+ - Do not require Ctrl or Cmd plus click as the only way to select multiple options.
67
+ - Do not use `role="menu"` or `role="menuitem"` for a selection list.
68
+ - Do not leave `aria-selected` out of sync with the visible selection state.
69
+
70
+ ## Golden Pattern
71
+
72
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
73
+
74
+ ```jsx
75
+ "use client";
76
+
77
+ function Listbox({ label, options, multiple = false }) {
78
+ const optionRefs = useRef([]);
79
+ const typeahead = useRef({ query: "", timer: null });
80
+ const [activeIndex, setActiveIndex] = useState(0);
81
+ const [selected, setSelected] = useState(() => (multiple ? new Set() : null));
82
+ const [hasFocus, setHasFocus] = useState(false);
83
+
84
+ const labelId = useId();
85
+
86
+ function isSelected(value) {
87
+ return multiple ? selected.has(value) : selected === value;
88
+ }
89
+
90
+ function toggleAt(index) {
91
+ const { value } = options[index];
92
+ if (multiple) {
93
+ setSelected((prev) => {
94
+ const next = new Set(prev);
95
+ if (next.has(value)) next.delete(value);
96
+ else next.add(value);
97
+ return next;
98
+ });
99
+ } else {
100
+ setSelected(value);
101
+ }
102
+ }
103
+
104
+ function focusOption(index) {
105
+ setActiveIndex(index);
106
+ // Roving tabindex: move DOM focus to the newly active option.
107
+ optionRefs.current[index]?.focus();
108
+ }
109
+
110
+ // Type-ahead: accumulate typed characters briefly, then jump to the
111
+ // first option whose label matches, so a keyboard user can reach a
112
+ // distant option without arrowing through every one.
113
+ function typeAhead(char) {
114
+ const state = typeahead.current;
115
+ clearTimeout(state.timer);
116
+ state.query += char.toLowerCase();
117
+ state.timer = setTimeout(() => {
118
+ state.query = "";
119
+ }, 500);
120
+ const match = options.findIndex((opt) =>
121
+ opt.label.toLowerCase().startsWith(state.query)
122
+ );
123
+ if (match >= 0) focusOption(match);
124
+ }
125
+
126
+ function onKeyDown(e) {
127
+ const last = options.length - 1;
128
+ let next = activeIndex;
129
+
130
+ switch (e.key) {
131
+ case "ArrowDown":
132
+ next = Math.min(last, activeIndex + 1);
133
+ break;
134
+ case "ArrowUp":
135
+ next = Math.max(0, activeIndex - 1);
136
+ break;
137
+ case "Home":
138
+ next = 0;
139
+ break;
140
+ case "End":
141
+ next = last;
142
+ break;
143
+ case " ":
144
+ // Space toggles in multi-select and commits in single-select;
145
+ // preventDefault stops the page from scrolling.
146
+ e.preventDefault();
147
+ toggleAt(activeIndex);
148
+ return;
149
+ case "Enter":
150
+ // Single-select: Enter commits the active option.
151
+ if (!multiple) toggleAt(activeIndex);
152
+ return;
153
+ default:
154
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
155
+ typeAhead(e.key);
156
+ }
157
+ return;
158
+ }
159
+
160
+ e.preventDefault();
161
+ focusOption(next);
162
+ // Single-select: selection follows focus.
163
+ if (!multiple) toggleAt(next);
164
+ }
165
+
166
+ return (
167
+ <div>
168
+ <span id={labelId}>{label}</span>
169
+ <ul
170
+ role="listbox"
171
+ aria-labelledby={labelId}
172
+ aria-multiselectable={multiple ? "true" : undefined}
173
+ onKeyDown={onKeyDown}
174
+ onFocus={() => setHasFocus(true)}
175
+ onBlur={() => setHasFocus(false)}
176
+ style={{
177
+ listStyle: "none",
178
+ margin: 0,
179
+ padding: 0,
180
+ border: "1px solid",
181
+ maxHeight: 200,
182
+ overflow: "auto",
183
+ }}
184
+ >
185
+ {options.map((opt, idx) => {
186
+ const isActive = idx === activeIndex;
187
+ return (
188
+ <li
189
+ key={opt.value}
190
+ ref={(el) => (optionRefs.current[idx] = el)}
191
+ role="option"
192
+ aria-selected={isSelected(opt.value) ? "true" : "false"}
193
+ // Roving tabindex: only the active option is in the tab order.
194
+ tabIndex={isActive ? 0 : -1}
195
+ onClick={() => {
196
+ focusOption(idx);
197
+ toggleAt(idx);
198
+ }}
199
+ style={{
200
+ display: "flex",
201
+ alignItems: "center",
202
+ gap: 8,
203
+ padding: "6px 8px",
204
+ cursor: "default",
205
+ // Focus ring keys to real DOM focus, not just active index,
206
+ // so it disappears when the listbox is not focused.
207
+ outline: isActive && hasFocus ? "2px solid" : "none",
208
+ outlineOffset: 1,
209
+ }}
210
+ >
211
+ {/* Selection is conveyed by aria-selected; the mark is decorative. */}
212
+ <span aria-hidden="true" style={{ width: "1em" }}>
213
+ {isSelected(opt.value) ? "✓" : ""}
214
+ </span>
215
+ {opt.label}
216
+ </li>
217
+ );
218
+ })}
219
+ </ul>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ export function ListboxDemo() {
225
+ return (
226
+ <div>
227
+ <Listbox label="Playback quality" options={QUALITY} />
228
+ <Listbox label="Genres to follow" options={GENRES} multiple />
229
+ </div>
230
+ );
231
+ }
232
+
233
+ const QUALITY = [
234
+ { value: "auto", label: "Auto" },
235
+ { value: "1080p", label: "1080p" },
236
+ { value: "720p", label: "720p" },
237
+ { value: "480p", label: "480p" },
238
+ ];
239
+
240
+ const GENRES = [
241
+ { value: "drama", label: "Drama" },
242
+ { value: "comedy", label: "Comedy" },
243
+ { value: "thriller", label: "Thriller" },
244
+ { value: "documentary", label: "Documentary" },
245
+ { value: "sci-fi", label: "Sci-Fi" },
246
+ ];
247
+ ```
248
+
249
+ ## Acceptance Checks
250
+
251
+ Keyboard
252
+ - Arrow Down and Arrow Up move the active option, and the active option shows a visible focus indicator.
253
+ - Home moves the active option to the first option; End moves it to the last option.
254
+ - In a multi-select listbox, Space toggles the selected state of the active option without moving focus.
255
+ - In a multi-select listbox, Shift+Arrow Up and Shift+Arrow Down extend the selection to the adjacent option.
256
+ - In a multi-select listbox, Ctrl+A (Cmd+A on macOS) selects all options.
257
+ - Typing a printable character moves the active option to the next option whose label begins with the typed characters.
258
+ - No option requires Ctrl or Cmd plus click to be selected.
259
+
260
+ Screen Reader
261
+ - The container is announced as a listbox, and as multi-selectable when `aria-multiselectable="true"` is set.
262
+ - Each option is announced as an option together with its selected state (from `aria-selected`).
263
+ - Moving the active option announces the newly active option and its selected state.
@@ -0,0 +1,294 @@
1
+ ---
2
+ id: menu.basic
3
+ title: Menu
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.1.0
7
+ tags: [menu, actions-menu, roving-tabindex, overflow-menu, kebab-menu, commands]
8
+ aliases: [kebab menu, overflow menu, actions menu, three-dot menu, more menu, menu button, context menu button, command menu]
9
+ summary: A button that opens a menu of commands using role='menu' and role='menuitem', with roving tabindex focus management and the full menu keyboard contract.
10
+ ---
11
+
12
+ # Menu
13
+
14
+ Pattern ID: `menu.basic`
15
+
16
+ A button that opens a menu of commands using `role="menu"` and `role="menuitem"`, with roving tabindex focus management and the full menu keyboard contract.
17
+
18
+ `role="menu"` is a commitment to implement the entire menu keyboard contract below. A partially implemented menu is worse than a plain list of buttons, so use this pattern only when every requirement in Must Haves is met.
19
+
20
+ ## Use When
21
+ - Use when a button reveals a set of in-place commands or actions (e.g., "Rename", "Duplicate", "Delete", "Export").
22
+ - Use when a button exposes an overflow ("...") or kebab action menu on a toolbar, card, or table row.
23
+ - Use when the items perform actions in place, not navigation to a URL.
24
+ - Use when you will implement the full menu keyboard contract (roving tabindex, Arrow keys, Home/End, type-ahead, Esc).
25
+
26
+ ## Do Not Use When
27
+ - Do not use when the items are links or navigate to a URL (use `navigation-menu.dropdown`, or for a primary navigation bar use `navigation-menu.basic`).
28
+ - Do not use when the user picks a value to submit in a form (use `select.native`, or for type-to-filter selection use `combobox.autocomplete`).
29
+ - Do not use when the trigger shows and hides arbitrary content rather than a list of commands (use `disclosure.basic`).
30
+ - Do not use when building a persistent application command bar with horizontal top-level items (use `menu.menubar`).
31
+
32
+ ## Must Haves
33
+
34
+ ### Roles & structure
35
+ - Use a native `<button>` (preferred), or `role="button"` only when a native button cannot be used.
36
+ - 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.
37
+ - The menu container has `role="menu"`.
38
+ - Each command uses `role="menuitem"`.
39
+ - A stateful item that toggles in place uses `role="menuitemcheckbox"` or `role="menuitemradio"` with `aria-checked="true|false"`.
40
+ - Dividers between groups of items use `role="separator"`.
41
+ - The menu contains only menu parts (`menuitem`, `menuitemcheckbox`, `menuitemradio`, `separator`, and grouping wrappers with `role="none"`), with no links, headings, or form inputs.
42
+
43
+ ### Accessible name
44
+ - The trigger has an accessible name that describes its purpose or action.
45
+ - For an icon-only trigger, provide an accessible name using `aria-label` or `aria-labelledby` (e.g., "More actions").
46
+ - Icons within the trigger must be decorative (`aria-hidden="true"`).
47
+ - The menu container has an accessible name via `aria-labelledby` referencing the trigger, or via `aria-label`.
48
+
49
+ ### State & properties
50
+ - The trigger has `aria-haspopup="menu"`.
51
+ - The trigger has `aria-expanded="true|false"` reflecting the open or closed state of the menu.
52
+ - The trigger has `aria-controls="IDREF"` pointing to the menu container.
53
+ - The menu is shown/hidden in the DOM (e.g., via the `hidden` attribute), so that when closed, its items cannot be reached by keyboard or screen readers.
54
+
55
+ ### Keyboard
56
+ - On the trigger, Enter, Space, and Arrow Down open the menu and move focus to the first item.
57
+ - On the trigger, Arrow Up opens the menu and moves focus to the last item.
58
+ - Within the menu, Arrow Up and Arrow Down move focus between items and wrap at the ends.
59
+ - Within the menu, Home moves focus to the first item and End moves focus to the last item.
60
+ - Within the menu, Enter or Space activates the focused item, then closes the menu and returns focus to the trigger.
61
+ - Within the menu, a printable character moves focus to the next item whose label starts with that character (type-ahead).
62
+
63
+ ### Focus
64
+ - Focus is managed with roving tabindex: the active item has `tabindex="0"` and all other items have `tabindex="-1"`.
65
+ - The active item receives DOM focus via `element.focus()`.
66
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the trigger and the menu items.
67
+ - Focus returns to the trigger when the menu closes via activation or Esc, deferred with `requestAnimationFrame` so the trigger is focusable when the call runs.
68
+
69
+ ### Dismissal
70
+ - Esc closes the menu and returns focus to the trigger.
71
+ - Tab moves focus out of the menu and closes it.
72
+ - An outside click or focus loss closes the menu.
73
+
74
+ ## Customizable
75
+ - Roving tabindex is the default focus model. `aria-activedescendant` on the menu container is an acceptable alternative when DOM focus must remain on a single owner element. It has weaker screen-reader support, and the active-item visual style must be painted manually because no element holds DOM focus.
76
+ - Items that toggle a setting in place may use `role="menuitemcheckbox"` or `role="menuitemradio"` with `aria-checked` instead of `role="menuitem"`.
77
+ - Submenus may be supported: Arrow Right on a parent item opens its submenu and Arrow Left closes the submenu and returns focus to the parent item.
78
+
79
+ ## Don'ts
80
+ - Do not use `role="menu"` for navigation or for items that are links.
81
+ - Do not place links, headings, form inputs, or arbitrary content inside a `role="menu"` container.
82
+ - Do not declare `role="menu"` without implementing the full keyboard contract (roving tabindex, Arrow keys, Home/End, type-ahead, Esc).
83
+ - Do not forget to return focus to the trigger when the menu closes via activation or Esc.
84
+ - Do not leave the menu visible while `aria-expanded="false"` (and vice versa).
85
+ - Do not reach for `aria-activedescendant` when roving tabindex is simpler for the case at hand.
86
+
87
+ ## Golden Pattern
88
+
89
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
90
+
91
+ ```jsx
92
+ "use client";
93
+
94
+ export function MenuButton({ label = "Actions", items = DEFAULT_ITEMS }) {
95
+ const [open, setOpen] = useState(false);
96
+ const [activeIndex, setActiveIndex] = useState(0);
97
+
98
+ const triggerRef = useRef(null);
99
+ const menuRef = useRef(null);
100
+ const itemRefs = useRef([]);
101
+
102
+ const baseId = useId();
103
+ const triggerId = `${baseId}-trigger`;
104
+ const menuId = `${baseId}-menu`;
105
+
106
+ // Roving tabindex: move DOM focus to the active item whenever the menu is open.
107
+ useEffect(() => {
108
+ if (open) itemRefs.current[activeIndex]?.focus();
109
+ }, [open, activeIndex]);
110
+
111
+ function openMenu(index) {
112
+ setActiveIndex(index);
113
+ setOpen(true);
114
+ }
115
+
116
+ function closeToTrigger() {
117
+ setOpen(false);
118
+ // Defer focus until the menu has left the tab order and the trigger is focusable again.
119
+ requestAnimationFrame(() => triggerRef.current?.focus());
120
+ }
121
+
122
+ function activate(index) {
123
+ items[index]?.onSelect?.();
124
+ closeToTrigger();
125
+ }
126
+
127
+ // Close on outside click without stealing focus back to the trigger.
128
+ useEffect(() => {
129
+ if (!open) return;
130
+
131
+ function onPointerDown(event) {
132
+ const trigger = triggerRef.current;
133
+ const menu = menuRef.current;
134
+ if (!trigger || !menu) return;
135
+ if (trigger.contains(event.target) || menu.contains(event.target)) return;
136
+ setOpen(false);
137
+ }
138
+
139
+ document.addEventListener("pointerdown", onPointerDown);
140
+ return () => document.removeEventListener("pointerdown", onPointerDown);
141
+ }, [open]);
142
+
143
+ // Close on focus loss: if focus leaves both the trigger and the menu, close without
144
+ // pulling focus back, so Tab and programmatic focus moves out of the menu dismiss it.
145
+ function onMenuBlur(event) {
146
+ const next = event.relatedTarget;
147
+ if (next && (triggerRef.current?.contains(next) || menuRef.current?.contains(next))) return;
148
+ setOpen(false);
149
+ }
150
+
151
+ function onTriggerKeyDown(event) {
152
+ switch (event.key) {
153
+ case "Enter":
154
+ case " ":
155
+ case "ArrowDown":
156
+ event.preventDefault();
157
+ openMenu(0);
158
+ break;
159
+ case "ArrowUp":
160
+ event.preventDefault();
161
+ openMenu(items.length - 1);
162
+ break;
163
+ default:
164
+ break;
165
+ }
166
+ }
167
+
168
+ function onMenuKeyDown(event) {
169
+ switch (event.key) {
170
+ case "ArrowDown":
171
+ event.preventDefault();
172
+ setActiveIndex((i) => (i + 1) % items.length); // wraps at the end
173
+ break;
174
+ case "ArrowUp":
175
+ event.preventDefault();
176
+ setActiveIndex((i) => (i - 1 + items.length) % items.length); // wraps at the start
177
+ break;
178
+ case "Home":
179
+ event.preventDefault();
180
+ setActiveIndex(0);
181
+ break;
182
+ case "End":
183
+ event.preventDefault();
184
+ setActiveIndex(items.length - 1);
185
+ break;
186
+ case "Enter":
187
+ case " ":
188
+ event.preventDefault();
189
+ activate(activeIndex);
190
+ break;
191
+ case "Escape":
192
+ event.preventDefault();
193
+ closeToTrigger();
194
+ break;
195
+ case "Tab":
196
+ // Tab moves focus out of the menu; let it proceed naturally and close.
197
+ setOpen(false);
198
+ break;
199
+ default:
200
+ // Type-ahead: jump to the next item whose label starts with the typed character.
201
+ if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
202
+ const query = event.key.toLowerCase();
203
+ for (let offset = 1; offset <= items.length; offset++) {
204
+ const idx = (activeIndex + offset) % items.length;
205
+ if (items[idx].label.toLowerCase().startsWith(query)) {
206
+ setActiveIndex(idx);
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ break;
212
+ }
213
+ }
214
+
215
+ return (
216
+ <div style={{ position: "relative", display: "inline-block" }}>
217
+ <button
218
+ ref={triggerRef}
219
+ id={triggerId}
220
+ type="button"
221
+ aria-haspopup="menu"
222
+ aria-expanded={open ? "true" : "false"}
223
+ aria-controls={menuId}
224
+ onClick={() => (open ? setOpen(false) : openMenu(0))}
225
+ onKeyDown={onTriggerKeyDown}
226
+ >
227
+ {label}
228
+ <span aria-hidden="true"> ⋯</span>
229
+ </button>
230
+
231
+ <ul
232
+ ref={menuRef}
233
+ id={menuId}
234
+ role="menu"
235
+ aria-labelledby={triggerId}
236
+ hidden={!open}
237
+ onKeyDown={onMenuKeyDown}
238
+ onBlur={onMenuBlur}
239
+ style={{
240
+ position: "absolute",
241
+ top: "100%",
242
+ left: 0,
243
+ listStyle: "none",
244
+ margin: 0,
245
+ padding: 4,
246
+ border: "1px solid GrayText", // opaque, bounded overlay (system color, not decorative)
247
+ background: "Canvas",
248
+ }}
249
+ >
250
+ {items.map((item, idx) => (
251
+ <li
252
+ key={item.id}
253
+ role="menuitem"
254
+ tabIndex={idx === activeIndex ? 0 : -1}
255
+ ref={(el) => {
256
+ itemRefs.current[idx] = el;
257
+ }}
258
+ onClick={() => activate(idx)}
259
+ style={{ padding: "6px 8px", cursor: "default" }}
260
+ >
261
+ {item.label}
262
+ </li>
263
+ ))}
264
+ </ul>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ const DEFAULT_ITEMS = [
270
+ { id: "rename", label: "Rename", onSelect: () => alert("Rename") },
271
+ { id: "duplicate", label: "Duplicate", onSelect: () => alert("Duplicate") },
272
+ { id: "export", label: "Export", onSelect: () => alert("Export") },
273
+ { id: "delete", label: "Delete", onSelect: () => alert("Delete") },
274
+ ];
275
+ ```
276
+
277
+ ## Acceptance Checks
278
+
279
+ Keyboard
280
+ - Enter, Space, or Arrow Down on the trigger opens the menu and focuses the first item.
281
+ - Arrow Up on the trigger opens the menu and focuses the last item.
282
+ - Arrow Up and Arrow Down move focus between items and wrap at the ends.
283
+ - Home focuses the first item and End focuses the last item.
284
+ - Typing a printable character moves focus to the next item whose label starts with that character.
285
+ - Enter or Space activates the focused item, closes the menu, and returns focus to the trigger.
286
+ - Esc closes the menu and returns focus to the trigger.
287
+ - Tab closes the menu and moves focus out of it.
288
+ - A click outside the trigger and menu closes the menu.
289
+
290
+ Screen Reader
291
+ - The trigger exposes a menu popup and its expanded or collapsed state (from `aria-haspopup="menu"` and `aria-expanded`).
292
+ - The container is announced as a menu with its accessible name.
293
+ - Items are announced as menu items, and stateful items convey their checked state via `aria-checked`.
294
+ - Closed menus are not reachable.