@astryxdesign/core 0.1.2-canary.bfcbf64 → 0.1.2-canary.c395fca

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 (132) hide show
  1. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +1 -1
  2. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  3. package/dist/Breadcrumbs/BreadcrumbItem.js +4 -1
  4. package/dist/Button/Button.d.ts.map +1 -1
  5. package/dist/Button/Button.js +2 -4
  6. package/dist/Calendar/Calendar.d.ts.map +1 -1
  7. package/dist/Calendar/Calendar.js +33 -14
  8. package/dist/Calendar/hooks/index.d.ts +0 -2
  9. package/dist/Calendar/hooks/index.d.ts.map +1 -1
  10. package/dist/Calendar/hooks/index.js +1 -2
  11. package/dist/Calendar/index.d.ts +2 -2
  12. package/dist/Calendar/index.d.ts.map +1 -1
  13. package/dist/Calendar/index.js +1 -1
  14. package/dist/CheckboxList/CheckboxList.js +3 -3
  15. package/dist/Citation/Citation.d.ts.map +1 -1
  16. package/dist/Citation/Citation.js +3 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts +1 -7
  18. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  19. package/dist/ContextMenu/ContextMenu.js +7 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -8
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +4 -9
  23. package/dist/Field/Field.d.ts +4 -4
  24. package/dist/Field/Field.d.ts.map +1 -1
  25. package/dist/Field/Field.js +2 -2
  26. package/dist/Field/FieldLabel.d.ts +4 -4
  27. package/dist/Field/FieldLabel.d.ts.map +1 -1
  28. package/dist/Field/FieldLabel.js +2 -2
  29. package/dist/InputGroup/InputGroup.js +3 -3
  30. package/dist/Link/Link.d.ts.map +1 -1
  31. package/dist/Link/Link.js +2 -4
  32. package/dist/MoreMenu/MoreMenu.d.ts +1 -7
  33. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  34. package/dist/MoreMenu/MoreMenu.js +0 -2
  35. package/dist/MultiSelector/MultiSelector.d.ts +25 -2
  36. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  37. package/dist/MultiSelector/MultiSelector.js +31 -6
  38. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  39. package/dist/ProgressBar/ProgressBar.js +2 -4
  40. package/dist/RadioList/RadioList.js +3 -3
  41. package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
  42. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControl.js +51 -61
  44. package/dist/Selector/Selector.d.ts +22 -1
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +31 -6
  47. package/dist/Switch/Switch.d.ts.map +1 -1
  48. package/dist/Switch/Switch.js +2 -4
  49. package/dist/TabList/TabList.d.ts +3 -2
  50. package/dist/TabList/TabList.d.ts.map +1 -1
  51. package/dist/TabList/TabList.js +55 -33
  52. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  53. package/dist/Table/tableContextMenu.js +0 -3
  54. package/dist/TextArea/TextArea.d.ts.map +1 -1
  55. package/dist/TextArea/TextArea.js +2 -4
  56. package/dist/Toolbar/Toolbar.d.ts +3 -3
  57. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  58. package/dist/Toolbar/Toolbar.js +46 -6
  59. package/dist/TreeList/TreeList.d.ts.map +1 -1
  60. package/dist/TreeList/TreeList.js +16 -22
  61. package/dist/TreeList/TreeListItem.d.ts +3 -2
  62. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  63. package/dist/astryx.css +3 -0
  64. package/dist/astryx.umd.js +47 -47
  65. package/dist/astryx.umd.js.map +4 -4
  66. package/dist/hooks/index.d.ts +4 -2
  67. package/dist/hooks/index.d.ts.map +1 -1
  68. package/dist/hooks/index.js +1 -0
  69. package/dist/hooks/useGridFocus.d.ts +56 -0
  70. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  71. package/dist/hooks/useGridFocus.js +138 -24
  72. package/dist/hooks/useKeyboardHint.d.ts +82 -0
  73. package/dist/hooks/useKeyboardHint.d.ts.map +1 -0
  74. package/dist/hooks/useKeyboardHint.js +220 -0
  75. package/dist/hooks/useTreeFocus.d.ts +27 -3
  76. package/dist/hooks/useTreeFocus.d.ts.map +1 -1
  77. package/dist/hooks/useTreeFocus.js +69 -6
  78. package/package.json +1 -1
  79. package/src/Breadcrumbs/BreadcrumbItem.tsx +5 -2
  80. package/src/Button/Button.tsx +3 -16
  81. package/src/Calendar/Calendar.tsx +52 -22
  82. package/src/Calendar/hooks/index.ts +0 -6
  83. package/src/Calendar/index.ts +0 -3
  84. package/src/CheckboxList/CheckboxList.tsx +3 -3
  85. package/src/Citation/Citation.doc.mjs +10 -0
  86. package/src/Citation/Citation.test.tsx +117 -0
  87. package/src/Citation/Citation.tsx +9 -1
  88. package/src/ContextMenu/ContextMenu.doc.mjs +0 -6
  89. package/src/ContextMenu/ContextMenu.test.tsx +4 -6
  90. package/src/ContextMenu/ContextMenu.tsx +7 -19
  91. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -8
  92. package/src/DropdownMenu/DropdownMenu.test.tsx +0 -23
  93. package/src/DropdownMenu/DropdownMenu.tsx +4 -16
  94. package/src/Field/Field.test.tsx +2 -2
  95. package/src/Field/Field.tsx +5 -5
  96. package/src/Field/FieldLabel.tsx +5 -5
  97. package/src/InputGroup/InputGroup.tsx +3 -3
  98. package/src/Link/Link.tsx +2 -13
  99. package/src/MoreMenu/MoreMenu.doc.mjs +2 -17
  100. package/src/MoreMenu/MoreMenu.tsx +0 -9
  101. package/src/MultiSelector/MultiSelector.doc.mjs +25 -0
  102. package/src/MultiSelector/MultiSelector.test.tsx +150 -1
  103. package/src/MultiSelector/MultiSelector.tsx +55 -3
  104. package/src/ProgressBar/ProgressBar.tsx +2 -3
  105. package/src/RadioList/RadioList.tsx +3 -3
  106. package/src/SegmentedControl/SegmentedControl.tsx +51 -77
  107. package/src/Selector/Selector.doc.mjs +21 -0
  108. package/src/Selector/Selector.test.tsx +123 -1
  109. package/src/Selector/Selector.tsx +53 -3
  110. package/src/Switch/Switch.tsx +2 -16
  111. package/src/TabList/TabList.test.tsx +41 -0
  112. package/src/TabList/TabList.tsx +66 -39
  113. package/src/Table/tableContextMenu.tsx +1 -5
  114. package/src/TextArea/TextArea.tsx +3 -13
  115. package/src/Toolbar/Toolbar.test.tsx +64 -6
  116. package/src/Toolbar/Toolbar.tsx +55 -4
  117. package/src/TreeList/TreeList.tsx +17 -28
  118. package/src/TreeList/TreeListItem.tsx +3 -2
  119. package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
  120. package/src/hooks/index.ts +12 -5
  121. package/src/hooks/useGridFocus.doc.mjs +40 -2
  122. package/src/hooks/useGridFocus.test.tsx +132 -0
  123. package/src/hooks/useGridFocus.ts +179 -23
  124. package/src/hooks/useKeyboardHint.doc.mjs +101 -0
  125. package/src/hooks/useKeyboardHint.test.tsx +70 -0
  126. package/src/hooks/useKeyboardHint.tsx +332 -0
  127. package/src/hooks/useTreeFocus.doc.mjs +15 -1
  128. package/src/hooks/useTreeFocus.ts +101 -6
  129. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +0 -57
  130. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +0 -1
  131. package/dist/Calendar/hooks/useCalendarRovingTabindex.js +0 -96
  132. package/src/Calendar/hooks/useCalendarRovingTabindex.ts +0 -118
