@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,220 @@
1
+ ---
2
+ id: navigation-menu.dropdown
3
+ title: Navigation Dropdown
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.1.0
7
+ tags: [navigation, dropdown, disclosure, account-menu, avatar-menu, header]
8
+ aliases: [account dropdown, user menu, avatar menu, profile menu, nav dropdown, links dropdown, account menu, header dropdown]
9
+ summary: A single-trigger, non-modal dropdown that reveals a short list of navigation links and optional actions, using a native button with aria-expanded plus DOM show/hide, and never role="menu".
10
+ ---
11
+
12
+ # Navigation Dropdown
13
+
14
+ Pattern ID: `navigation-menu.dropdown`
15
+
16
+ A single-trigger, non-modal dropdown that reveals a short list of navigation links and optional actions, using a native `<button>` with `aria-expanded` plus DOM show/hide, and never `role="menu"`.
17
+
18
+ This is a disclosure of links, not an ARIA menu. The revealed content is primarily navigation destinations (with an optional trailing action such as "Sign out"), so it uses `aria-expanded` alone as the state signal and does not use `role="menu"` or `aria-haspopup`.
19
+
20
+ ## Use When
21
+ - Use when a single dedicated control reveals a compact set of navigation destinations (e.g., an avatar button or an "Account" button opening "Profile", "Settings", "Billing").
22
+ - Use when the revealed content is short and stable, typically 3-10 items that are primarily links.
23
+ - Use when the revealed list ends in an optional single incidental action alongside the links (e.g., "Sign out").
24
+
25
+ ## Do Not Use When
26
+ - Do not use when the site navigation is a bar of several top-level items or sibling dropdowns (use `navigation-menu.basic`).
27
+ - Do not use when the revealed items are primarily in-place commands or actions that need arrow-key navigation and `role="menu"` (use `menu.basic`).
28
+ - Do not use when the control chooses a value to submit in a form (use `select.native`).
29
+ - Do not use when the trigger shows and hides arbitrary non-navigational content (use `disclosure.basic`).
30
+
31
+ ## Must Haves
32
+
33
+ ### Roles & structure
34
+ - Use a native `<button>` (preferred), or `role="button"` only when a native button cannot be used.
35
+ - 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.
36
+ - The list is shown/hidden in the DOM (e.g., via the `hidden` attribute), so that when closed, its content cannot be reached by keyboard or screen readers.
37
+ - Items are contained in a list structure (`<ul><li>…</li></ul>`) holding links and an optional trailing action `<button>`.
38
+ - Any links and buttons comply with the rules for the `link.basic` and `button.basic` patterns.
39
+
40
+ ### Accessible name
41
+ - The button has an accessible name that describes its purpose or action.
42
+ - For an icon-only avatar button, provide an accessible name using `aria-label` or `aria-labelledby`.
43
+ - Icons within the button are decorative (`aria-hidden="true"`).
44
+
45
+ ### State & properties
46
+ - The button reflects open state with `aria-expanded="true|false"`.
47
+ - The button is associated with the list container via `aria-controls="IDREF"`.
48
+ - The button does not carry `aria-haspopup`.
49
+ - A list of links is not a menu, listbox, tree, grid, or dialog, so `aria-haspopup` announces a widget that is not present; `aria-expanded` alone is the correct signal.
50
+
51
+ ### Focus
52
+ - When the dropdown opens, focus remains on the invoking button.
53
+ - Keyboard users reach the first item with Tab.
54
+ - The dropdown does not trap focus. Users can Tab through the items and continue to the rest of the page.
55
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the invoking button and every item inside the list.
56
+
57
+ ### Dismissal
58
+ - When focus moves outside the button and list (Tab away, click elsewhere), the dropdown closes.
59
+ - Esc closes the dropdown and returns focus to the invoking button (use `requestAnimationFrame` for the focus restore).
60
+
61
+ ## Customizable
62
+ - Whether the list contains only links or links plus a single trailing action button (e.g., "Sign out").
63
+ - Whether the dropdown closes when an item is activated (often yes).
64
+ - Positioning of the list (left/right alignment, above/below) as long as DOM order and focus order remain logical.
65
+ - The disclosure indicator (e.g., a down caret) is decorative and may be omitted; when present it is marked `aria-hidden="true"` and does not replace the button's accessible name.
66
+ - Accessible labeling strategy:
67
+ - The button label may be visible text (e.g., "Account") or, for an icon-only avatar button, an `aria-label` or `aria-labelledby`.
68
+ - The list may carry an optional `aria-label` (e.g., `aria-label="Account"`).
69
+
70
+ ## Don'ts
71
+ - Do not use `role="menu"` or `role="menuitem"`; the items are links in a list, not menu commands.
72
+ - Do not add `aria-haspopup` to the button; it announces a menu that is not there.
73
+ - Do not render the button without `aria-expanded`; state alone is what conveys open or closed.
74
+ - Do not make hover or pointer click the only way to open the dropdown; opening must also work with keyboard Enter and Space.
75
+ - Do not leave the list visible while `aria-expanded="false"` (and vice versa).
76
+ - Do not close the dropdown in a way that strands focus (e.g., removing the focused element without moving focus).
77
+ - Do not let a hidden dropdown be the only path to a destination; expose those links elsewhere on the site as well.
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 NavigationDropdown() {
87
+ const [open, setOpen] = useState(false);
88
+ const btnRef = useRef(null);
89
+ const listRef = useRef(null);
90
+
91
+ const baseId = useId();
92
+ const buttonId = `${baseId}-button`;
93
+ const listId = `${baseId}-list`;
94
+
95
+ const close = useCallback(() => setOpen(false), []);
96
+ const toggle = useCallback(() => setOpen((v) => !v), []);
97
+
98
+ // Close on outside pointer press (non-modal disclosure).
99
+ useEffect(() => {
100
+ if (!open) return;
101
+ const onPointerDown = (e) => {
102
+ const btn = btnRef.current;
103
+ const list = listRef.current;
104
+ if (!btn || !list) return;
105
+ if (!btn.contains(e.target) && !list.contains(e.target)) close();
106
+ };
107
+ document.addEventListener("pointerdown", onPointerDown);
108
+ return () => document.removeEventListener("pointerdown", onPointerDown);
109
+ }, [open, close]);
110
+
111
+ // Close when focus leaves button + list.
112
+ useEffect(() => {
113
+ if (!open) return;
114
+ const onFocusIn = (e) => {
115
+ const btn = btnRef.current;
116
+ const list = listRef.current;
117
+ if (!btn || !list) return;
118
+ if (!btn.contains(e.target) && !list.contains(e.target)) close();
119
+ };
120
+ document.addEventListener("focusin", onFocusIn, true);
121
+ return () => document.removeEventListener("focusin", onFocusIn, true);
122
+ }, [open, close]);
123
+
124
+ // Esc closes and returns focus to the trigger.
125
+ useEffect(() => {
126
+ if (!open) return;
127
+ const onKeyDown = (e) => {
128
+ if (e.key !== "Escape") return;
129
+ e.preventDefault();
130
+ close();
131
+ // rAF defers focus until React flushes the close and the trigger is focusable again.
132
+ requestAnimationFrame(() => btnRef.current?.focus());
133
+ };
134
+ document.addEventListener("keydown", onKeyDown, true);
135
+ return () => document.removeEventListener("keydown", onKeyDown, true);
136
+ }, [open, close]);
137
+
138
+ // Close on Tab past the last item and Shift+Tab before the first item.
139
+ const onListKeyDown = (e) => {
140
+ if (!open || e.key !== "Tab") return;
141
+
142
+ const list = listRef.current;
143
+ if (!list) return;
144
+
145
+ const focusables = Array.from(
146
+ list.querySelectorAll(
147
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
148
+ )
149
+ );
150
+ if (!focusables.length) return;
151
+
152
+ const first = focusables[0];
153
+ const last = focusables[focusables.length - 1];
154
+
155
+ if (!e.shiftKey && document.activeElement === last) close();
156
+ if (e.shiftKey && document.activeElement === first) close();
157
+ };
158
+
159
+ return (
160
+ <div>
161
+ {/* No aria-haspopup: a list of links is not a menu. aria-expanded is the state signal. */}
162
+ <button
163
+ ref={btnRef}
164
+ id={buttonId}
165
+ type="button"
166
+ aria-expanded={open ? "true" : "false"}
167
+ aria-controls={listId}
168
+ onClick={toggle}
169
+ >
170
+ Account <span aria-hidden="true">▼</span>
171
+ </button>
172
+
173
+ <ul
174
+ id={listId}
175
+ ref={listRef}
176
+ hidden={!open}
177
+ aria-label="Account"
178
+ onKeyDown={onListKeyDown}
179
+ >
180
+ {ACCOUNT_LINKS.map((item) => (
181
+ <li key={item.href}>
182
+ <a href={item.href}>{item.label}</a>
183
+ </li>
184
+ ))}
185
+ <li>
186
+ <button type="button" onClick={() => alert("Signed out")}>
187
+ Sign out
188
+ </button>
189
+ </li>
190
+ </ul>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ const ACCOUNT_LINKS = [
196
+ { label: "Profile", href: "/account/profile" },
197
+ { label: "Watchlist", href: "/account/watchlist" },
198
+ { label: "Subscription", href: "/account/subscription" },
199
+ { label: "Settings", href: "/account/settings" },
200
+ ];
201
+ ```
202
+
203
+ ## Acceptance Checks
204
+
205
+ Keyboard
206
+ - Tab reaches the invoking button.
207
+ - Enter or Space opens the dropdown; focus remains on the button.
208
+ - Tab moves from the button into the first item, then through each item, then out to the next focusable element on the page.
209
+ - Tab from the last item closes the dropdown and moves to the next focusable element after it.
210
+ - Shift+Tab from the first item closes the dropdown and moves focus back to the button.
211
+ - Esc closes the dropdown and returns focus to the button.
212
+ - If focus moves outside the button and list (Tab away or click elsewhere), the dropdown closes.
213
+
214
+ Screen Reader
215
+ - The button announces expanded/collapsed state via `aria-expanded`; the attribute is present and flips between `"true"` and `"false"` on every open and close.
216
+ - The button is not announced as a menu; no `role="menu"` or `aria-haspopup` is present.
217
+ - The button exposes an accessible name (visible text or `aria-label`/`aria-labelledby` on an icon-only avatar button).
218
+ - When closed, the items are not reachable.
219
+ - When open, the list and its items are reachable in reading and focus order.
220
+ - Items announce as links and navigate correctly; the trailing action announces as a button and activates correctly.
@@ -0,0 +1,318 @@
1
+ ---
2
+ id: select.basic
3
+ title: Select
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.2.0
7
+ tags: [select, form, form-select, listbox, dropdown]
8
+ aliases: [form select, styled select, listbox select, dropdown select]
9
+ summary: A custom-styled form select that matches native <select> behavior using a button trigger + listbox popup, and a visually hidden native <select> for form submission and browser autofill.
10
+ ---
11
+
12
+ # Select
13
+
14
+ Pattern ID: `select.basic`
15
+
16
+ A custom-styled form select that matches native `<select>` behavior using a button trigger + listbox popup, and a visually hidden native `<select>` for form submission and browser autofill.
17
+
18
+ Native `<select>` elements are accessible out of the box. This pattern applies only when building a **custom-styled** select input.
19
+
20
+ ## Use When
21
+ - Use when a form input must allow selecting **one option** from a predefined set.
22
+ - Use when the options are short labels and the selection is discrete (not freeform text).
23
+ - Use for primary or secondary forms anywhere on a site or application.
24
+
25
+ ## Do Not Use When
26
+ - Do not use when a native `<select>` styled with CSS meets the need (use `select.native`).
27
+ - Do not use when the user needs to type to filter a large list (use `combobox.autocomplete`).
28
+ - Do not use when multiple selections are required (use `listbox.basic`).
29
+ - Do not use for navigation menus or account menus (use `navigation-menu.basic` or `navigation-menu.dropdown`).
30
+
31
+ ## Must Haves
32
+ - Provide a visible label for the field. Prefer a native `<label>` and ensure the custom UI is programmatically associated with the label (see `aria-labelledby` below).
33
+ - Include a **visually hidden native `<select>`** (required).
34
+ - The hidden `<select>` includes the same options and current value as the custom UI.
35
+ - The hidden `<select>` includes form attributes as needed (e.g., `name`, `required`, `disabled`).
36
+ - The hidden `<select>` is not focusable (so users don't tab to both controls).
37
+ - The visible interactive control is a native `<button>` (preferred) or `role="button"` only when a native button cannot be used.
38
+ - If using `role="button"`, add `tabindex="0"` and keyboard support for Enter and Space, ensuring Space prevents page scrolling while activating.
39
+ - The button must expose "opens a listbox" semantics and state:
40
+ - `aria-haspopup="listbox"`
41
+ - `aria-expanded="true|false"` reflecting open/closed
42
+ - `aria-controls="IDREF"` pointing to the listbox element
43
+ - The button must have an accessible name that includes the field label and current value.
44
+ - Recommended: `aria-labelledby="{labelId} {valueId}"` where:
45
+ - `labelId` references the label element
46
+ - `valueId` references an element containing the currently selected value text
47
+ - The popup must use listbox semantics:
48
+ - The popup contains one element with `role="listbox"` and a stable ID (the same one referenced by `aria-controls`).
49
+ - Each option uses `role="option"` and has a stable ID.
50
+ - Each option exposes selection with `aria-selected="true|false"`.
51
+ - When the listbox is open, the "currently active" option is programmatically indicated (recommended).
52
+ - Preferred approach: keep focus on the button and set `aria-activedescendant="{optionId}"` on the button to point to the active option.
53
+ - The listbox must be shown/hidden in the DOM so that when closed it cannot be reached by keyboard or screen readers (e.g., via `hidden`).
54
+ - Keyboard UX must match native `<select>` expectations:
55
+ - Tab / Shift+Tab moves focus to/from the button like a normal form field (no focus trap).
56
+ - Enter or Space on the button opens the listbox.
57
+ - When open:
58
+ - Arrow Up/Down moves the active option.
59
+ - Enter or Space selects the active option and closes the listbox.
60
+ - Esc closes the listbox and returns the user to the button state.
61
+ - Clicking/tapping outside closes the listbox.
62
+ - Screen reader UX must match native `<select>` expectations:
63
+ - The label and current value are announced together (via `aria-labelledby`).
64
+ - Expanded/collapsed state is announced (via `aria-expanded`).
65
+ - The user can perceive which option is selected (`aria-selected`).
66
+ - The user can perceive which option is currently active when navigating (`aria-activedescendant` recommended).
67
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the button that opens the select, and all option elements inside.
68
+
69
+ ## Customizable
70
+ - Whether Arrow Up/Down changes selection while closed vs opens then moves active option.
71
+ - Choose one behavior and test with keyboard + screen readers.
72
+ - Whether the listbox closes immediately after selection (usually yes for single-select).
73
+ - Visual styling (caret icon, borders, spacing, animation), as long as it does not replace accessible naming.
74
+ - Optional grouping or separators, as long as listbox/option semantics remain correct and options remain navigable.
75
+
76
+ ## Don'ts
77
+ - Do not omit the visually hidden native `<select>` when this control participates in form submission or should support autofill.
78
+ - Do not implement the trigger as a non-interactive element (e.g., `<div>`) without button semantics.
79
+ - Do not use `role="menu"` / `role="menuitem"` for select options.
80
+ - Do not make each option tabbable (avoid forcing users to Tab through options).
81
+ - Do not leave the listbox visible while `aria-expanded="false"` (and vice versa).
82
+ - Do not allow the custom UI and hidden `<select>` to get out of sync (options/value must match).
83
+
84
+ ## Golden Pattern
85
+
86
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
87
+
88
+ ```jsx
89
+ "use client";
90
+
91
+ const OPTIONS = [
92
+ { value: "red", label: "Red" },
93
+ { value: "orange", label: "Orange" },
94
+ { value: "yellow", label: "Yellow" },
95
+ { value: "green", label: "Green" },
96
+ ];
97
+
98
+ // Visually-hidden styles matching the global sr-only utility (global.sr-only).
99
+ const srOnlySelectStyle = {
100
+ position: "absolute",
101
+ width: 1,
102
+ height: 1,
103
+ padding: 0,
104
+ margin: -1,
105
+ overflow: "hidden",
106
+ clip: "rect(0, 0, 0, 0)",
107
+ whiteSpace: "nowrap",
108
+ border: 0,
109
+ };
110
+
111
+ export function CustomSelectBasic() {
112
+ const [open, setOpen] = useState(false);
113
+ const [value, setValue] = useState("red");
114
+ const [activeIndex, setActiveIndex] = useState(0);
115
+
116
+ const rootRef = useRef(null);
117
+ const btnRef = useRef(null);
118
+
119
+ const labelId = "color-select-label";
120
+ const valueId = "color-select-value";
121
+ const listboxId = "color-select-listbox";
122
+ const nativeId = "color-select-native";
123
+
124
+ const selectedIndex = useMemo(
125
+ () => Math.max(0, OPTIONS.findIndex((o) => o.value === value)),
126
+ [value],
127
+ );
128
+
129
+ const selected = OPTIONS[selectedIndex];
130
+
131
+ function openListbox() {
132
+ setActiveIndex(selectedIndex);
133
+ setOpen(true);
134
+ }
135
+
136
+ function closeListbox() {
137
+ setOpen(false);
138
+ }
139
+
140
+ function chooseIndex(idx) {
141
+ const opt = OPTIONS[idx];
142
+ if (!opt) return;
143
+ setValue(opt.value);
144
+ setActiveIndex(idx);
145
+ closeListbox();
146
+ requestAnimationFrame(() => btnRef.current?.focus());
147
+ }
148
+
149
+ // Close on outside click.
150
+ useEffect(() => {
151
+ if (!open) return;
152
+
153
+ function onPointerDown(e) {
154
+ const root = rootRef.current;
155
+ if (!root) return;
156
+ if (!root.contains(e.target)) closeListbox();
157
+ }
158
+
159
+ document.addEventListener("pointerdown", onPointerDown);
160
+ return () => document.removeEventListener("pointerdown", onPointerDown);
161
+ }, [open]);
162
+
163
+ // Close when tabbing away (no focus trap).
164
+ function onBlurCapture(e) {
165
+ if (!open) return;
166
+ const root = rootRef.current;
167
+ const next = e.relatedTarget;
168
+ if (!root || !next) return;
169
+ if (!root.contains(next)) closeListbox();
170
+ }
171
+
172
+ function moveActive(delta) {
173
+ setActiveIndex((i) => {
174
+ const next = Math.max(0, Math.min(OPTIONS.length - 1, i + delta));
175
+ return next;
176
+ });
177
+ }
178
+
179
+ function onTriggerClick() {
180
+ if (open) closeListbox();
181
+ else openListbox();
182
+ }
183
+
184
+ function onTriggerKeyDown(e) {
185
+ switch (e.key) {
186
+ case "ArrowDown":
187
+ e.preventDefault();
188
+ if (!open) openListbox();
189
+ moveActive(+1);
190
+ break;
191
+ case "ArrowUp":
192
+ e.preventDefault();
193
+ if (!open) openListbox();
194
+ moveActive(-1);
195
+ break;
196
+ case "Enter":
197
+ case " ":
198
+ e.preventDefault();
199
+ if (!open) openListbox();
200
+ else chooseIndex(activeIndex);
201
+ break;
202
+ case "Escape":
203
+ if (open) {
204
+ e.preventDefault();
205
+ closeListbox();
206
+ }
207
+ break;
208
+ default:
209
+ break;
210
+ }
211
+ }
212
+
213
+ return (
214
+ <div
215
+ ref={rootRef}
216
+ onBlurCapture={onBlurCapture}
217
+ style={{ display: "inline-block", position: "relative" }}
218
+ >
219
+ <label id={labelId} htmlFor={nativeId} style={{ display: "block" }}>
220
+ Favorite color
221
+ </label>
222
+
223
+ {/* Visually hidden native select (required for forms/autofill) */}
224
+ <select
225
+ id={nativeId}
226
+ name="favoriteColor"
227
+ value={value}
228
+ onChange={(e) => setValue(e.target.value)}
229
+ style={srOnlySelectStyle}
230
+ aria-hidden="true"
231
+ tabIndex={-1}
232
+ >
233
+ {OPTIONS.map((opt) => (
234
+ <option key={opt.value} value={opt.value}>
235
+ {opt.label}
236
+ </option>
237
+ ))}
238
+ </select>
239
+
240
+ <button
241
+ ref={btnRef}
242
+ type="button"
243
+ aria-haspopup="listbox"
244
+ aria-expanded={open ? "true" : "false"}
245
+ aria-controls={listboxId}
246
+ aria-labelledby={`${labelId} ${valueId}`}
247
+ aria-activedescendant={open ? `${listboxId}-opt-${activeIndex}` : undefined}
248
+ onClick={onTriggerClick}
249
+ onKeyDown={onTriggerKeyDown}
250
+ style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
251
+ >
252
+ <span id={valueId}>{selected.label}</span>
253
+ <span aria-hidden="true">▼</span>
254
+ </button>
255
+
256
+ <ul
257
+ id={listboxId}
258
+ role="listbox"
259
+ aria-labelledby={labelId}
260
+ hidden={!open}
261
+ style={{
262
+ position: "absolute",
263
+ zIndex: 10,
264
+ top: "calc(100% + 0.25rem)",
265
+ left: 0,
266
+ listStyle: "none",
267
+ margin: 0,
268
+ padding: 4,
269
+ border: "1px solid #ccc",
270
+ background: "white",
271
+ maxHeight: 160,
272
+ overflow: "auto",
273
+ }}
274
+ >
275
+ {OPTIONS.map((opt, idx) => {
276
+ const isSelected = opt.value === value;
277
+ const isActive = idx === activeIndex;
278
+
279
+ return (
280
+ <li
281
+ key={opt.value}
282
+ id={`${listboxId}-opt-${idx}`}
283
+ role="option"
284
+ aria-selected={isSelected ? "true" : "false"}
285
+ onMouseEnter={() => setActiveIndex(idx)}
286
+ onMouseDown={(e) => e.preventDefault()} // keep focus on button
287
+ onClick={() => chooseIndex(idx)}
288
+ style={{
289
+ padding: "6px 8px",
290
+ cursor: "default",
291
+ outline: isActive ? "2px solid #888" : "none",
292
+ }}
293
+ >
294
+ {opt.label}
295
+ </li>
296
+ );
297
+ })}
298
+ </ul>
299
+ </div>
300
+ );
301
+ }
302
+ ```
303
+
304
+ ## Acceptance Checks
305
+
306
+ Keyboard
307
+ - Tab focuses the trigger button; Shift+Tab moves away normally.
308
+ - Enter or Space on the trigger opens the listbox.
309
+ - When open, Arrow Up/Down moves the active option.
310
+ - Enter or Space selects the active option and closes the listbox.
311
+ - Esc closes the listbox and leaves focus on the trigger.
312
+ - Tab does not move through every option (no focus trap / no tabbing each option).
313
+
314
+ Screen Reader
315
+ - The trigger is announced with "has popup" and expanded/collapsed state (from aria-haspopup="listbox" + aria-expanded).
316
+ - The listbox is announced as a listbox, and options are announced as options.
317
+ - The selected option is conveyed via aria-selected="true".
318
+ - The active option is conveyed while navigating (e.g., via aria-activedescendant).
@@ -0,0 +1,108 @@
1
+ ---
2
+ id: select.native
3
+ title: Native Select
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.1.0
7
+ tags: [select, native-select, form, form-control, dropdown, picker]
8
+ aliases: [native select, html select, select element, dropdown, form select, single-select, option picker, select box]
9
+ summary: A native select element styled with CSS, the default single-value form control; retains native keyboard, mobile, dismissal, and assistive-technology behavior.
10
+ ---
11
+
12
+ # Native Select
13
+
14
+ Pattern ID: `select.native`
15
+
16
+ A native `<select>` element styled with CSS, the default single-value form control; retains native keyboard, mobile, dismissal, and assistive-technology behavior.
17
+
18
+ This is the first-choice control for single selection. Reach for a custom `select.basic` only when the native element cannot meet a hard requirement.
19
+
20
+ ## Use When
21
+ - Use when a form field selects one value from a preset list of plain-text options (e.g., "Sort by", "Country", "Subscription plan").
22
+ - Use when native keyboard support, mobile pickers, dismissal, form submission, and assistive-technology support are wanted without re-implementation.
23
+ - Use when single selection is needed and no requirement forces a custom widget (this is the default choice).
24
+
25
+ ## Do Not Use When
26
+ - Do not use when the option list needs rich or interactive content the native control cannot render (use `select.basic`).
27
+ - Do not use when the user must type to filter a large list (use `combobox.autocomplete`).
28
+ - Do not use when multiple selection is required (use `listbox.basic`; a checkbox group is often better).
29
+ - Do not use when the control performs actions rather than choosing a value (use `menu.basic`).
30
+
31
+ ## Must Haves
32
+ - Use a native `<select>` element with native `<option>` elements.
33
+ - Provide a visible label associated with the `<select>` via a native `<label>` using `for`/`id`.
34
+ - The `<select>` participates in the form via a `name` attribute, and uses native attributes (`required`, `disabled`) as needed.
35
+ - Keep option content as plain text.
36
+ - Style the control with CSS only (`appearance` and related properties).
37
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the `<select>` control.
38
+
39
+ ## Customizable
40
+ - The modern customizable-select CSS (`appearance: base-select` with the picker pseudo-elements and the `:open` state) may be used where browser support allows, with the classic approach of styling only the closed control as the fallback where support is absent.
41
+ - `<optgroup>` may be used to group related options under visible group labels.
42
+ - The arrow indicator styling is at the engineer's discretion as long as the control keeps its 3:1 non-text contrast and native behavior.
43
+
44
+ ## Don'ts
45
+ - Do not rebuild the select with `<div>` elements and scripting when a native `<select>` suffices.
46
+ - Do not remove the native element in favor of a custom widget for styling reasons alone.
47
+ - Do not replace native behavior with scripting.
48
+ - Do not use the `multiple` attribute (native multi-select tests very poorly).
49
+ - Do not hide the label.
50
+
51
+ ## Golden Pattern
52
+
53
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
54
+
55
+ ```jsx
56
+ "use client";
57
+
58
+ export function NativeSelectDemo() {
59
+ const [value, setValue] = useState("trending");
60
+
61
+ return (
62
+ <div>
63
+ {/* Native label associated by for/id; do not hide the label */}
64
+ <label htmlFor="sort-order-select" style={{ display: "block" }}>
65
+ Sort titles by
66
+ </label>
67
+
68
+ {/* Native select retains keyboard, mobile picker, and AT behavior.
69
+ appearance is set for styling; when overriding the native arrow,
70
+ supply a replacement affordance that keeps 3:1 non-text contrast. */}
71
+ <select
72
+ id="sort-order-select"
73
+ name="sortOrder"
74
+ value={value}
75
+ onChange={(e) => setValue(e.target.value)}
76
+ style={{ appearance: "none" }}
77
+ >
78
+ {SORT_OPTIONS.map((opt) => (
79
+ <option key={opt.value} value={opt.value}>
80
+ {opt.label}
81
+ </option>
82
+ ))}
83
+ </select>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ const SORT_OPTIONS = [
89
+ { value: "trending", label: "Trending now" },
90
+ { value: "newest", label: "Recently added" },
91
+ { value: "az", label: "Title A to Z" },
92
+ { value: "rating", label: "Highest rated" },
93
+ ];
94
+ ```
95
+
96
+ ## Acceptance Checks
97
+
98
+ Keyboard
99
+ - Tab moves focus to the `<select>`, which shows a visible focus indicator.
100
+ - Type-ahead and Arrow Up/Down move through the options natively.
101
+ - Enter, Space, and Esc behave as the native control defines (opening, selecting, and dismissing).
102
+
103
+ Screen Reader
104
+ - The control is announced with its label and current value as a native select.
105
+ - Options are announced as the user moves through the list.
106
+
107
+ Form
108
+ - Submitting the form sends the selected value under the field name `sortOrder`.