@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.
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +4 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +2 -4
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +33 -14
- package/dist/Calendar/hooks/index.d.ts +0 -2
- package/dist/Calendar/hooks/index.d.ts.map +1 -1
- package/dist/Calendar/hooks/index.js +1 -2
- package/dist/Calendar/index.d.ts +2 -2
- package/dist/Calendar/index.d.ts.map +1 -1
- package/dist/Calendar/index.js +1 -1
- package/dist/CheckboxList/CheckboxList.js +3 -3
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +3 -3
- package/dist/ContextMenu/ContextMenu.d.ts +1 -7
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +7 -13
- package/dist/DropdownMenu/DropdownMenu.d.ts +1 -8
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +4 -9
- package/dist/Field/Field.d.ts +4 -4
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +2 -2
- package/dist/Field/FieldLabel.d.ts +4 -4
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +2 -2
- package/dist/InputGroup/InputGroup.js +3 -3
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +2 -4
- package/dist/MoreMenu/MoreMenu.d.ts +1 -7
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +0 -2
- package/dist/MultiSelector/MultiSelector.d.ts +25 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +31 -6
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -4
- package/dist/RadioList/RadioList.js +3 -3
- package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControl.js +51 -61
- package/dist/Selector/Selector.d.ts +22 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +31 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +2 -4
- package/dist/TabList/TabList.d.ts +3 -2
- package/dist/TabList/TabList.d.ts.map +1 -1
- package/dist/TabList/TabList.js +55 -33
- package/dist/Table/tableContextMenu.d.ts.map +1 -1
- package/dist/Table/tableContextMenu.js +0 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +2 -4
- package/dist/Toolbar/Toolbar.d.ts +3 -3
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +46 -6
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +16 -22
- package/dist/TreeList/TreeListItem.d.ts +3 -2
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/astryx.css +3 -0
- package/dist/astryx.umd.js +47 -47
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +4 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useGridFocus.d.ts +56 -0
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +138 -24
- package/dist/hooks/useKeyboardHint.d.ts +82 -0
- package/dist/hooks/useKeyboardHint.d.ts.map +1 -0
- package/dist/hooks/useKeyboardHint.js +220 -0
- package/dist/hooks/useTreeFocus.d.ts +27 -3
- package/dist/hooks/useTreeFocus.d.ts.map +1 -1
- package/dist/hooks/useTreeFocus.js +69 -6
- package/package.json +1 -1
- package/src/Breadcrumbs/BreadcrumbItem.tsx +5 -2
- package/src/Button/Button.tsx +3 -16
- package/src/Calendar/Calendar.tsx +52 -22
- package/src/Calendar/hooks/index.ts +0 -6
- package/src/Calendar/index.ts +0 -3
- package/src/CheckboxList/CheckboxList.tsx +3 -3
- package/src/Citation/Citation.doc.mjs +10 -0
- package/src/Citation/Citation.test.tsx +117 -0
- package/src/Citation/Citation.tsx +9 -1
- package/src/ContextMenu/ContextMenu.doc.mjs +0 -6
- package/src/ContextMenu/ContextMenu.test.tsx +4 -6
- package/src/ContextMenu/ContextMenu.tsx +7 -19
- package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -8
- package/src/DropdownMenu/DropdownMenu.test.tsx +0 -23
- package/src/DropdownMenu/DropdownMenu.tsx +4 -16
- package/src/Field/Field.test.tsx +2 -2
- package/src/Field/Field.tsx +5 -5
- package/src/Field/FieldLabel.tsx +5 -5
- package/src/InputGroup/InputGroup.tsx +3 -3
- package/src/Link/Link.tsx +2 -13
- package/src/MoreMenu/MoreMenu.doc.mjs +2 -17
- package/src/MoreMenu/MoreMenu.tsx +0 -9
- package/src/MultiSelector/MultiSelector.doc.mjs +25 -0
- package/src/MultiSelector/MultiSelector.test.tsx +150 -1
- package/src/MultiSelector/MultiSelector.tsx +55 -3
- package/src/ProgressBar/ProgressBar.tsx +2 -3
- package/src/RadioList/RadioList.tsx +3 -3
- package/src/SegmentedControl/SegmentedControl.tsx +51 -77
- package/src/Selector/Selector.doc.mjs +21 -0
- package/src/Selector/Selector.test.tsx +123 -1
- package/src/Selector/Selector.tsx +53 -3
- package/src/Switch/Switch.tsx +2 -16
- package/src/TabList/TabList.test.tsx +41 -0
- package/src/TabList/TabList.tsx +66 -39
- package/src/Table/tableContextMenu.tsx +1 -5
- package/src/TextArea/TextArea.tsx +3 -13
- package/src/Toolbar/Toolbar.test.tsx +64 -6
- package/src/Toolbar/Toolbar.tsx +55 -4
- package/src/TreeList/TreeList.tsx +17 -28
- package/src/TreeList/TreeListItem.tsx +3 -2
- package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
- package/src/hooks/index.ts +12 -5
- package/src/hooks/useGridFocus.doc.mjs +40 -2
- package/src/hooks/useGridFocus.test.tsx +132 -0
- package/src/hooks/useGridFocus.ts +179 -23
- package/src/hooks/useKeyboardHint.doc.mjs +101 -0
- package/src/hooks/useKeyboardHint.test.tsx +70 -0
- package/src/hooks/useKeyboardHint.tsx +332 -0
- package/src/hooks/useTreeFocus.doc.mjs +15 -1
- package/src/hooks/useTreeFocus.ts +101 -6
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +0 -57
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +0 -1
- package/dist/Calendar/hooks/useCalendarRovingTabindex.js +0 -96
- 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
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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
|
|
197
|
-
//
|
|
198
|
-
// or first enabled)
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
269
|
-
*
|
|
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
|
|
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
|
|
31
|
-
{ guidance: false, description: 'Put interactive controls inside it
|
|
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
|
|
59
|
-
{ guidance: false, description: 'Put interactive controls inside it
|
|
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
|
|
73
|
-
{ guidance: false, description: 'Put interactive controls inside it
|
|
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: {
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
+
});
|