@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
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useGridFocus.ts
|
|
7
|
-
* @input Uses React useCallback, useRef
|
|
7
|
+
* @input Uses React useCallback, useRef, useIsomorphicLayoutEffect
|
|
8
8
|
* @output Exports useGridFocus hook for grid keyboard navigation
|
|
9
9
|
* @position Core hook; used by Calendar for date grid navigation
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update:
|
|
12
12
|
* - /packages/core/src/hooks/index.ts
|
|
13
|
-
* - /packages/core/src/hooks/useGridFocus.test.
|
|
13
|
+
* - /packages/core/src/hooks/useGridFocus.test.tsx
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {useCallback, useRef} from 'react';
|
|
17
|
+
import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Configuration for grid focus behavior
|
|
@@ -83,6 +84,34 @@ export interface UseGridFocusOptions {
|
|
|
83
84
|
* Callback for Page Down key (e.g., next month).
|
|
84
85
|
*/
|
|
85
86
|
onPageDown?: () => void;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the grid is in a right-to-left context. When true, ArrowLeft and
|
|
90
|
+
* ArrowRight are swapped so horizontal navigation follows visual direction.
|
|
91
|
+
* @default false
|
|
92
|
+
*/
|
|
93
|
+
isRtl?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Roving-tabindex ownership. When true, the hook manages a single tab stop
|
|
97
|
+
* across the grid: exactly one focusable cell carries `tabindex="0"` and the
|
|
98
|
+
* rest `tabindex="-1"`. The tab stop is stamped on mount and repaired
|
|
99
|
+
* whenever cells mount/unmount or toggle focusable, and moves with arrow
|
|
100
|
+
* navigation. Attach the returned {@link UseGridFocusReturn.handleFocus} to
|
|
101
|
+
* the container's `onFocus` to keep the stop in sync after clicks or
|
|
102
|
+
* programmatic focus.
|
|
103
|
+
*
|
|
104
|
+
* The tab stop is stamped on the resolved focus target (see
|
|
105
|
+
* {@link UseGridFocusOptions.getFocusTarget}), not the cell wrapper, so it
|
|
106
|
+
* works when the focusable element is a descendant of the cell. An existing
|
|
107
|
+
* `tabindex="0"` on a focus target is honored, letting the caller seed which
|
|
108
|
+
* cell is initially tabbable.
|
|
109
|
+
*
|
|
110
|
+
* When false (the default), the hook only *moves* focus (`.focus()`) and
|
|
111
|
+
* never touches `tabindex` — the caller owns tab-stop management.
|
|
112
|
+
* @default false
|
|
113
|
+
*/
|
|
114
|
+
hasRovingTabIndex?: boolean;
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
/**
|
|
@@ -99,6 +128,13 @@ export interface UseGridFocusReturn<T extends HTMLElement = HTMLElement> {
|
|
|
99
128
|
*/
|
|
100
129
|
handleKeyDown: (e: React.KeyboardEvent) => void;
|
|
101
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Focus handler to attach to the container's `onFocus`. Keeps the roving tab
|
|
133
|
+
* stop in sync when `hasRovingTabIndex` is enabled; a no-op otherwise, so it
|
|
134
|
+
* is always safe to attach.
|
|
135
|
+
*/
|
|
136
|
+
handleFocus: (e: React.FocusEvent) => void;
|
|
137
|
+
|
|
102
138
|
/**
|
|
103
139
|
* Focus a specific cell by index.
|
|
104
140
|
*/
|
|
@@ -132,6 +168,12 @@ export interface UseGridFocusReturn<T extends HTMLElement = HTMLElement> {
|
|
|
132
168
|
* non-focusable cell (per `isCellFocusable`), it continues in the same
|
|
133
169
|
* direction to the next focusable cell.
|
|
134
170
|
*
|
|
171
|
+
* By default the hook only *moves* focus and leaves `tabindex` management to
|
|
172
|
+
* the caller. Opt into {@link UseGridFocusOptions.hasRovingTabIndex} for a hook
|
|
173
|
+
* that owns a single tab stop (roving tabindex) across the grid — stamping and
|
|
174
|
+
* repairing it as cells mount/unmount or toggle focusable, and moving it with
|
|
175
|
+
* arrow navigation.
|
|
176
|
+
*
|
|
135
177
|
* @example
|
|
136
178
|
* ```
|
|
137
179
|
* const {gridRef, handleKeyDown} = useGridFocus({
|
|
@@ -144,6 +186,24 @@ export interface UseGridFocusReturn<T extends HTMLElement = HTMLElement> {
|
|
|
144
186
|
* {cells.map(cell => <button role="gridcell">{cell}</button>)}
|
|
145
187
|
* </div>
|
|
146
188
|
* ```
|
|
189
|
+
*
|
|
190
|
+
* Roving-tabindex grid (e.g. a date picker where the cell is a wrapper around
|
|
191
|
+
* a focusable button):
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```
|
|
195
|
+
* const {gridRef, handleKeyDown, handleFocus} = useGridFocus({
|
|
196
|
+
* columns: 7,
|
|
197
|
+
* cellSelector: '[role="gridcell"]',
|
|
198
|
+
* isCellFocusable: cell => cell.querySelector('button:not([disabled])') != null,
|
|
199
|
+
* getFocusTarget: cell => cell.querySelector('button'),
|
|
200
|
+
* hasRovingTabIndex: true,
|
|
201
|
+
* });
|
|
202
|
+
*
|
|
203
|
+
* <div ref={gridRef} role="grid" onKeyDown={handleKeyDown} onFocus={handleFocus}>
|
|
204
|
+
* {cells}
|
|
205
|
+
* </div>
|
|
206
|
+
* ```
|
|
147
207
|
*/
|
|
148
208
|
export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
149
209
|
options: UseGridFocusOptions,
|
|
@@ -157,6 +217,8 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
157
217
|
onNavigateAfter,
|
|
158
218
|
onPageUp,
|
|
159
219
|
onPageDown,
|
|
220
|
+
isRtl = false,
|
|
221
|
+
hasRovingTabIndex = false,
|
|
160
222
|
} = options;
|
|
161
223
|
|
|
162
224
|
const gridRef = useRef<T>(null);
|
|
@@ -200,21 +262,102 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
200
262
|
[getFocusTarget],
|
|
201
263
|
);
|
|
202
264
|
|
|
265
|
+
// --- Roving tabindex ownership (opt-in via `hasRovingTabIndex`) -------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Set `tabindex` on an element, but only when it differs (avoids redundant
|
|
269
|
+
* DOM writes). Uses setAttribute so the value reflects even for elements
|
|
270
|
+
* (like `<button>`) whose default tabIndex is already 0.
|
|
271
|
+
*/
|
|
272
|
+
const setTabIndex = useCallback((el: HTMLElement, value: 0 | -1) => {
|
|
273
|
+
if (el.getAttribute('tabindex') !== String(value)) {
|
|
274
|
+
el.setAttribute('tabindex', String(value));
|
|
275
|
+
}
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
203
278
|
/**
|
|
204
|
-
*
|
|
279
|
+
* The resolved focus targets of all focusable cells, in DOM order. These are
|
|
280
|
+
* the elements that carry the roving tab stop (a descendant of each cell when
|
|
281
|
+
* `getFocusTarget` is provided, otherwise the cell itself).
|
|
205
282
|
*/
|
|
206
|
-
const
|
|
283
|
+
const getFocusTargets = useCallback((): HTMLElement[] => {
|
|
284
|
+
return getCells()
|
|
285
|
+
.filter(cell => cellFocusable(cell))
|
|
286
|
+
.map(cell => resolveFocusTarget(cell))
|
|
287
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
288
|
+
}, [getCells, cellFocusable, resolveFocusTarget]);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Stamp the roving tab stop: exactly one focus target is tabbable (0), the
|
|
292
|
+
* rest are -1. Prefer keeping the currently-tabbable target if it is still
|
|
293
|
+
* focusable; otherwise promote the first focusable one (tab-stop repair).
|
|
294
|
+
*/
|
|
295
|
+
const syncTabStops = useCallback(() => {
|
|
296
|
+
const targets = getFocusTargets();
|
|
297
|
+
if (targets.length === 0) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const current = targets.find(el => el.getAttribute('tabindex') === '0');
|
|
301
|
+
const tabbable = current ?? targets[0];
|
|
302
|
+
for (const el of targets) {
|
|
303
|
+
setTabIndex(el, el === tabbable ? 0 : -1);
|
|
304
|
+
}
|
|
305
|
+
}, [getFocusTargets, setTabIndex]);
|
|
306
|
+
|
|
307
|
+
// Keep the tab stop valid across renders (cells added/removed, focusability
|
|
308
|
+
// toggled). Runs after every commit but only when roving tabindex is on.
|
|
309
|
+
useIsomorphicLayoutEffect(() => {
|
|
310
|
+
if (hasRovingTabIndex) {
|
|
311
|
+
syncTabStops();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Move the roving tab stop so `target` becomes the sole tabbable focus
|
|
317
|
+
* target, then focus it. No-op on tab stops unless roving tabindex is on.
|
|
318
|
+
*/
|
|
319
|
+
const focusTarget = useCallback(
|
|
320
|
+
(target: HTMLElement | null) => {
|
|
321
|
+
if (!target) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (hasRovingTabIndex) {
|
|
325
|
+
for (const el of getFocusTargets()) {
|
|
326
|
+
setTabIndex(el, el === target ? 0 : -1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
target.focus();
|
|
330
|
+
},
|
|
331
|
+
[hasRovingTabIndex, getFocusTargets, setTabIndex],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Focus a cell by resolving and focusing its target, moving the roving tab
|
|
336
|
+
* stop when enabled.
|
|
337
|
+
*/
|
|
338
|
+
const focusCellWithStop = useCallback(
|
|
207
339
|
(cell: HTMLElement | undefined): boolean => {
|
|
208
340
|
const target = resolveFocusTarget(cell);
|
|
209
|
-
if (target) {
|
|
210
|
-
|
|
211
|
-
return true;
|
|
341
|
+
if (!target) {
|
|
342
|
+
return false;
|
|
212
343
|
}
|
|
213
|
-
|
|
344
|
+
focusTarget(target);
|
|
345
|
+
return true;
|
|
214
346
|
},
|
|
215
|
-
[resolveFocusTarget],
|
|
347
|
+
[resolveFocusTarget, focusTarget],
|
|
216
348
|
);
|
|
217
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Keep the roving stop pointing at whatever ended up focused (e.g. a click
|
|
352
|
+
* or programmatic focus) so the next Tab behaves correctly. No-op unless
|
|
353
|
+
* roving tabindex is enabled.
|
|
354
|
+
*/
|
|
355
|
+
const handleFocus = useCallback(() => {
|
|
356
|
+
if (hasRovingTabIndex) {
|
|
357
|
+
syncTabStops();
|
|
358
|
+
}
|
|
359
|
+
}, [hasRovingTabIndex, syncTabStops]);
|
|
360
|
+
|
|
218
361
|
/**
|
|
219
362
|
* Get the currently focused cell index within the full cell set.
|
|
220
363
|
*/
|
|
@@ -261,10 +404,10 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
261
404
|
target = findFocusableInDirection(cells, clampedIndex, -1);
|
|
262
405
|
}
|
|
263
406
|
if (target !== -1) {
|
|
264
|
-
|
|
407
|
+
focusCellWithStop(cells[target]);
|
|
265
408
|
}
|
|
266
409
|
},
|
|
267
|
-
[getCells, findFocusableInDirection,
|
|
410
|
+
[getCells, findFocusableInDirection, focusCellWithStop],
|
|
268
411
|
);
|
|
269
412
|
|
|
270
413
|
/**
|
|
@@ -274,9 +417,9 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
274
417
|
const cells = getCells();
|
|
275
418
|
const index = findFocusableInDirection(cells, 0, 1);
|
|
276
419
|
if (index !== -1) {
|
|
277
|
-
|
|
420
|
+
focusCellWithStop(cells[index]);
|
|
278
421
|
}
|
|
279
|
-
}, [getCells, findFocusableInDirection,
|
|
422
|
+
}, [getCells, findFocusableInDirection, focusCellWithStop]);
|
|
280
423
|
|
|
281
424
|
/**
|
|
282
425
|
* Focus the last focusable cell.
|
|
@@ -285,9 +428,9 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
285
428
|
const cells = getCells();
|
|
286
429
|
const index = findFocusableInDirection(cells, cells.length - 1, -1);
|
|
287
430
|
if (index !== -1) {
|
|
288
|
-
|
|
431
|
+
focusCellWithStop(cells[index]);
|
|
289
432
|
}
|
|
290
|
-
}, [getCells, findFocusableInDirection,
|
|
433
|
+
}, [getCells, findFocusableInDirection, focusCellWithStop]);
|
|
291
434
|
|
|
292
435
|
/**
|
|
293
436
|
* Handle keyboard navigation.
|
|
@@ -310,12 +453,23 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
310
453
|
|
|
311
454
|
let handled = true;
|
|
312
455
|
|
|
313
|
-
|
|
456
|
+
// In RTL, ArrowLeft/ArrowRight are swapped so horizontal navigation
|
|
457
|
+
// follows visual direction. Vertical keys (Up/Down) are unaffected.
|
|
458
|
+
let key = e.key;
|
|
459
|
+
if (isRtl) {
|
|
460
|
+
if (key === 'ArrowLeft') {
|
|
461
|
+
key = 'ArrowRight';
|
|
462
|
+
} else if (key === 'ArrowRight') {
|
|
463
|
+
key = 'ArrowLeft';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
switch (key) {
|
|
314
468
|
case 'ArrowRight': {
|
|
315
469
|
// Move right, skipping non-focusable cells in the same direction.
|
|
316
470
|
const target = findFocusableInDirection(cells, currentIndex + 1, 1);
|
|
317
471
|
if (target !== -1) {
|
|
318
|
-
|
|
472
|
+
focusCellWithStop(cells[target]);
|
|
319
473
|
} else {
|
|
320
474
|
onNavigateAfter?.((currentCol + 1) % columns, 1);
|
|
321
475
|
}
|
|
@@ -325,7 +479,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
325
479
|
case 'ArrowLeft': {
|
|
326
480
|
const target = findFocusableInDirection(cells, currentIndex - 1, -1);
|
|
327
481
|
if (target !== -1) {
|
|
328
|
-
|
|
482
|
+
focusCellWithStop(cells[target]);
|
|
329
483
|
} else {
|
|
330
484
|
onNavigateBefore?.(
|
|
331
485
|
currentCol === 0 ? columns - 1 : currentCol - 1,
|
|
@@ -345,7 +499,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
345
499
|
columns,
|
|
346
500
|
);
|
|
347
501
|
if (target !== -1) {
|
|
348
|
-
|
|
502
|
+
focusCellWithStop(cells[target]);
|
|
349
503
|
} else {
|
|
350
504
|
onNavigateAfter?.(currentCol, columns);
|
|
351
505
|
}
|
|
@@ -363,7 +517,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
363
517
|
-columns,
|
|
364
518
|
);
|
|
365
519
|
if (target !== -1) {
|
|
366
|
-
|
|
520
|
+
focusCellWithStop(cells[target]);
|
|
367
521
|
} else {
|
|
368
522
|
onNavigateBefore?.(currentCol, columns);
|
|
369
523
|
}
|
|
@@ -383,7 +537,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
383
537
|
const rowEnd = Math.min(rowStart + columns - 1, cells.length - 1);
|
|
384
538
|
const target = findFocusableInDirection(cells, rowStart, 1);
|
|
385
539
|
if (target !== -1 && target <= rowEnd) {
|
|
386
|
-
|
|
540
|
+
focusCellWithStop(cells[target]);
|
|
387
541
|
}
|
|
388
542
|
}
|
|
389
543
|
break;
|
|
@@ -398,7 +552,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
398
552
|
const rowEnd = Math.min(rowStart + columns - 1, cells.length - 1);
|
|
399
553
|
const target = findFocusableInDirection(cells, rowEnd, -1);
|
|
400
554
|
if (target !== -1 && target >= rowStart) {
|
|
401
|
-
|
|
555
|
+
focusCellWithStop(cells[target]);
|
|
402
556
|
}
|
|
403
557
|
}
|
|
404
558
|
break;
|
|
@@ -423,11 +577,12 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
423
577
|
[
|
|
424
578
|
columns,
|
|
425
579
|
findFocusableInDirection,
|
|
426
|
-
|
|
580
|
+
focusCellWithStop,
|
|
427
581
|
focusFirst,
|
|
428
582
|
focusLast,
|
|
429
583
|
getCells,
|
|
430
584
|
getCurrentIndex,
|
|
585
|
+
isRtl,
|
|
431
586
|
onNavigateAfter,
|
|
432
587
|
onNavigateBefore,
|
|
433
588
|
onPageDown,
|
|
@@ -438,6 +593,7 @@ export function useGridFocus<T extends HTMLElement = HTMLElement>(
|
|
|
438
593
|
return {
|
|
439
594
|
gridRef,
|
|
440
595
|
handleKeyDown,
|
|
596
|
+
handleFocus,
|
|
441
597
|
focusCell,
|
|
442
598
|
focusFirst,
|
|
443
599
|
focusLast,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('../docs-types').HookDoc} */
|
|
4
|
+
export const docs = {
|
|
5
|
+
name: 'useKeyboardHint',
|
|
6
|
+
displayName: 'useKeyboardHint',
|
|
7
|
+
keywords: ['keyboard', 'hint', 'arrow', 'navigation', 'roving', 'tabindex', 'focus', 'discoverability', 'toolbar', 'tabs', 'segmented', 'affordance', 'a11y'],
|
|
8
|
+
params: [
|
|
9
|
+
{
|
|
10
|
+
name: 'options',
|
|
11
|
+
type: 'UseKeyboardHintOptions',
|
|
12
|
+
description: 'Configuration object for the keyboard hint.',
|
|
13
|
+
required: false,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'options.orientation',
|
|
17
|
+
type: "'horizontal' | 'vertical' | 'both'",
|
|
18
|
+
description: 'Which arrow-key axis the composite navigates, controlling which arrow icons the hint shows (← → for horizontal, ↑ ↓ for vertical, all four for both).',
|
|
19
|
+
default: "'horizontal'",
|
|
20
|
+
required: false,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'options.dismissAfterMs',
|
|
24
|
+
type: 'number',
|
|
25
|
+
description: 'Milliseconds before the hint auto-dismisses after appearing.',
|
|
26
|
+
default: '3000',
|
|
27
|
+
required: false,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'options.isEnabled',
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Whether the hint is enabled. Set to false to suppress it for a specific instance (e.g. a disabled or read-only widget).',
|
|
33
|
+
default: 'true',
|
|
34
|
+
required: false,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
returns: [
|
|
38
|
+
{
|
|
39
|
+
name: 'hintElement',
|
|
40
|
+
type: 'ReactNode',
|
|
41
|
+
description: 'The popover hint element to render inside the composite container (as the last child). Portals to the top layer via popover="manual", renders arrow keys with Kbd, and manages its own visibility — render it unconditionally.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'onFocus',
|
|
45
|
+
type: '(e: React.FocusEvent) => void',
|
|
46
|
+
description: 'Attach to the container onFocus. Shows the hint on the first keyboard-focus (:focus-visible) entry from outside the composite.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'onBlur',
|
|
50
|
+
type: '(e: React.FocusEvent) => void',
|
|
51
|
+
description: 'Attach to the container onBlur. Hides the hint when focus leaves the composite entirely, and re-anchors when focus moves within.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'onKeyDown',
|
|
55
|
+
type: '(e: React.KeyboardEvent) => void',
|
|
56
|
+
description: 'Attach to the container onKeyDown. Dismisses the hint on the first arrow press (the user has discovered the interaction). Never prevents default or stops propagation.',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
usage: {
|
|
60
|
+
description:
|
|
61
|
+
'Shows an ephemeral "← → to navigate" hint anchored to the focused item the first time a roving-tabindex composite (Toolbar, TabList, SegmentedControl, etc.) receives keyboard focus. It teaches sighted keyboard users that arrow keys move within the group. The hint renders arrow keys with Kbd in the top layer (popover="manual") and is CSS-anchor-positioned to the focused element, so overflow containers never clip it. It auto-dismisses on the first arrow press, on timeout, or on blur, and does not re-show for that instance. Toolbar, TabList, and SegmentedControl wire this in automatically — reach for the hook directly only when building a custom roving-tabindex widget.',
|
|
62
|
+
bestPractices: [
|
|
63
|
+
{ guidance: true, description: 'Compose the returned onFocus/onKeyDown with your existing focus handlers rather than replacing them — call onKeyDown first (it only dismisses, never prevents), then your navigation handler.' },
|
|
64
|
+
{ guidance: true, description: 'Render hintElement as the last child of the composite container; it is position:fixed in the top layer and aria-hidden, so it never affects layout or the accessibility tree.' },
|
|
65
|
+
{ guidance: true, description: 'Match orientation to the arrow keys your widget actually responds to so the hint shows the correct icons.' },
|
|
66
|
+
{ guidance: false, description: 'Use for single controls or widgets without roving-tabindex navigation — the hint only makes sense where arrows move focus within a group.' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
relatedComponents: ['Toolbar', 'TabList', 'SegmentedControl'],
|
|
70
|
+
relatedHooks: ['useListFocus', 'useGridFocus', 'useTreeFocus'],
|
|
71
|
+
importPath: '@astryxdesign/core/hooks',
|
|
72
|
+
category: 'focus',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** @type {import('../docs-types').HookTranslationDoc} */
|
|
76
|
+
export const docsDense = {
|
|
77
|
+
description:
|
|
78
|
+
'Ephemeral "← → to navigate" hint anchored to the focused item on first keyboard focus of a roving-tabindex composite (Toolbar/TabList/SegmentedControl). Teaches sighted keyboard users that arrows move within the group. Kbd-rendered arrows in a top-layer popover="manual", CSS-anchor-positioned (never clipped by overflow), aria-hidden. Auto-dismisses on first arrow press, timeout, or blur; never re-shows for that instance.',
|
|
79
|
+
paramDescriptions: {
|
|
80
|
+
options: 'config for the keyboard hint.',
|
|
81
|
+
'options.orientation': "arrow axis: 'horizontal' (← →), 'vertical' (↑ ↓), 'both' (all four). Controls shown icons.",
|
|
82
|
+
'options.dismissAfterMs': 'ms before auto-dismiss after appearing.',
|
|
83
|
+
'options.isEnabled': 'whether the hint is enabled; false suppresses it (e.g. disabled/read-only widget).',
|
|
84
|
+
},
|
|
85
|
+
returnDescriptions: {
|
|
86
|
+
hintElement: 'popover hint to render as last child of the container. Kbd-rendered arrows, top-layer, self-managing — render unconditionally.',
|
|
87
|
+
onFocus: 'container onFocus: shows hint on first :focus-visible entry from outside.',
|
|
88
|
+
onBlur: 'container onBlur: hides on leaving the composite; re-anchors on internal moves.',
|
|
89
|
+
onKeyDown: 'container onKeyDown: dismisses on first arrow press. Never prevents default.',
|
|
90
|
+
},
|
|
91
|
+
usage: {
|
|
92
|
+
description:
|
|
93
|
+
'Ephemeral "← → to navigate" hint on first keyboard focus of a roving-tabindex composite. Top-layer anchored popover, aria-hidden. Auto-dismisses on first arrow press, timeout, or blur; no re-show. Toolbar/TabList/SegmentedControl wire it in automatically.',
|
|
94
|
+
bestPractices: [
|
|
95
|
+
{ guidance: true, description: 'Compose returned onFocus/onKeyDown with existing handlers (call onKeyDown first — it only dismisses).' },
|
|
96
|
+
{ guidance: true, description: 'Render hintElement as last child; top-layer + aria-hidden, no layout/a11y impact.' },
|
|
97
|
+
{ guidance: true, description: 'Match orientation to the arrows the widget responds to.' },
|
|
98
|
+
{ guidance: false, description: 'Use for single controls / non-roving widgets.' },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useKeyboardHint.test.tsx
|
|
5
|
+
* @input Uses React Testing Library, useKeyboardHint
|
|
6
|
+
* @output Unit tests for keyboard hint rendering
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {render} from '@testing-library/react';
|
|
10
|
+
import {describe, expect, it} from 'vitest';
|
|
11
|
+
import {useKeyboardHint, type KeyboardHintOrientation} from './useKeyboardHint';
|
|
12
|
+
|
|
13
|
+
function TestHint({orientation}: {orientation?: KeyboardHintOrientation}) {
|
|
14
|
+
const {hintElement} = useKeyboardHint({orientation});
|
|
15
|
+
return <div>{hintElement}</div>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getKeyLabels(container: HTMLElement): (string | null)[] {
|
|
19
|
+
return Array.from(container.querySelectorAll('.astryx-kbd')).map(key =>
|
|
20
|
+
key.getAttribute('aria-label'),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getKeyText(container: HTMLElement): string[] {
|
|
25
|
+
return Array.from(container.querySelectorAll('kbd')).map(
|
|
26
|
+
key => key.textContent ?? '',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('useKeyboardHint', () => {
|
|
31
|
+
it('renders horizontal arrow keys with Kbd', () => {
|
|
32
|
+
const {container} = render(<TestHint orientation="horizontal" />);
|
|
33
|
+
|
|
34
|
+
expect(getKeyLabels(container)).toEqual(['Left arrow', 'Right arrow']);
|
|
35
|
+
expect(getKeyText(container)).toEqual(['←', '→']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders vertical arrow keys with Kbd', () => {
|
|
39
|
+
const {container} = render(<TestHint orientation="vertical" />);
|
|
40
|
+
|
|
41
|
+
expect(getKeyLabels(container)).toEqual(['Up arrow', 'Down arrow']);
|
|
42
|
+
expect(getKeyText(container)).toEqual(['↑', '↓']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders all arrow keys for both-axis navigation', () => {
|
|
46
|
+
const {container} = render(<TestHint orientation="both" />);
|
|
47
|
+
|
|
48
|
+
expect(getKeyLabels(container)).toEqual([
|
|
49
|
+
'Left arrow',
|
|
50
|
+
'Right arrow',
|
|
51
|
+
'Up arrow',
|
|
52
|
+
'Down arrow',
|
|
53
|
+
]);
|
|
54
|
+
expect(getKeyText(container)).toEqual(['←', '→', '↑', '↓']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('positions the hint farther from the anchor', () => {
|
|
58
|
+
const {container} = render(<TestHint />);
|
|
59
|
+
const hint = container.querySelector('[popover="manual"]') as HTMLElement;
|
|
60
|
+
|
|
61
|
+
expect(hint.style.marginBlockStart).toBe('var(--spacing-2)');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps the hint surface padding after useLayer reset styles', () => {
|
|
65
|
+
const {container} = render(<TestHint />);
|
|
66
|
+
const hint = container.querySelector('[popover="manual"]') as HTMLElement;
|
|
67
|
+
|
|
68
|
+
expect(hint.className).toContain('useKeyboardHint__styles.hint');
|
|
69
|
+
});
|
|
70
|
+
});
|