@@ -99,12 +99,13 @@ function collectExpandedKeys(items: TreeListItemData[]): string[] {
99
99
  }
100
100
 
101
101
  /**
102
- * Compute the initial roving-tabindex owner: the first selected enabled item
103
- * in document order, else the first enabled item, else the first item.
104
- * Only the top-level candidates are considered for the default so the tab stop
105
- * is reachable even when the tree is fully collapsed.
102
+ * Compute the initial roving-tabindex seed: the first selected enabled item in
103
+ * document order, else the first enabled item, else the first item. The hook
104
+ * (useTreeFocus with hasRovingTabIndex) takes ownership after mount — it
105
+ * preserves this seeded `tabindex="0"` on its repair pass and moves the stop
106
+ * with keyboard navigation.
106
107
  */
107
- function findDefaultActiveId(items: TreeListItemData[]): string | undefined {
108
+ function findInitialTabbableId(items: TreeListItemData[]): string | undefined {
108
109
  let firstEnabled: string | undefined;
109
110
  const walk = (list: TreeListItemData[]): string | undefined => {
110
111
  for (const item of list) {
@@ -193,28 +194,15 @@ export function TreeList({
193
194
  // Roving tabindex + APG tree keyboard model (via useTreeFocus)
194
195
  // ---------------------------------------------------------------------------
195
196
 
196
- // The treeitem that currently owns the tree's single tab stop. The hook moves
197
- // it around via onActiveChange; a data-driven default seeds it (selected item
198
- // or first enabled).
199
- const [activeId, setActiveId] = useState<string | undefined>(() =>
200
- findDefaultActiveId(items),
197
+ // The hook (hasRovingTabIndex) owns the tree's single tab stop: it repairs
198
+ // the stop on mount and moves it with keyboard navigation. We only seed the
199
+ // initially-tabbable treeitem in the render (selected item or first enabled);
200
+ // the hook's repair pass preserves that seeded `tabindex="0"`.
201
+ const initialTabbableId = useMemo(
202
+ () => findInitialTabbableId(items),
203
+ [items],
201
204
  );
202
205
 
203
- // If the seed id is no longer present (items changed), fall back to a valid
204
- // default so the tree always has exactly one reachable tab stop.
205
- const resolvedActiveId = useMemo(() => {
206
- if (activeId == null) {
207
- return findDefaultActiveId(items);
208
- }
209
- const exists = (list: TreeListItemData[]): boolean =>
210
- list.some(
211
- item =>
212
- item.id === activeId ||
213
- (item.children != null && exists(item.children)),
214
- );
215
- return exists(items) ? activeId : findDefaultActiveId(items);
216
- }, [activeId, items]);
217
-
218
206
  // Enter/Space activation: prefer the treeitem's own inner action (link or
219
207
  // button); return true when handled so the hook does not also toggle. Scoped
220
208
  // to this treeitem's own row — never a descendant treeitem's action inside an
@@ -232,10 +220,10 @@ export function TreeList({
232
220
  return false;
233
221
  }, []);
234
222
 
235
- const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
223
+ const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
236
224
  onToggleExpand: handleToggle,
237
225
  onActivate: activateItem,
238
- onActiveChange: setActiveId,
226
+ hasRovingTabIndex: true,
239
227
  });
240
228
 
241
229
  function renderItems(
@@ -286,7 +274,7 @@ export function TreeList({
286
274
  renderedChildren={renderedChildren}
287
275
  posInSet={index + 1}
288
276
  setSize={items.length}
289
- isTabbable={item.id === resolvedActiveId}
277
+ isTabbable={item.id === initialTabbableId}
290
278
  />
291
279
  );
292
280
  });
@@ -312,6 +300,7 @@ export function TreeList({
312
300
  role="tree"
313
301
  aria-labelledby={header != null ? headerId : undefined}
314
302
  onKeyDown={handleKeyDown}
303
+ onFocus={handleFocus}
315
304
  {...stylex.props(styles.list)}>
316
305
  {renderItems(items, 0, [])}
317
306
  </ul>
@@ -265,8 +265,9 @@ export interface TreeListItemInternalProps {
265
265
  /** Number of siblings at this level (aria-setsize). */
266
266
  setSize: number;
267
267
  /**
268
- * Whether this treeitem currently owns the tree's single tab stop
269
- * (roving tabindex). Exactly one visible treeitem is tabbable at a time.
268
+ * Whether this treeitem is the initial roving-tabindex seed. Exactly one
269
+ * treeitem is seeded tabbable at mount; useTreeFocus (hasRovingTabIndex)
270
+ * then owns the tab stop dynamically.
270
271
  */
271
272
  isTabbable: boolean;
272
273
  }
@@ -23,12 +23,12 @@ export const docs = {
23
23
  },
24
24
  ],
25
25
  usage: {
26
- description: 'Renders content in the accessibility tree while hiding it visually. Use for accessible names on icon-only controls, aria-live announcement regions, and supplementary screen-reader context. Deliberately has no styling props — the whole point is to stay invisible.',
26
+ description: 'Renders content in the accessibility tree while hiding it visually. Use for accessible names on icon-only controls, aria-live announcement regions, and supplementary screen-reader context. Deliberately has no styling props; the whole point is to stay invisible.',
27
27
  bestPractices: [
28
28
  { guidance: true, description: 'Use to give icon-only buttons and controls an accessible name that screen readers announce.' },
29
29
  { guidance: true, description: 'Render as a block element (as="div") with aria-live to announce dynamic updates like drag-and-drop or result counts.' },
30
- { guidance: false, description: 'Use it to hide content from everyone — it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
31
- { guidance: false, description: 'Put interactive controls inside it — the content is not visible and cannot receive pointer input.' },
30
+ { guidance: false, description: 'Use it to hide content from everyone; it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
31
+ { guidance: false, description: 'Put interactive controls inside it; the content is not visible and cannot receive pointer input.' },
32
32
  ],
33
33
  },
34
34
  };
@@ -55,8 +55,8 @@ export const docsZh = {
55
55
  bestPractices: [
56
56
  { guidance: true, description: 'Use to give icon-only buttons and controls an accessible name that screen readers announce.' },
57
57
  { guidance: true, description: 'Render as a block element (as="div") with aria-live to announce dynamic updates like drag-and-drop or result counts.' },
58
- { guidance: false, description: 'Use it to hide content from everyone — it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
59
- { guidance: false, description: 'Put interactive controls inside it — the content is not visible and cannot receive pointer input.' },
58
+ { guidance: false, description: 'Use it to hide content from everyone; it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
59
+ { guidance: false, description: 'Put interactive controls inside it; the content is not visible and cannot receive pointer input.' },
60
60
  ],
61
61
  },
62
62
  };
@@ -69,8 +69,8 @@ export const docsDense = {
69
69
  bestPractices: [
70
70
  { guidance: true, description: 'Use to give icon-only buttons and controls an accessible name that screen readers announce.' },
71
71
  { guidance: true, description: 'Render as a block element (as="div") with aria-live to announce dynamic updates like drag-and-drop or result counts.' },
72
- { guidance: false, description: 'Use it to hide content from everyone — it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
73
- { guidance: false, description: 'Put interactive controls inside it — the content is not visible and cannot receive pointer input.' },
72
+ { guidance: false, description: 'Use it to hide content from everyone; it stays in the accessibility tree; use conditional rendering or `hidden` to remove content entirely.' },
73
+ { guidance: false, description: 'Put interactive controls inside it; the content is not visible and cannot receive pointer input.' },
74
74
  ],
75
75
  },
76
76
  propDescriptions: {
@@ -31,6 +31,13 @@ export {useTreeFocus} from './useTreeFocus';
31
31
  export type {UseTreeFocusOptions, UseTreeFocusReturn} from './useTreeFocus';
32
32
 
33
33
  export {useTypeahead} from './useTypeahead';
34
+
35
+ export {useKeyboardHint} from './useKeyboardHint';
36
+ export type {
37
+ UseKeyboardHintOptions,
38
+ UseKeyboardHintReturn,
39
+ KeyboardHintOrientation,
40
+ } from './useKeyboardHint';
34
41
  export type {UseTypeaheadOptions, UseTypeaheadReturn} from './useTypeahead';
35
42
 
36
43
  export {useMediaQuery} from './useMediaQuery';
@@ -54,7 +61,10 @@ export type {
54
61
  export {useImageMode} from './useImageMode';
55
62
  export type {ImageSampleRegion, UseImageModeOptions} from './useImageMode';
56
63
 
57
- export {useClickableContainer, INTERACTIVE_SELECTORS} from './useClickableContainer';
64
+ export {
65
+ useClickableContainer,
66
+ INTERACTIVE_SELECTORS,
67
+ } from './useClickableContainer';
58
68
  export type {
59
69
  UseClickableContainerOptions,
60
70
  ClickableContainerResult,
@@ -70,7 +80,4 @@ export type {
70
80
  } from './useInteractiveRole';
71
81
 
72
82
  export {useLongPress} from './useLongPress';
73
- export type {
74
- UseLongPressOptions,
75
- UseLongPressHandlers,
76
- } from './useLongPress';
83
+ export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress';
@@ -21,10 +21,22 @@ export const docs = {
21
21
  {
22
22
  name: 'options.cellSelector',
23
23
  type: 'string',
24
- description: 'Selector for focusable cells within the grid.',
24
+ description: 'Selector for cells within the grid. Should match ALL cell positions in DOM order (including disabled/empty) so grid geometry is preserved.',
25
25
  default: "'button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'",
26
26
  required: false,
27
27
  },
28
+ {
29
+ name: 'options.isCellFocusable',
30
+ type: '(cell: HTMLElement) => boolean',
31
+ description: 'Predicate for whether a matched cell can receive focus. Omit to treat every matched cell as focusable.',
32
+ required: false,
33
+ },
34
+ {
35
+ name: 'options.getFocusTarget',
36
+ type: '(cell: HTMLElement) => HTMLElement | null',
37
+ description: 'Resolves the element to focus for a cell, e.g. a button inside a role="gridcell" wrapper. Omit to focus the cell itself.',
38
+ required: false,
39
+ },
28
40
  {
29
41
  name: 'options.onNavigateBefore',
30
42
  type: '(column: number, offset: number) => void',
@@ -49,6 +61,20 @@ export const docs = {
49
61
  description: 'Callback for Page Down key (e.g., navigate to next month in calendars).',
50
62
  required: false,
51
63
  },
64
+ {
65
+ name: 'options.isRtl',
66
+ type: 'boolean',
67
+ description: 'Swap ArrowLeft/ArrowRight so horizontal navigation follows visual direction in right-to-left contexts.',
68
+ default: 'false',
69
+ required: false,
70
+ },
71
+ {
72
+ name: 'options.hasRovingTabIndex',
73
+ type: 'boolean',
74
+ description: 'Own a single roving tab stop across the grid: one focusable cell (its resolved focus target) carries tabindex="0", the rest -1. Stamped/repaired on render and moved with arrow navigation. Attach the returned handleFocus to the container onFocus.',
75
+ default: 'false',
76
+ required: false,
77
+ },
52
78
  ],
53
79
  returns: [
54
80
  {
@@ -61,6 +87,11 @@ export const docs = {
61
87
  type: '(e: React.KeyboardEvent) => void',
62
88
  description: 'Key down handler to attach to the grid container.',
63
89
  },
90
+ {
91
+ name: 'handleFocus',
92
+ type: '(e: React.FocusEvent) => void',
93
+ description: 'Focus handler for the grid container. Keeps the roving tab stop in sync when hasRovingTabIndex is enabled; a no-op otherwise, so always safe to attach.',
94
+ },
64
95
  {
65
96
  name: 'focusCell',
66
97
  type: '(index: number) => void',
@@ -83,6 +114,7 @@ export const docs = {
83
114
  bestPractices: [
84
115
  { guidance: true, description: 'Use for calendar date grids: wire onPageUp/onPageDown to month navigation and onNavigateBefore/onNavigateAfter for cross-month arrow key navigation.' },
85
116
  { guidance: true, description: 'Attach both gridRef and handleKeyDown to the grid container element.' },
117
+ { guidance: true, description: 'For roving-tabindex grids (e.g. Calendar), set hasRovingTabIndex: true and attach handleFocus to the container onFocus; seed one focus target with tabindex=0 and the hook repairs and moves it.' },
86
118
  { guidance: false, description: 'Use for simple linear lists; prefer useListFocus for 1D navigation.' },
87
119
  ],
88
120
  },
@@ -99,15 +131,20 @@ export const docsDense = {
99
131
  paramDescriptions: {
100
132
  options: 'config for grid focus behavior.',
101
133
  'options.columns': '# columns in grid. Used for up/down navigation (moves by this many cells).',
102
- 'options.cellSelector': 'selector for focusable cells in grid.',
134
+ 'options.cellSelector': 'selector for grid cells in DOM order (incl. disabled/empty) so geometry is preserved.',
135
+ 'options.isCellFocusable': 'predicate for whether a matched cell can take focus. Omit = all focusable.',
136
+ 'options.getFocusTarget': 'resolve element to focus for a cell (e.g. button inside gridcell wrapper). Omit = focus the cell.',
103
137
  'options.onNavigateBefore': 'callback when navigation would go before first cell. Receives column index + offset (1 for horizontal, columns for vertical).',
104
138
  'options.onNavigateAfter': 'callback when navigation would go after last cell. Receives column index + offset.',
105
139
  'options.onPageUp': 'callback for Page Up key (e.g. navigate to previous month in calendars).',
106
140
  'options.onPageDown': 'callback for Page Down key (e.g. navigate to next month in calendars).',
141
+ 'options.isRtl': 'swap ArrowLeft/ArrowRight for RTL horizontal navigation. default false.',
142
+ 'options.hasRovingTabIndex': 'own a single roving tab stop across the grid (one focus target tabindex=0, rest -1); repaired on render + moved with arrows. Attach handleFocus to onFocus. default false.',
107
143
  },
108
144
  returnDescriptions: {
109
145
  gridRef: 'ref to attach to grid container element.',
110
146
  handleKeyDown: 'key down handler for grid container.',
147
+ handleFocus: 'focus handler for grid container; syncs roving tab stop when hasRovingTabIndex on, else no-op.',
111
148
  focusCell: 'focus specific cell by index (clamped to valid range).',
112
149
  focusFirst: 'focus first focusable cell in grid.',
113
150
  focusLast: 'focus last focusable cell in grid.',
@@ -118,6 +155,7 @@ export const docsDense = {
118
155
  bestPractices: [
119
156
  { guidance: true, description: 'Use for calendar date grids: wire onPageUp/onPageDown to month navigation + onNavigateBefore/onNavigateAfter for cross-month arrow key navigation.' },
120
157
  { guidance: true, description: 'Attach both gridRef + handleKeyDown to grid container element.' },
158
+ { guidance: true, description: 'For roving-tabindex grids (e.g. Calendar): hasRovingTabIndex: true + attach handleFocus to onFocus; seed one focus target tabindex=0, hook repairs + moves it.' },
121
159
  { guidance: false, description: 'Use for simple linear lists; prefer useListFocus for 1D navigation.' },
122
160
  ],
123
161
  },
@@ -0,0 +1,132 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useGridFocus.test.tsx
5
+ * @input Uses vitest, @testing-library/react, useGridFocus hook
6
+ * @output Unit tests for useGridFocus roving tabindex + RTL navigation
7
+ * @position Testing; validates useGridFocus.ts roving-tabindex ownership
8
+ *
9
+ * SYNC: When useGridFocus.ts changes, update tests to match new behavior
10
+ */
11
+
12
+ import {describe, it, expect} from 'vitest';
13
+ import {render, screen, fireEvent} from '@testing-library/react';
14
+ import {useGridFocus} from './useGridFocus';
15
+
16
+ const NO_DISABLED: number[] = [];
17
+
18
+ /**
19
+ * A 3x3 grid of cells shaped like Calendar: each `role="gridcell"` div wraps a
20
+ * focusable `<button>` (the focus target). `seed` marks which button starts
21
+ * tabbable (mirrors Calendar seeding the selected/today/first-enabled day).
22
+ */
23
+ function Grid({
24
+ disabled = NO_DISABLED,
25
+ seed = 0,
26
+ ...opts
27
+ }: {
28
+ disabled?: number[];
29
+ seed?: number;
30
+ } & Partial<Parameters<typeof useGridFocus>[0]>) {
31
+ const {gridRef, handleKeyDown, handleFocus} = useGridFocus<HTMLDivElement>({
32
+ columns: 3,
33
+ cellSelector: '[role="gridcell"]',
34
+ isCellFocusable: cell =>
35
+ cell.querySelector('button:not([disabled])') !== null,
36
+ getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
37
+ hasRovingTabIndex: true,
38
+ ...opts,
39
+ });
40
+ return (
41
+ <div
42
+ ref={gridRef}
43
+ role="grid"
44
+ onKeyDown={handleKeyDown}
45
+ onFocus={handleFocus}>
46
+ {Array.from({length: 9}, (_, i) => (
47
+ <div role="gridcell" key={i}>
48
+ <button
49
+ type="button"
50
+ disabled={disabled.includes(i)}
51
+ tabIndex={i === seed ? 0 : -1}
52
+ data-testid={`cell-${i}`}>
53
+ {i}
54
+ </button>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ describe('useGridFocus roving tabindex (hasRovingTabIndex)', () => {
62
+ it('honors the seeded tab stop and stamps -1 on the rest', () => {
63
+ render(<Grid seed={4} />);
64
+ expect(screen.getByTestId('cell-4')).toHaveAttribute('tabindex', '0');
65
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
66
+ expect(screen.getByTestId('cell-8')).toHaveAttribute('tabindex', '-1');
67
+ });
68
+
69
+ it('repairs to the first focusable cell when no cell is seeded', () => {
70
+ render(<Grid seed={-1} />);
71
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '0');
72
+ expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '-1');
73
+ });
74
+
75
+ it('promotes the first ENABLED cell when the seed is disabled', () => {
76
+ render(<Grid seed={-1} disabled={[0, 1]} />);
77
+ expect(screen.getByTestId('cell-2')).toHaveAttribute('tabindex', '0');
78
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
79
+ });
80
+
81
+ it('ArrowRight moves the tab stop to the next cell', () => {
82
+ render(<Grid seed={0} />);
83
+ const grid = screen.getByRole('grid');
84
+ screen.getByTestId('cell-0').focus();
85
+ fireEvent.keyDown(grid, {key: 'ArrowRight'});
86
+ expect(screen.getByTestId('cell-1')).toHaveFocus();
87
+ expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '0');
88
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
89
+ });
90
+
91
+ it('ArrowDown moves the tab stop one row down', () => {
92
+ render(<Grid seed={0} />);
93
+ const grid = screen.getByRole('grid');
94
+ screen.getByTestId('cell-0').focus();
95
+ fireEvent.keyDown(grid, {key: 'ArrowDown'});
96
+ expect(screen.getByTestId('cell-3')).toHaveFocus();
97
+ expect(screen.getByTestId('cell-3')).toHaveAttribute('tabindex', '0');
98
+ });
99
+
100
+ it('handleFocus repairs the stop when the tabbable cell became disabled', () => {
101
+ // Seed cell 0 as tabbable, then disable it and fire the container onFocus.
102
+ // syncTabStops should promote the first still-focusable cell (cell 1).
103
+ const {rerender} = render(<Grid seed={0} />);
104
+ const grid = screen.getByRole('grid');
105
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '0');
106
+ rerender(<Grid seed={-1} disabled={[0]} />);
107
+ fireEvent.focus(grid);
108
+ expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '0');
109
+ expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
110
+ });
111
+
112
+ it('flips ArrowLeft/ArrowRight under RTL', () => {
113
+ render(<Grid seed={1} isRtl />);
114
+ const grid = screen.getByRole('grid');
115
+ screen.getByTestId('cell-1').focus();
116
+ // In RTL, ArrowLeft is "forward" (moves to the next cell in DOM order).
117
+ fireEvent.keyDown(grid, {key: 'ArrowLeft'});
118
+ expect(screen.getByTestId('cell-2')).toHaveFocus();
119
+ });
120
+
121
+ it('does not manage tabindex when hasRovingTabIndex is off', () => {
122
+ render(<Grid seed={0} hasRovingTabIndex={false} />);
123
+ // The seeded -1/0 values are left untouched (caller owns them), and
124
+ // navigation still works without stamping.
125
+ const grid = screen.getByRole('grid');
126
+ screen.getByTestId('cell-0').focus();
127
+ fireEvent.keyDown(grid, {key: 'ArrowRight'});
128
+ expect(screen.getByTestId('cell-1')).toHaveFocus();
129
+ // cell-1 kept its seeded -1; the hook did not promote it.
130
+ expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '-1');
131
+ });
132
+ });