@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,97 @@
1
+ ---
2
+ id: disclosure.basic
3
+ title: Disclosure
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.1.0
7
+ tags: [disclosure, show-hide, expand-collapse, button, toggle]
8
+ aliases: [disclosure, show more, expand collapse, expandable section, collapsible panel, reveal panel, toggle content, twisty]
9
+ summary: A button that shows and hides an associated content region; uses aria-expanded on the trigger and aria-controls to the region, with DOM show/hide.
10
+ ---
11
+
12
+ # Disclosure
13
+
14
+ Pattern ID: `disclosure.basic`
15
+
16
+ A button that shows and hides an associated content region; uses `aria-expanded` on the trigger and `aria-controls` to the region, with DOM show/hide.
17
+
18
+ ## Use When
19
+ - Use when a control shows and hides a single section of related content (e.g., "Show more details", an FAQ answer, an advanced-options or filters panel).
20
+ - Use when the revealed content is arbitrary (text, form fields, media).
21
+ - Use when a single independent expandable region is toggled by one trigger.
22
+
23
+ ## Do Not Use When
24
+ - Do not use when several coordinated expandable sections form an accordion (use `accordion.basic`).
25
+ - Do not use when the revealed items are in-place commands or actions (use `menu.basic`).
26
+ - Do not use when a single trigger reveals a list of navigation links (use `navigation-menu.dropdown`).
27
+ - Do not use when the control chooses a form value (use `select.native`).
28
+
29
+ ## Must Haves
30
+ - Use a native `<button>` (preferred), or `role="button"` only when a native button cannot be used.
31
+ - 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.
32
+ - The button reflects open state with `aria-expanded="true|false"`.
33
+ - The button references the region with `aria-controls="IDREF"`.
34
+ - The region 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.
35
+ - The button has an accessible name that describes the content it controls.
36
+ - When the button has visible text, the visible text serves as the accessible name.
37
+ - For an icon-only trigger, provide an accessible name using `aria-label` or `aria-labelledby`.
38
+ - The button omits `aria-haspopup`.
39
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the trigger.
40
+
41
+ ## Customizable
42
+ - The region may take `role="region"` with an accessible name, recommended when the region contains headings; avoid `role="region"` when it would create landmark proliferation (e.g., more than roughly 6 disclosures on one page).
43
+ - Enter and Space both toggle the region by virtue of the native `<button>`; no extra key handling is required.
44
+ - A disclosure indicator (e.g., a caret) is decorative and marked `aria-hidden="true"`.
45
+
46
+ ## Don'ts
47
+ - Do not use `role="menu"` on the region.
48
+ - Do not add `aria-haspopup` to the button.
49
+ - Do not put the expanded state on the region instead of the trigger.
50
+ - Do not leave the region visible while `aria-expanded="false"` (and vice versa).
51
+
52
+ ## Golden Pattern
53
+
54
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
55
+
56
+ ```jsx
57
+ "use client";
58
+
59
+ export function Disclosure({ label, children }) {
60
+ const [open, setOpen] = useState(false);
61
+ const regionId = useId();
62
+
63
+ return (
64
+ <div>
65
+ <button
66
+ type="button"
67
+ aria-expanded={open ? "true" : "false"}
68
+ aria-controls={regionId}
69
+ onClick={() => setOpen(v => !v)}
70
+ >
71
+ {label}
72
+ {/* Decorative caret; state is conveyed by aria-expanded, not the glyph. */}
73
+ <span aria-hidden="true">{open ? " [-]" : " [+]"}</span>
74
+ </button>
75
+
76
+ {/* Region is removed from the accessibility tree and tab order when closed. */}
77
+ <div id={regionId} hidden={!open}>
78
+ {children}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+ ```
84
+
85
+ ## Acceptance Checks
86
+
87
+ Keyboard
88
+ - Tab lands on the trigger button.
89
+ - A visible focus indicator appears on the trigger when it receives keyboard focus.
90
+ - Enter toggles the region between shown and hidden.
91
+ - Space toggles the region between shown and hidden.
92
+ - When the region is hidden, focus cannot reach any content inside it.
93
+
94
+ Screen Reader
95
+ - The button announces its expanded or collapsed state via `aria-expanded`, which flips between `"true"` and `"false"` on every toggle.
96
+ - The button name describes the content it controls.
97
+ - When collapsed, the region content is not reachable in reading order.
@@ -0,0 +1,453 @@
1
+ ---
2
+ id: grid.channel-guide
3
+ title: Channel Guide Grid
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.2.0
7
+ tags: [grid, channel-guide, epg, schedule, roving-tabindex, keyboard]
8
+ aliases: [epg, electronic program guide, tv guide, live guide, schedule grid]
9
+ summary: Interactive channel guide grid with one Tab stop and arrow-key navigation across channels and time slots.
10
+ ---
11
+
12
+ # Channel Guide Grid
13
+
14
+ Pattern ID: `grid.channel-guide`
15
+
16
+ Interactive channel guide grid with one Tab stop and arrow-key navigation across channels and time slots.
17
+
18
+ ## Use When
19
+ - Use when content is arranged in a 2D matrix of channels (rows) and time slots (columns).
20
+ - Use when program items span time horizontally and are positioned according to schedule duration.
21
+ - Use when users navigate spatially across a time-based schedule using arrow keys.
22
+
23
+ ## Do Not Use When
24
+ - Do not use when presenting static tabular data with row/column headers (use `grid.data`).
25
+ - Do not use when items are displayed as a simple vertical or horizontal list without time-based positioning (use `list`).
26
+ - Do not use when supporting spreadsheet-style editing, multi-cell selection, sorting, or resizing (use `grid.interactive`).
27
+
28
+ ## Must Haves
29
+
30
+ ### Roles & structure
31
+ - Use `role="grid"` with an accessible name (e.g., `aria-label="Channel guide"`).
32
+ - Use `role="row"` for each row, including the header row.
33
+ - Use `role="columnheader"` for the time header cells (typically static).
34
+ - Use `role="rowheader"` for the channel name/logo column (may be interactive).
35
+ - Use `role="gridcell"` for program listing cells (interactive).
36
+ - Grid structural roles (`gridcell`, `rowheader`, `columnheader`) must be applied to container elements.
37
+ - Interactive controls (e.g., `<button>`, `<a>`) must be nested inside those containers.
38
+ - The grid must expose its dimensions:
39
+ - Set `aria-rowcount` to the total number of rows in the grid.
40
+ - Set `aria-colcount` to the total number of columns in the grid (including the channel column).
41
+ - Set `aria-rowindex` on each row and `aria-colindex` on each cell (1-based, counting the header row and the channel column), so position announcements remain correct when rows or columns are rendered lazily.
42
+
43
+ ### Keyboard
44
+ - Keyboard model:
45
+ - The grid must be a single Tab stop.
46
+ - Only one cell is tabbable at a time (roving `tabIndex`: active cell `0`, all others `-1`).
47
+ - Arrow keys move focus within the grid (Left/Right/Up/Down).
48
+ - Tab/Shift+Tab exits the grid to the next/previous focusable element outside.
49
+ - Activation model:
50
+ - Channel column (row header) opens "channel details".
51
+ - "Now" column activates tune (no-op if already selected).
52
+ - Future columns open "program details" (demo can use a modal).
53
+
54
+ ### Focus
55
+ - Pointer + keyboard continuity:
56
+ - Clicking a cell updates the roving "current cell" so Arrow-key navigation continues from that cell.
57
+ - Persist and restore "last focused cell":
58
+ - When focus leaves and re-enters the grid, focus lands on the last focused cell.
59
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) on each focusable element, such as grid cells and grid headers.
60
+
61
+ ## Customizable
62
+ - Support a "currently playing" channel:
63
+ - Exactly one channel row is marked as selected (separate from focus).
64
+ - Selecting/tuning changes the selected row, but focus stays with the user's navigation.
65
+
66
+ ## Don'ts
67
+ - Do not make every cell a Tab stop.
68
+ - Do not require Tab to move between cells.
69
+ - Do not mix multiple interactive controls inside a cell in this basic pattern.
70
+ - Do not conflate "selected channel" with "focused cell".
71
+ - Do not use `<button role="gridcell">` or `<button role="columnheader">`.
72
+
73
+ ## Golden Pattern
74
+
75
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
76
+
77
+ ```jsx
78
+ "use client";
79
+
80
+ export function ChannelGuideGrid({
81
+ ariaLabel = "Channel guide",
82
+ columns = DEFAULT_COLUMNS,
83
+ channels = DEMO_CHANNELS,
84
+ }) {
85
+ // Selected row = currently playing channel (visual/semantic state only)
86
+ const [selectedRow, setSelectedRow] = useState(1);
87
+
88
+ // Roving focus position (row, col)
89
+ // col 0 = channel buttons column, col 1 = Now, col 2.. = future
90
+ const [pos, setPos] = useState({ row: selectedRow, col: 1 });
91
+
92
+ // Last-focused cell for Tab out / Tab back in restoration.
93
+ const lastPosRef = useRef({ row: selectedRow, col: 1 });
94
+
95
+ // Button refs keyed by "row-col" so arrow keys can programmatically move focus.
96
+ const btnRefs = useRef({}); // { "1-2": HTMLButtonElement }
97
+
98
+ const totalCols = columns.length + 1; // +1 for channel column
99
+ const rowCount = channels.length + 1; // +1 header row
100
+
101
+ function keyOf(row, col) {
102
+ return `${row}-${col}`;
103
+ }
104
+
105
+ function setBtnRef(row, col, el) {
106
+ if (!el) return;
107
+ btnRefs.current[keyOf(row, col)] = el;
108
+ }
109
+
110
+ function focusButton(row, col) {
111
+ const el = btnRefs.current[keyOf(row, col)];
112
+ if (el && typeof el.focus === "function") el.focus();
113
+ }
114
+
115
+ function clamp(n, min, max) {
116
+ return Math.max(min, Math.min(max, n));
117
+ }
118
+
119
+ function commitPos(next) {
120
+ lastPosRef.current = next;
121
+ setPos(next);
122
+ requestAnimationFrame(() => focusButton(next.row, next.col));
123
+ }
124
+
125
+ function move(deltaRow, deltaCol) {
126
+ const nextRow = clamp(pos.row + deltaRow, 0, channels.length - 1);
127
+ const nextCol = clamp(pos.col + deltaCol, 0, columns.length); // 0..columns.length
128
+ commitPos({ row: nextRow, col: nextCol });
129
+ }
130
+
131
+ // When Tab enters the grid, restore focus to the last-focused cell's button.
132
+ function onGridFocusCapture(e) {
133
+ const from = e.relatedTarget;
134
+ if (from && e.currentTarget.contains(from)) return;
135
+
136
+ const last = lastPosRef.current;
137
+ setPos(last);
138
+ requestAnimationFrame(() => focusButton(last.row, last.col));
139
+ }
140
+
141
+ // Keep roving position in sync with real focus.
142
+ function onButtonFocus(row, col) {
143
+ const next = { row, col };
144
+ lastPosRef.current = next;
145
+ setPos(next);
146
+ }
147
+
148
+ // Mouse: update roving state BEFORE click/focus so arrow navigation works immediately after click.
149
+ function onButtonPointerDown(row, col) {
150
+ const next = { row, col };
151
+ lastPosRef.current = next;
152
+ setPos(next);
153
+ }
154
+
155
+ function onButtonKeyDown(e) {
156
+ switch (e.key) {
157
+ case "ArrowLeft":
158
+ e.preventDefault();
159
+ move(0, -1);
160
+ break;
161
+ case "ArrowRight":
162
+ e.preventDefault();
163
+ move(0, 1);
164
+ break;
165
+ case "ArrowUp":
166
+ e.preventDefault();
167
+ move(-1, 0);
168
+ break;
169
+ case "ArrowDown":
170
+ e.preventDefault();
171
+ move(1, 0);
172
+ break;
173
+ case "Home":
174
+ e.preventDefault();
175
+ commitPos({ row: pos.row, col: 0 });
176
+ break;
177
+ case "End":
178
+ e.preventDefault();
179
+ commitPos({ row: pos.row, col: columns.length });
180
+ break;
181
+ case "Enter":
182
+ case " ":
183
+ e.preventDefault();
184
+ // ACTION NOTE:
185
+ // - If col === 1 ("Now"): tune the live player to this channel and update selectedRow.
186
+ // - If col === 0 or col > 1: open details (e.g., modal) for channel/program.
187
+ // For the MCP golden pattern we leave this as a stub.
188
+ if (pos.col === 1 && pos.row !== selectedRow) setSelectedRow(pos.row);
189
+ break;
190
+ default:
191
+ break;
192
+ }
193
+ }
194
+
195
+ return (
196
+ <div
197
+ role="grid"
198
+ aria-label={ariaLabel}
199
+ aria-rowcount={rowCount}
200
+ aria-colcount={totalCols}
201
+ onFocusCapture={onGridFocusCapture}
202
+ style={{
203
+ border: "1px solid rgba(0,0,0,0.2)",
204
+ borderRadius: 12,
205
+ overflow: "hidden",
206
+ background: "#fff",
207
+ maxWidth: 1100,
208
+ }}
209
+ >
210
+ {/* Header row (static, not focusable). Row/col indices are 1-based and
211
+ include this header row, so positions stay correct if rows or
212
+ columns are lazy-loaded. */}
213
+ <div role="row" aria-rowindex={1} style={rowStyle(true)}>
214
+ <div role="columnheader" aria-colindex={1} style={headerCellStyle}>
215
+ Channel
216
+ </div>
217
+ {columns.map((c, colIdx) => (
218
+ <div
219
+ key={c.key}
220
+ role="columnheader"
221
+ aria-colindex={colIdx + 2}
222
+ style={headerCellStyle}
223
+ >
224
+ {c.label}
225
+ </div>
226
+ ))}
227
+ </div>
228
+
229
+ {/* Data rows */}
230
+ {channels.map((ch, rowIndex) => {
231
+ const isSelected = rowIndex === selectedRow;
232
+
233
+ return (
234
+ <div
235
+ key={ch.id}
236
+ role="row"
237
+ aria-rowindex={rowIndex + 2}
238
+ aria-selected={isSelected ? "true" : undefined}
239
+ style={rowStyle(false, isSelected)}
240
+ >
241
+ {/* Rowheader cell */}
242
+ <div
243
+ role="rowheader"
244
+ aria-colindex={1}
245
+ style={cellContainerStyle(isSelected)}
246
+ >
247
+ <button
248
+ type="button"
249
+ tabIndex={pos.row === rowIndex && pos.col === 0 ? 0 : -1}
250
+ ref={(el) => setBtnRef(rowIndex, 0, el)}
251
+ onPointerDown={() => onButtonPointerDown(rowIndex, 0)}
252
+ onFocus={() => onButtonFocus(rowIndex, 0)}
253
+ onKeyDown={onButtonKeyDown}
254
+ onClick={() => {
255
+ // ACTION NOTE: open channel details
256
+ }}
257
+ aria-label={`${ch.name}${isSelected ? ", currently playing" : ""}`}
258
+ style={cellButtonStyle}
259
+ >
260
+ <span style={{ fontWeight: 650 }}>{ch.name}</span>
261
+ </button>
262
+ </div>
263
+
264
+ {/* Program cells */}
265
+ {ch.programs.map((p, programIndex) => {
266
+ const colIndex = programIndex + 1; // 1..columns.length
267
+ const isNow = colIndex === 1;
268
+
269
+ // NOTE: If VoiceOver double-announces, prefer aria-labelledby + aria-describedby
270
+ // OR set inner text aria-hidden and rely on aria-label.
271
+ const label = isNow
272
+ ? `Now: ${p.title}. ${p.meta}. ${p.timeText}`
273
+ : `${columns[colIndex - 1].label}: ${p.title}. ${p.meta}. ${p.timeText}`;
274
+
275
+ return (
276
+ <div
277
+ key={`${ch.id}-${p.id}`}
278
+ role="gridcell"
279
+ aria-colindex={colIndex + 1}
280
+ style={cellContainerStyle(isSelected, isNow)}
281
+ >
282
+ <button
283
+ type="button"
284
+ tabIndex={pos.row === rowIndex && pos.col === colIndex ? 0 : -1}
285
+ ref={(el) => setBtnRef(rowIndex, colIndex, el)}
286
+ onPointerDown={() => onButtonPointerDown(rowIndex, colIndex)}
287
+ onFocus={() => onButtonFocus(rowIndex, colIndex)}
288
+ onKeyDown={onButtonKeyDown}
289
+ onClick={() => {
290
+ // ACTION NOTE:
291
+ // - If colIndex === 1 ("Now"): tune to channel (and update selectedRow)
292
+ // - Else: open program details
293
+ if (colIndex === 1 && rowIndex !== selectedRow) setSelectedRow(rowIndex);
294
+ }}
295
+ aria-label={label}
296
+ style={cellButtonStyle}
297
+ >
298
+ <div style={{ fontWeight: 650, lineHeight: 1.2 }}>{p.title}</div>
299
+ <div style={{ opacity: 0.8, fontSize: 13 }}>{p.meta}</div>
300
+ <div style={{ opacity: 0.8, fontSize: 13 }}>{p.timeText}</div>
301
+ </button>
302
+ </div>
303
+ );
304
+ })}
305
+ </div>
306
+ );
307
+ })}
308
+
309
+ {/* DOCUMENTATION NOTES (intentionally not implemented in MCP code):
310
+ - A live player preview region should announce currently playing content changes (aria-live="polite").
311
+ - Channel tuning should be a no-op when the selectedRow channel is already playing.
312
+ - "Details" actions (channel column + future programs) should open a modal/dialog (use your dialog.modal pattern).
313
+ - On modal close: restore focus to the invoking grid button (store opener ref).
314
+ - Consider additional keys: PageUp/PageDown (jump time columns), Ctrl+Home/End (grid edges), etc.
315
+ - For SR verbosity: aria-label may be double-announced in some AT/browser combos; prefer aria-labelledby/aria-describedby.
316
+ */}
317
+ </div>
318
+ );
319
+ }
320
+
321
+ /* Styles */
322
+ function rowStyle(isHeader, isSelectedRow) {
323
+ return {
324
+ display: "grid",
325
+ gridTemplateColumns: "220px repeat(5, minmax(0, 1fr))",
326
+ background: isHeader ? "rgba(0,0,0,0.06)" : "#fff",
327
+ borderTop: isHeader ? "none" : "1px solid rgba(0,0,0,0.1)",
328
+ outline: isSelectedRow ? "2px solid rgba(0,0,0,0.35)" : "none",
329
+ outlineOffset: isSelectedRow ? -2 : 0,
330
+ };
331
+ }
332
+
333
+ const headerCellStyle = {
334
+ padding: 10,
335
+ fontWeight: 700,
336
+ fontSize: 13,
337
+ borderRight: "1px solid rgba(0,0,0,0.1)",
338
+ };
339
+
340
+ function cellContainerStyle(isSelectedRow, isNow) {
341
+ return {
342
+ borderRight: "1px solid rgba(0,0,0,0.1)",
343
+ background: isNow
344
+ ? "rgba(0,0,0,0.06)"
345
+ : isSelectedRow
346
+ ? "rgba(0,0,0,0.03)"
347
+ : "transparent",
348
+ };
349
+ }
350
+
351
+ const cellButtonStyle = {
352
+ width: "100%",
353
+ height: "100%",
354
+ boxSizing: "border-box",
355
+ display: "grid",
356
+ gap: 2,
357
+ padding: 10,
358
+ textAlign: "left",
359
+ border: "none",
360
+ background: "transparent",
361
+ cursor: "pointer",
362
+ borderRadius: 0,
363
+ outlineOffset: 2,
364
+ };
365
+
366
+ /* Demo data */
367
+ const DEFAULT_COLUMNS = [
368
+ { key: "now", label: "Now" },
369
+ { key: "t1", label: "4:00 PM" },
370
+ { key: "t2", label: "4:30 PM" },
371
+ { key: "t3", label: "5:00 PM" },
372
+ { key: "t4", label: "5:30 PM" },
373
+ ];
374
+
375
+ const DEMO_CHANNELS = [
376
+ {
377
+ id: "c1",
378
+ name: "News 24",
379
+ programs: [
380
+ { id: "p11", title: "Live Headlines", meta: "TV-PG · News", timeText: "22m remaining" },
381
+ { id: "p12", title: "World Report", meta: "TV-PG · News", timeText: "4:00–4:30 PM" },
382
+ { id: "p13", title: "City Desk", meta: "TV-PG · News", timeText: "4:30–5:00 PM" },
383
+ { id: "p14", title: "Markets", meta: "TV-G · Business", timeText: "5:00–5:30 PM" },
384
+ { id: "p15", title: "Evening Brief", meta: "TV-PG · News", timeText: "5:30–6:00 PM" },
385
+ ],
386
+ },
387
+ {
388
+ id: "c2",
389
+ name: "Action Max",
390
+ programs: [
391
+ { id: "p21", title: "Steel Harbor", meta: "PG-13 · Action", timeText: "48m remaining" },
392
+ { id: "p22", title: "Night Pursuit", meta: "R · Action", timeText: "4:00–4:30 PM" },
393
+ { id: "p23", title: "Rapid Response", meta: "TV-14 · Series", timeText: "4:30–5:00 PM" },
394
+ { id: "p24", title: "Streetline", meta: "TV-14 · Series", timeText: "5:00–5:30 PM" },
395
+ { id: "p25", title: "Afterburn", meta: "TV-14 · Series", timeText: "5:30–6:00 PM" },
396
+ ],
397
+ },
398
+ {
399
+ id: "c3",
400
+ name: "Comedy Loop",
401
+ programs: [
402
+ { id: "p31", title: "Lunch Break Laughs", meta: "TV-PG · Comedy", timeText: "10m remaining" },
403
+ { id: "p32", title: "Stand-Up Hour", meta: "TV-MA · Comedy", timeText: "4:00–4:30 PM" },
404
+ { id: "p33", title: "Sitcom Shuffle", meta: "TV-PG · Comedy", timeText: "4:30–5:00 PM" },
405
+ { id: "p34", title: "Sketch Night", meta: "TV-14 · Comedy", timeText: "5:00–5:30 PM" },
406
+ { id: "p35", title: "Late Laughs", meta: "TV-14 · Comedy", timeText: "5:30–6:00 PM" },
407
+ ],
408
+ },
409
+ {
410
+ id: "c4",
411
+ name: "Nature HD",
412
+ programs: [
413
+ { id: "p41", title: "Wild Rivers", meta: "TV-G · Documentary", timeText: "35m remaining" },
414
+ { id: "p42", title: "Deep Forest", meta: "TV-G · Documentary", timeText: "4:00–4:30 PM" },
415
+ { id: "p43", title: "Ocean Life", meta: "TV-G · Documentary", timeText: "4:30–5:00 PM" },
416
+ { id: "p44", title: "Sky Trails", meta: "TV-G · Documentary", timeText: "5:00–5:30 PM" },
417
+ { id: "p45", title: "Night Creatures", meta: "TV-PG · Documentary", timeText: "5:30–6:00 PM" },
418
+ ],
419
+ },
420
+ {
421
+ id: "c5",
422
+ name: "Kids Zone",
423
+ programs: [
424
+ { id: "p51", title: "Puzzle Pals", meta: "TV-Y · Kids", timeText: "7m remaining" },
425
+ { id: "p52", title: "Craft Corner", meta: "TV-Y · Kids", timeText: "4:00–4:30 PM" },
426
+ { id: "p53", title: "Story Time", meta: "TV-Y · Kids", timeText: "4:30–5:00 PM" },
427
+ { id: "p54", title: "Space Sprouts", meta: "TV-Y7 · Kids", timeText: "5:00–5:30 PM" },
428
+ { id: "p55", title: "Animal Amigos", meta: "TV-Y · Kids", timeText: "5:30–6:00 PM" },
429
+ ],
430
+ },
431
+ ];
432
+ ```
433
+
434
+ ## Acceptance Checks
435
+ - Entry/exit:
436
+ - Tab enters the grid to the last-focused cell.
437
+ - Tab/Shift+Tab exits the grid to the next/previous focusable element outside.
438
+ - Keyboard navigation:
439
+ - Arrow keys move focus between cells (Left/Right/Up/Down).
440
+ - Home moves to the channel column for the current row.
441
+ - End moves to the last time column for the current row.
442
+ - Only the active cell is tabbable (`tabIndex=0`); all others are not (`tabIndex=-1`).
443
+ - Semantics:
444
+ - Grid container uses `role="grid"` and has an accessible name.
445
+ - Time headers use `role="columnheader"` and are not focusable.
446
+ - Channel cells are row headers and are interactive.
447
+ - Program cells use `role="gridcell"` and are interactive.
448
+ - Rows expose `aria-rowindex` and cells expose `aria-colindex`, consistent with `aria-rowcount`/`aria-colcount`.
449
+ - State:
450
+ - Exactly one channel row is marked as selected (currently playing), distinct from focus.
451
+ - Selecting/tuning updates the selected row without forcibly moving focus.
452
+ - Pointer + keyboard continuity:
453
+ - Clicking a cell updates the roving "current cell" so arrow-key navigation continues from that cell.
@@ -0,0 +1,105 @@
1
+ ---
2
+ id: link.basic
3
+ title: Link
4
+ stack: web/react
5
+ status: beta
6
+ latest_version: 0.2.0
7
+ tags: [link, anchor, navigation, external-link]
8
+ aliases: [anchor, hyperlink, external link]
9
+ summary: Native link for navigation using <a href>. Supports optional context in the accessible name, including "opens in new tab/window/dialog".
10
+ ---
11
+
12
+ # Link
13
+
14
+ Pattern ID: `link.basic`
15
+
16
+ Native link for navigation using `<a href>`. Supports optional context in the accessible name, including "opens in new tab/window/dialog".
17
+
18
+ ## Use When
19
+ - Use when activating the element navigates to a different URL, route, or in-page anchor.
20
+ - Use when the primary purpose of the element is destination-based navigation rather than performing an action.
21
+
22
+ ## Do Not Use When
23
+ - Do not use when activating the element performs an in-place action such as submitting, saving, deleting, toggling, or opening a dialog (use `button`).
24
+ - Do not use when the element changes UI state without navigation (use `button`).
25
+
26
+ ## Must Haves
27
+ - Use a native `<a>` element with an `href` whenever possible.
28
+ - Ensure the link's purpose/destination is understandable from the link text alone, or from the link text plus programmatically determined context (e.g., `aria-label`, `aria-labelledby`, offscreen text).
29
+ - The link has an accessible name that describes its purpose or destination.
30
+ - When the link has visible text, the visible text serves as the accessible name.
31
+ - When additional context is needed beyond the visible text, add it via `aria-label`, `aria-labelledby`, or offscreen text. The visible text appears at the start of the accessible name.
32
+ - For icon-only links, provide an accessible name using `aria-label` or `aria-labelledby`.
33
+ - Icons within links must be decorative (`aria-hidden="true"`).
34
+ - Keyboard activation must follow native link behavior: Enter activates; Space does not.
35
+ - Ensure the link is focusable:
36
+ - Preferred: provide `href` (native focus + native behaviors).
37
+ - If using `role="link"` on a non-`<a>` element, you must also provide keyboard support and focus management (e.g., `tabIndex="0"` and Enter key activation).
38
+ - If a link opens a new tab/window, include both:
39
+ - programmatic context in the accessible name (e.g., "opens in new tab"), and
40
+ - a visual affordance: append an external-link icon at the end of the visible label.
41
+ - Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the link.
42
+
43
+ ## Customizable
44
+ - No accessibility-relevant variations beyond the Must Haves above.
45
+
46
+ ## Don'ts
47
+ - Do not style a link to look like plain text when it appears inline within a paragraph; inline links must be visually obvious (e.g., underlined).
48
+ - Do not rely on color alone to indicate a link.
49
+ - Do not use `<a>` without `href` for interactive behavior; it loses native link semantics and behaviors.
50
+ - Do not use `role="link"` on non-link elements unless you cannot use a native `<a href>`. Native links provide browser behaviors ARIA cannot add automatically.
51
+ - Do not use `role="link"` unless you also implement the missing link behaviors (focus, Enter activation, navigation, and expected link affordances).
52
+ - Do not permit Space to activate links.
53
+
54
+ ## Golden Pattern
55
+
56
+ Structural reference for AI coding assistants — semantics, focus, and keyboard behavior. Styling, copy, and demo data are illustrative.
57
+
58
+ ```jsx
59
+ export function LinkDemo() {
60
+ return (
61
+ <div>
62
+ {/* Simple link: accessible name from visible text */}
63
+ <a href="/account">Sign in</a>
64
+
65
+ <br />
66
+
67
+ {/* Add context when needed (e.g., repeated links) */}
68
+ <h3 id="prod-1">Superflo Water Bottle</h3>
69
+ <a href="/products/1" aria-labelledby="prod-1-link prod-1">
70
+ <span id="prod-1-link">Read more</span>
71
+ </a>
72
+
73
+ <br />
74
+
75
+ {/* Opens in new tab: add accessible context + visible external icon */}
76
+ <a
77
+ href="https://example.com/report.pdf"
78
+ target="_blank"
79
+ rel="noopener noreferrer"
80
+ aria-label="Download report, opens in a new tab"
81
+ >
82
+ Download report <span aria-hidden="true">[external-link-icon]</span>
83
+ </a>
84
+
85
+ <br />
86
+
87
+ {/* Icon-only link: must provide an accessible name */}
88
+ <a href="/settings" aria-label="Settings">
89
+ <span aria-hidden="true">[icon]</span>
90
+ </a>
91
+ </div>
92
+ );
93
+ }
94
+ ```
95
+
96
+ ## Acceptance Checks
97
+ - Tab to each link: link receives focus and has a visible focus indicator.
98
+ - Press Enter on a focused link: navigation is triggered.
99
+ - Press Space on a focused link: does not activate the link.
100
+ - Inline link in body text is visually identifiable as a link (e.g., underlined).
101
+ - Screen reader announces an understandable name for each link:
102
+ - Simple link: reads the visible text.
103
+ - Contextual link: includes the additional context (e.g., "Superflo Water Bottle Read more").
104
+ - New tab/window link: includes "opens in a new tab/window" in the accessible name, and the external-link icon is not announced.
105
+ - Icon-only link: announces the `aria-label`.