@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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useKeyboardHint.tsx
|
|
7
|
+
* @input Uses React, StyleX, Kbd, useLayer
|
|
8
|
+
* @output Exports useKeyboardHint hook — ephemeral arrow-key navigation hint
|
|
9
|
+
* @position Core hook; shows sighted keyboard users how to navigate composite
|
|
10
|
+
* widgets that use roving tabindex (single Tab stop, arrows inside)
|
|
11
|
+
*
|
|
12
|
+
* SYNC: When modified, update:
|
|
13
|
+
* - /packages/core/src/hooks/index.ts
|
|
14
|
+
* - /packages/core/src/hooks/useKeyboardHint.doc.mjs
|
|
15
|
+
* - /apps/storybook/stories/useKeyboardHint.stories.tsx
|
|
16
|
+
* - /packages/cli/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, {useCallback, useEffect, useRef, type ReactNode} from 'react';
|
|
20
|
+
import * as stylex from '@stylexjs/stylex';
|
|
21
|
+
import {
|
|
22
|
+
colorVars,
|
|
23
|
+
radiusVars,
|
|
24
|
+
shadowVars,
|
|
25
|
+
spacingVars,
|
|
26
|
+
typeScaleVars,
|
|
27
|
+
} from '../theme/tokens.stylex';
|
|
28
|
+
import {Kbd} from '../Kbd';
|
|
29
|
+
import {useLayer} from '../Layer/useLayer';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export type KeyboardHintOrientation = 'horizontal' | 'vertical' | 'both';
|
|
36
|
+
|
|
37
|
+
export interface UseKeyboardHintOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Orientation of the arrow-key navigation. Controls which arrow icons are
|
|
40
|
+
* shown in the hint badge.
|
|
41
|
+
* - `'horizontal'` → ← →
|
|
42
|
+
* - `'vertical'` → ↑ ↓
|
|
43
|
+
* - `'both'` → ← → ↑ ↓
|
|
44
|
+
* @default 'horizontal'
|
|
45
|
+
*/
|
|
46
|
+
orientation?: KeyboardHintOrientation;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Milliseconds before the hint auto-dismisses after appearing.
|
|
50
|
+
* @default 3000
|
|
51
|
+
*/
|
|
52
|
+
dismissAfterMs?: number;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the hint is enabled. Set to false to disable for a specific
|
|
56
|
+
* instance (e.g. when the widget is read-only or the user has dismissed
|
|
57
|
+
* globally).
|
|
58
|
+
* @default true
|
|
59
|
+
*/
|
|
60
|
+
isEnabled?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UseKeyboardHintReturn {
|
|
64
|
+
/**
|
|
65
|
+
* The popover hint element to render inside your component tree (portals to
|
|
66
|
+
* top layer via `popover="manual"`). Render unconditionally — it manages its
|
|
67
|
+
* own visibility.
|
|
68
|
+
*/
|
|
69
|
+
hintElement: ReactNode;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Attach to the composite container's `onFocus`. Shows the hint on the first
|
|
73
|
+
* keyboard-focus (`:focus-visible`) entry from outside.
|
|
74
|
+
*/
|
|
75
|
+
onFocus: (e: React.FocusEvent) => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Attach to the composite container's `onBlur`. Hides the hint when focus
|
|
79
|
+
* leaves the composite entirely.
|
|
80
|
+
*/
|
|
81
|
+
onBlur: (e: React.FocusEvent) => void;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Attach to the composite container's `onKeyDown`. Dismisses the hint on the
|
|
85
|
+
* first arrow press (the user discovered the interaction).
|
|
86
|
+
*/
|
|
87
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Styles
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
|
|
95
|
+
|
|
96
|
+
const ARROW_HINT_KEYS: Record<
|
|
97
|
+
KeyboardHintOrientation,
|
|
98
|
+
ReadonlyArray<string>
|
|
99
|
+
> = {
|
|
100
|
+
horizontal: ['left', 'right'],
|
|
101
|
+
vertical: ['up', 'down'],
|
|
102
|
+
both: ['left', 'right', 'up', 'down'],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const styles = stylex.create({
|
|
106
|
+
hint: {
|
|
107
|
+
// Top layer + anchor positioned
|
|
108
|
+
position: 'fixed',
|
|
109
|
+
inset: 'auto',
|
|
110
|
+
margin: 0,
|
|
111
|
+
border: 'none',
|
|
112
|
+
|
|
113
|
+
// Surface
|
|
114
|
+
backgroundColor: colorVars['--color-background-popover'],
|
|
115
|
+
borderRadius: radiusVars['--radius-element'],
|
|
116
|
+
boxShadow: shadowVars['--shadow-low'],
|
|
117
|
+
paddingBlockStart: spacingVars['--spacing-1'],
|
|
118
|
+
paddingBlockEnd: spacingVars['--spacing-1'],
|
|
119
|
+
paddingInlineStart: spacingVars['--spacing-2'],
|
|
120
|
+
paddingInlineEnd: spacingVars['--spacing-2'],
|
|
121
|
+
|
|
122
|
+
// Typography
|
|
123
|
+
fontSize: typeScaleVars['--text-supporting-size'],
|
|
124
|
+
lineHeight: typeScaleVars['--text-supporting-leading'],
|
|
125
|
+
color: colorVars['--color-text-secondary'],
|
|
126
|
+
whiteSpace: 'nowrap',
|
|
127
|
+
|
|
128
|
+
// Animation
|
|
129
|
+
opacity: {
|
|
130
|
+
default: 0,
|
|
131
|
+
':popover-open': 1,
|
|
132
|
+
},
|
|
133
|
+
transitionProperty: 'opacity, display, overlay',
|
|
134
|
+
transitionDuration: '150ms',
|
|
135
|
+
transitionBehavior: 'allow-discrete',
|
|
136
|
+
|
|
137
|
+
// Don't capture pointer events (hint floats above content)
|
|
138
|
+
pointerEvents: 'none',
|
|
139
|
+
},
|
|
140
|
+
keys: {
|
|
141
|
+
display: 'inline-flex',
|
|
142
|
+
alignItems: 'center',
|
|
143
|
+
gap: spacingVars['--spacing-1'],
|
|
144
|
+
},
|
|
145
|
+
label: {
|
|
146
|
+
marginInlineStart: spacingVars['--spacing-1'],
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Hook
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Shows an ephemeral visual hint ("← → to navigate") anchored to the focused
|
|
156
|
+
* item when a composite widget first receives keyboard focus. Teaches sighted
|
|
157
|
+
* keyboard users that arrows navigate within the group.
|
|
158
|
+
*
|
|
159
|
+
* The hint renders in the top layer (popover="manual") and is CSS-anchor-
|
|
160
|
+
* positioned to the currently focused element, so it is never clipped by
|
|
161
|
+
* overflow containers. It auto-dismisses on first arrow press, timeout, or
|
|
162
|
+
* blur, and does not re-show for that instance.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```
|
|
166
|
+
* const hint = useKeyboardHint({orientation: 'horizontal'});
|
|
167
|
+
* <div role="toolbar" onFocus={hint.onFocus} onBlur={hint.onBlur} onKeyDown={hint.onKeyDown}>
|
|
168
|
+
* {children}
|
|
169
|
+
* {hint.hintElement}
|
|
170
|
+
* </div>
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export function useKeyboardHint(
|
|
174
|
+
options: UseKeyboardHintOptions = {},
|
|
175
|
+
): UseKeyboardHintReturn {
|
|
176
|
+
const {
|
|
177
|
+
orientation = 'horizontal',
|
|
178
|
+
dismissAfterMs = 3000,
|
|
179
|
+
isEnabled = true,
|
|
180
|
+
} = options;
|
|
181
|
+
|
|
182
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
183
|
+
const dismissedRef = useRef(false);
|
|
184
|
+
const isVisibleRef = useRef(false);
|
|
185
|
+
const layerAnchorRef = useRef<(el: HTMLElement | null) => void>(() => {});
|
|
186
|
+
|
|
187
|
+
const clearDismissTimeout = useCallback(() => {
|
|
188
|
+
if (timeoutRef.current) {
|
|
189
|
+
clearTimeout(timeoutRef.current);
|
|
190
|
+
timeoutRef.current = null;
|
|
191
|
+
}
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
const handleLayerShow = useCallback(() => {
|
|
195
|
+
isVisibleRef.current = true;
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
const handleLayerHide = useCallback(() => {
|
|
199
|
+
isVisibleRef.current = false;
|
|
200
|
+
clearDismissTimeout();
|
|
201
|
+
layerAnchorRef.current(null);
|
|
202
|
+
}, [clearDismissTimeout]);
|
|
203
|
+
|
|
204
|
+
const layer = useLayer({
|
|
205
|
+
mode: 'context',
|
|
206
|
+
onShow: handleLayerShow,
|
|
207
|
+
onHide: handleLayerHide,
|
|
208
|
+
});
|
|
209
|
+
layerAnchorRef.current = layer.ref;
|
|
210
|
+
|
|
211
|
+
// Hide + mark dismissed (won't re-show for this instance)
|
|
212
|
+
const dismiss = useCallback(() => {
|
|
213
|
+
dismissedRef.current = true;
|
|
214
|
+
clearDismissTimeout();
|
|
215
|
+
layer.hide();
|
|
216
|
+
isVisibleRef.current = false;
|
|
217
|
+
layerAnchorRef.current(null);
|
|
218
|
+
}, [clearDismissTimeout, layer]);
|
|
219
|
+
|
|
220
|
+
// Show the layer anchored to the focused element
|
|
221
|
+
const show = useCallback(
|
|
222
|
+
(anchor: HTMLElement) => {
|
|
223
|
+
if (dismissedRef.current || !isEnabled) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
layerAnchorRef.current(anchor);
|
|
228
|
+
layer.show();
|
|
229
|
+
|
|
230
|
+
clearDismissTimeout();
|
|
231
|
+
timeoutRef.current = setTimeout(() => {
|
|
232
|
+
dismiss();
|
|
233
|
+
}, dismissAfterMs);
|
|
234
|
+
},
|
|
235
|
+
[clearDismissTimeout, dismiss, dismissAfterMs, isEnabled, layer],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Cleanup on unmount
|
|
239
|
+
useEffect(
|
|
240
|
+
() => () => {
|
|
241
|
+
clearDismissTimeout();
|
|
242
|
+
layerAnchorRef.current(null);
|
|
243
|
+
},
|
|
244
|
+
[clearDismissTimeout],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// --- Handlers ---
|
|
248
|
+
|
|
249
|
+
const onFocus = useCallback(
|
|
250
|
+
(e: React.FocusEvent) => {
|
|
251
|
+
if (dismissedRef.current || !isEnabled) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Only show on keyboard focus (focus-visible)
|
|
255
|
+
const target = e.target as HTMLElement;
|
|
256
|
+
if (!target.matches(':focus-visible')) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Only show when focus enters from outside the container
|
|
260
|
+
const container = e.currentTarget as HTMLElement;
|
|
261
|
+
if (
|
|
262
|
+
e.relatedTarget instanceof Node &&
|
|
263
|
+
container.contains(e.relatedTarget)
|
|
264
|
+
) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
show(target);
|
|
268
|
+
},
|
|
269
|
+
[show, isEnabled],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const onBlur = useCallback(
|
|
273
|
+
(e: React.FocusEvent) => {
|
|
274
|
+
if (!isVisibleRef.current) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const container = e.currentTarget as HTMLElement;
|
|
278
|
+
// Only dismiss when focus leaves the container entirely
|
|
279
|
+
if (
|
|
280
|
+
e.relatedTarget instanceof Node &&
|
|
281
|
+
container.contains(e.relatedTarget)
|
|
282
|
+
) {
|
|
283
|
+
// Focus moved within — re-anchor to the new target
|
|
284
|
+
if (!dismissedRef.current && e.relatedTarget instanceof HTMLElement) {
|
|
285
|
+
layerAnchorRef.current(e.relatedTarget);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
dismiss();
|
|
290
|
+
},
|
|
291
|
+
[dismiss],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const onKeyDown = useCallback(
|
|
295
|
+
(e: React.KeyboardEvent) => {
|
|
296
|
+
if (!isVisibleRef.current) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (ARROW_KEYS.has(e.key)) {
|
|
300
|
+
dismiss();
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
[dismiss],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// --- Render the hint element ---
|
|
307
|
+
|
|
308
|
+
const arrowContent = (
|
|
309
|
+
<span {...stylex.props(styles.keys)}>
|
|
310
|
+
{ARROW_HINT_KEYS[orientation].map(key => (
|
|
311
|
+
<Kbd key={key} keys={key} />
|
|
312
|
+
))}
|
|
313
|
+
</span>
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const hintElement = layer.render(
|
|
317
|
+
<span aria-hidden="true">
|
|
318
|
+
{arrowContent}
|
|
319
|
+
<span {...stylex.props(styles.label)}>to navigate</span>
|
|
320
|
+
</span>,
|
|
321
|
+
{
|
|
322
|
+
placement: 'below',
|
|
323
|
+
alignment: 'start',
|
|
324
|
+
xstyle: styles.hint,
|
|
325
|
+
style: {
|
|
326
|
+
marginBlockStart: spacingVars['--spacing-2'],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return {hintElement, onFocus, onBlur, onKeyDown};
|
|
332
|
+
}
|
|
@@ -49,6 +49,13 @@ export const docs = {
|
|
|
49
49
|
description: 'Notified when the hook moves focus to a treeitem. Consumers use this to move a single roving tab stop.',
|
|
50
50
|
required: false,
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
name: 'options.hasRovingTabIndex',
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'When true, the hook owns a single roving tab stop across the visible treeitems (stamps tabindex 0/-1, repairs on mount, moves with navigation). Preserves an existing tabindex="0" seed on mount. Attach the returned `handleFocus` to keep the stop in sync after clicks.',
|
|
56
|
+
default: 'false',
|
|
57
|
+
required: false,
|
|
58
|
+
},
|
|
52
59
|
{
|
|
53
60
|
name: 'options.typeahead',
|
|
54
61
|
type: 'boolean',
|
|
@@ -68,6 +75,11 @@ export const docs = {
|
|
|
68
75
|
type: '(e: React.KeyboardEvent) => void',
|
|
69
76
|
description: 'Key down handler to attach to the tree container.',
|
|
70
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: 'handleFocus',
|
|
80
|
+
type: '(e: React.FocusEvent) => void',
|
|
81
|
+
description: 'Focus handler to attach to the container\'s onFocus. Keeps the roving tab stop in sync when hasRovingTabIndex is enabled; a no-op otherwise, so always safe to attach.',
|
|
82
|
+
},
|
|
71
83
|
{
|
|
72
84
|
name: 'focusFirst',
|
|
73
85
|
type: '() => void',
|
|
@@ -85,7 +97,7 @@ export const docs = {
|
|
|
85
97
|
bestPractices: [
|
|
86
98
|
{ guidance: true, description: 'Use for hierarchical tree widgets: wire onToggleExpand to your expansion state and onActiveChange to a single roving tab stop.' },
|
|
87
99
|
{ guidance: true, description: 'Attach both treeRef and handleKeyDown to the role="tree" container element.' },
|
|
88
|
-
{ guidance: false, description: 'Use for linear lists (prefer useListFocus) or 2D grids (prefer useGridFocus)
|
|
100
|
+
{ guidance: false, description: 'Use for linear lists (prefer useListFocus) or 2D grids (prefer useGridFocus); those traversals differ from a tree.' },
|
|
89
101
|
],
|
|
90
102
|
},
|
|
91
103
|
relatedComponents: ['TreeList'],
|
|
@@ -106,11 +118,13 @@ export const docsDense = {
|
|
|
106
118
|
'options.onToggleExpand': 'expand/collapse treeitem by id (ArrowRight collapsed parent, ArrowLeft expanded parent, Enter/Space parent w/o own action).',
|
|
107
119
|
'options.onActivate': 'called on Enter/Space activation. Return true when handled; else hook falls back to toggling expansion.',
|
|
108
120
|
'options.onActiveChange': 'notified when focus moves to a treeitem. Use to move a single roving tab stop.',
|
|
121
|
+
'options.hasRovingTabIndex': 'hook owns a single roving tab stop (stamps tabindex 0/-1, repairs on mount, moves w/ nav). Preserves an existing tabindex="0" seed. Attach handleFocus to sync after clicks.',
|
|
109
122
|
'options.typeahead': 'enable typeahead (jump to next item whose text starts with typed chars).',
|
|
110
123
|
},
|
|
111
124
|
returnDescriptions: {
|
|
112
125
|
treeRef: 'ref to attach to tree container (role="tree").',
|
|
113
126
|
handleKeyDown: 'key down handler for tree container.',
|
|
127
|
+
handleFocus: 'onFocus handler; keeps roving tab stop in sync when hasRovingTabIndex on (no-op otherwise).',
|
|
114
128
|
focusFirst: 'focus first enabled visible treeitem.',
|
|
115
129
|
focusLast: 'focus last enabled visible treeitem.',
|
|
116
130
|
},
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useTreeFocus.ts
|
|
7
|
-
* @input Uses React useCallback, useRef
|
|
7
|
+
* @input Uses React useCallback, useRef, useIsomorphicLayoutEffect
|
|
8
8
|
* @output Exports useTreeFocus hook for WAI-ARIA tree keyboard navigation
|
|
9
9
|
* @position Core hook; used by TreeList for roving tabindex + APG tree keyboard model
|
|
10
10
|
*
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import {useCallback, useRef} from 'react';
|
|
18
|
+
import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
|
|
18
19
|
|
|
19
20
|
/** Keys handled by the tree keyboard model (used to gate typeahead). */
|
|
20
21
|
const NAVIGATION_KEYS = new Set([
|
|
@@ -109,7 +110,10 @@ export interface UseTreeFocusOptions {
|
|
|
109
110
|
* @param item The focused treeitem element.
|
|
110
111
|
* @param id The treeitem's id (per `getItemId`), if any.
|
|
111
112
|
*/
|
|
112
|
-
onActivate?: (
|
|
113
|
+
onActivate?: (
|
|
114
|
+
item: HTMLElement,
|
|
115
|
+
id: string | undefined,
|
|
116
|
+
) => boolean | undefined;
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
119
|
* Whether typeahead (jump to next item whose text starts with the typed
|
|
@@ -126,6 +130,25 @@ export interface UseTreeFocusOptions {
|
|
|
126
130
|
* TreeList uses this to move its single roving tab stop.
|
|
127
131
|
*/
|
|
128
132
|
onActiveChange?: (id: string | undefined) => void;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Roving-tabindex ownership. When true, the hook manages a single tab stop
|
|
136
|
+
* across the visible treeitems: exactly one enabled treeitem carries
|
|
137
|
+
* `tabindex="0"` and the rest `tabindex="-1"`. The tab stop is repaired on
|
|
138
|
+
* mount and whenever items mount/unmount or toggle disabled, and moves with
|
|
139
|
+
* keyboard navigation. Attach the returned {@link UseTreeFocusReturn.handleFocus}
|
|
140
|
+
* to the container's `onFocus` to keep the stop in sync after clicks or
|
|
141
|
+
* programmatic focus.
|
|
142
|
+
*
|
|
143
|
+
* On mount the hook preserves an existing `tabindex="0"` treeitem (so a
|
|
144
|
+
* consumer can seed the active item in its render); if none exists it
|
|
145
|
+
* promotes the first enabled treeitem.
|
|
146
|
+
*
|
|
147
|
+
* When false (the default), the hook only *moves* focus (`.focus()`) and
|
|
148
|
+
* never touches `tabindex` — the caller owns tab-stop management.
|
|
149
|
+
* @default false
|
|
150
|
+
*/
|
|
151
|
+
hasRovingTabIndex?: boolean;
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
/**
|
|
@@ -138,6 +161,13 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
|
|
|
138
161
|
/** Key down handler to attach to the tree container. */
|
|
139
162
|
handleKeyDown: (e: React.KeyboardEvent) => void;
|
|
140
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Focus handler to attach to the container's `onFocus`. Keeps the roving tab
|
|
166
|
+
* stop in sync when `hasRovingTabIndex` is enabled; a no-op otherwise, so it
|
|
167
|
+
* is always safe to attach.
|
|
168
|
+
*/
|
|
169
|
+
handleFocus: (e: React.FocusEvent) => void;
|
|
170
|
+
|
|
141
171
|
/** Focus the first enabled visible treeitem. */
|
|
142
172
|
focusFirst: () => void;
|
|
143
173
|
|
|
@@ -167,12 +197,12 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
|
|
|
167
197
|
*
|
|
168
198
|
* @example
|
|
169
199
|
* ```
|
|
170
|
-
* const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
|
|
200
|
+
* const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
|
|
171
201
|
* onToggleExpand: id => toggle(id),
|
|
172
|
-
*
|
|
202
|
+
* hasRovingTabIndex: true,
|
|
173
203
|
* });
|
|
174
204
|
*
|
|
175
|
-
* <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown}>
|
|
205
|
+
* <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown} onFocus={handleFocus}>
|
|
176
206
|
* {items.map(item => <li role="treeitem" tabIndex={-1}>{item.label}</li>)}
|
|
177
207
|
* </ul>
|
|
178
208
|
* ```
|
|
@@ -192,6 +222,7 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
|
|
|
192
222
|
typeahead = true,
|
|
193
223
|
typeaheadResetMs = DEFAULT_TYPEAHEAD_RESET_MS,
|
|
194
224
|
onActiveChange,
|
|
225
|
+
hasRovingTabIndex = false,
|
|
195
226
|
} = options;
|
|
196
227
|
|
|
197
228
|
const treeRef = useRef<T>(null);
|
|
@@ -245,16 +276,68 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
|
|
|
245
276
|
[getItemId],
|
|
246
277
|
);
|
|
247
278
|
|
|
279
|
+
// --- Roving tabindex ownership (opt-in via `hasRovingTabIndex`) -------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Set `tabindex` on a treeitem, but only when it differs (avoids redundant
|
|
283
|
+
* DOM writes).
|
|
284
|
+
*/
|
|
285
|
+
const setTabIndex = useCallback((el: HTMLElement, value: 0 | -1) => {
|
|
286
|
+
if (el.getAttribute('tabindex') !== String(value)) {
|
|
287
|
+
el.setAttribute('tabindex', String(value));
|
|
288
|
+
}
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Make `target` the sole tabbable treeitem: 0 on it, -1 on every other
|
|
293
|
+
* visible treeitem.
|
|
294
|
+
*/
|
|
295
|
+
const moveTabStop = useCallback(
|
|
296
|
+
(items: HTMLElement[], target: HTMLElement) => {
|
|
297
|
+
for (const el of items) {
|
|
298
|
+
setTabIndex(el, el === target ? 0 : -1);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
[setTabIndex],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Repair the roving tab stop: exactly one enabled treeitem is tabbable (0),
|
|
306
|
+
* the rest are -1. Prefer an existing `tabindex="0"` treeitem (so a consumer
|
|
307
|
+
* can seed the active item in its render); otherwise promote the first
|
|
308
|
+
* enabled treeitem.
|
|
309
|
+
*/
|
|
310
|
+
const syncTabStops = useCallback(() => {
|
|
311
|
+
const items = getItems();
|
|
312
|
+
const enabled = items.filter(el => !itemDisabled(el));
|
|
313
|
+
if (enabled.length === 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const current = enabled.find(el => el.getAttribute('tabindex') === '0');
|
|
317
|
+
moveTabStop(items, current ?? enabled[0]);
|
|
318
|
+
}, [getItems, itemDisabled, moveTabStop]);
|
|
319
|
+
|
|
320
|
+
// Keep the tab stop valid across renders (items added/removed, disabled
|
|
321
|
+
// toggled). Runs after every commit but only when roving tabindex is on.
|
|
322
|
+
useIsomorphicLayoutEffect(() => {
|
|
323
|
+
if (hasRovingTabIndex) {
|
|
324
|
+
syncTabStops();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
248
328
|
/** Move focus to a treeitem and notify the active-change listener. */
|
|
249
329
|
const focusItem = useCallback(
|
|
250
330
|
(el: HTMLElement | undefined) => {
|
|
251
331
|
if (el == null) {
|
|
252
332
|
return;
|
|
253
333
|
}
|
|
334
|
+
if (hasRovingTabIndex) {
|
|
335
|
+
moveTabStop(getItems(), el);
|
|
336
|
+
}
|
|
254
337
|
onActiveChange?.(idOf(el));
|
|
255
338
|
el.focus();
|
|
256
339
|
},
|
|
257
|
-
[idOf, onActiveChange],
|
|
340
|
+
[idOf, onActiveChange, hasRovingTabIndex, moveTabStop, getItems],
|
|
258
341
|
);
|
|
259
342
|
|
|
260
343
|
/**
|
|
@@ -455,9 +538,21 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
|
|
|
455
538
|
],
|
|
456
539
|
);
|
|
457
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Keep the roving stop pointing at whatever ended up focused (e.g. a click
|
|
543
|
+
* or programmatic focus) so the next Tab behaves correctly. No-op unless
|
|
544
|
+
* roving tabindex is enabled.
|
|
545
|
+
*/
|
|
546
|
+
const handleFocus = useCallback(() => {
|
|
547
|
+
if (hasRovingTabIndex) {
|
|
548
|
+
syncTabStops();
|
|
549
|
+
}
|
|
550
|
+
}, [hasRovingTabIndex, syncTabStops]);
|
|
551
|
+
|
|
458
552
|
return {
|
|
459
553
|
treeRef,
|
|
460
554
|
handleKeyDown,
|
|
555
|
+
handleFocus,
|
|
461
556
|
focusFirst,
|
|
462
557
|
focusLast,
|
|
463
558
|
};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { ISODateString } from '../../utils/dateTypes';
|
|
2
|
-
import { type PlainDate } from '../../utils/plainDate';
|
|
3
|
-
import type { CalendarDay } from './useCalendarDays';
|
|
4
|
-
/**
|
|
5
|
-
* Configuration for calendar roving tabindex
|
|
6
|
-
*/
|
|
7
|
-
export interface UseCalendarRovingTabindexOptions {
|
|
8
|
-
/** All days in the grid */
|
|
9
|
-
days: CalendarDay[];
|
|
10
|
-
/** Today's date as a PlainDate */
|
|
11
|
-
today: PlainDate;
|
|
12
|
-
/** The year being displayed */
|
|
13
|
-
year: number;
|
|
14
|
-
/** The month being displayed (1-based: 1 = January, 12 = December) */
|
|
15
|
-
month: number;
|
|
16
|
-
/** Function to check if a date is disabled */
|
|
17
|
-
isDateDisabled: (date: PlainDate) => boolean;
|
|
18
|
-
/** Currently selected date (if any) */
|
|
19
|
-
selectedDate?: PlainDate | null;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Return type for useCalendarRovingTabindex hook
|
|
23
|
-
*/
|
|
24
|
-
export interface UseCalendarRovingTabindexReturn {
|
|
25
|
-
/** The ISO date string of the tabbable element, or null */
|
|
26
|
-
tabbableDate: ISODateString | null;
|
|
27
|
-
/** Check if a specific date is the tabbable one */
|
|
28
|
-
isTabbable: (iso: ISODateString) => boolean;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Hook for managing roving tabindex in calendar grids.
|
|
32
|
-
*
|
|
33
|
-
* Implements the WAI-ARIA roving tabindex pattern where only one element
|
|
34
|
-
* in a group has tabindex="0" while others have tabindex="-1".
|
|
35
|
-
*
|
|
36
|
-
* Priority for tabbable element:
|
|
37
|
-
* 1. Today if visible in the current month and enabled
|
|
38
|
-
* 2. First enabled day in the month
|
|
39
|
-
*
|
|
40
|
-
* @example
|
|
41
|
-
* ```
|
|
42
|
-
* const {tabbableDate, isTabbable} = useCalendarRovingTabindex({
|
|
43
|
-
* days,
|
|
44
|
-
* today: {year: 2026, month: 1, day: 20},
|
|
45
|
-
* year: 2026,
|
|
46
|
-
* month: 1, // January (1-based)
|
|
47
|
-
* isDateDisabled,
|
|
48
|
-
* });
|
|
49
|
-
*
|
|
50
|
-
* // In day rendering:
|
|
51
|
-
* <button tabIndex={isTabbable(day.iso) ? 0 : -1}>
|
|
52
|
-
* {day.dayNumber}
|
|
53
|
-
* </button>
|
|
54
|
-
* ```
|
|
55
|
-
*/
|
|
56
|
-
export declare function useCalendarRovingTabindex(options: UseCalendarRovingTabindexOptions): UseCalendarRovingTabindexReturn;
|
|
57
|
-
//# sourceMappingURL=useCalendarRovingTabindex.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"useCalendarRovingTabindex.d.ts","sourceRoot":"","sources":["../../../src/Calendar/hooks/useCalendarRovingTabindex.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAC,KAAK,SAAS,EAAiB,MAAM,uBAAuB,CAAC;AACrE,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,gCAAgC;IAC/C,2BAA2B;IAC3B,IAAI,EAAE,WAAW,EAAE,CAAC;IACpB,kCAAkC;IAClC,KAAK,EAAE,SAAS,CAAC;IACjB,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,cAAc,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC;IAC7C,uCAAuC;IACvC,YAAY,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,WAAW,+BAA+B;IAC9C,2DAA2D;IAC3D,YAAY,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC,mDAAmD;IACnD,UAAU,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,OAAO,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,gCAAgC,GACxC,+BAA+B,CA0CjC"}
|