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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +1 -1
  2. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  3. package/dist/Breadcrumbs/BreadcrumbItem.js +4 -1
  4. package/dist/Button/Button.d.ts.map +1 -1
  5. package/dist/Button/Button.js +2 -4
  6. package/dist/Calendar/Calendar.d.ts.map +1 -1
  7. package/dist/Calendar/Calendar.js +33 -14
  8. package/dist/Calendar/hooks/index.d.ts +0 -2
  9. package/dist/Calendar/hooks/index.d.ts.map +1 -1
  10. package/dist/Calendar/hooks/index.js +1 -2
  11. package/dist/Calendar/index.d.ts +2 -2
  12. package/dist/Calendar/index.d.ts.map +1 -1
  13. package/dist/Calendar/index.js +1 -1
  14. package/dist/CheckboxList/CheckboxList.js +3 -3
  15. package/dist/Citation/Citation.d.ts.map +1 -1
  16. package/dist/Citation/Citation.js +3 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts +1 -7
  18. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  19. package/dist/ContextMenu/ContextMenu.js +7 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -8
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +4 -9
  23. package/dist/Field/Field.d.ts +4 -4
  24. package/dist/Field/Field.d.ts.map +1 -1
  25. package/dist/Field/Field.js +2 -2
  26. package/dist/Field/FieldLabel.d.ts +4 -4
  27. package/dist/Field/FieldLabel.d.ts.map +1 -1
  28. package/dist/Field/FieldLabel.js +2 -2
  29. package/dist/InputGroup/InputGroup.js +3 -3
  30. package/dist/Link/Link.d.ts.map +1 -1
  31. package/dist/Link/Link.js +2 -4
  32. package/dist/MoreMenu/MoreMenu.d.ts +1 -7
  33. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  34. package/dist/MoreMenu/MoreMenu.js +0 -2
  35. package/dist/MultiSelector/MultiSelector.d.ts +25 -2
  36. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  37. package/dist/MultiSelector/MultiSelector.js +31 -6
  38. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  39. package/dist/ProgressBar/ProgressBar.js +2 -4
  40. package/dist/RadioList/RadioList.js +3 -3
  41. package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
  42. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  43. package/dist/SegmentedControl/SegmentedControl.js +51 -61
  44. package/dist/Selector/Selector.d.ts +22 -1
  45. package/dist/Selector/Selector.d.ts.map +1 -1
  46. package/dist/Selector/Selector.js +31 -6
  47. package/dist/Switch/Switch.d.ts.map +1 -1
  48. package/dist/Switch/Switch.js +2 -4
  49. package/dist/TabList/TabList.d.ts +3 -2
  50. package/dist/TabList/TabList.d.ts.map +1 -1
  51. package/dist/TabList/TabList.js +55 -33
  52. package/dist/Table/tableContextMenu.d.ts.map +1 -1
  53. package/dist/Table/tableContextMenu.js +0 -3
  54. package/dist/TextArea/TextArea.d.ts.map +1 -1
  55. package/dist/TextArea/TextArea.js +2 -4
  56. package/dist/Toolbar/Toolbar.d.ts +3 -3
  57. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  58. package/dist/Toolbar/Toolbar.js +46 -6
  59. package/dist/TreeList/TreeList.d.ts.map +1 -1
  60. package/dist/TreeList/TreeList.js +16 -22
  61. package/dist/TreeList/TreeListItem.d.ts +3 -2
  62. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  63. package/dist/astryx.css +3 -0
  64. package/dist/astryx.umd.js +47 -47
  65. package/dist/astryx.umd.js.map +4 -4
  66. package/dist/hooks/index.d.ts +4 -2
  67. package/dist/hooks/index.d.ts.map +1 -1
  68. package/dist/hooks/index.js +1 -0
  69. package/dist/hooks/useGridFocus.d.ts +56 -0
  70. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  71. package/dist/hooks/useGridFocus.js +138 -24
  72. package/dist/hooks/useKeyboardHint.d.ts +82 -0
  73. package/dist/hooks/useKeyboardHint.d.ts.map +1 -0
  74. package/dist/hooks/useKeyboardHint.js +220 -0
  75. package/dist/hooks/useTreeFocus.d.ts +27 -3
  76. package/dist/hooks/useTreeFocus.d.ts.map +1 -1
  77. package/dist/hooks/useTreeFocus.js +69 -6
  78. package/package.json +1 -1
  79. package/src/Breadcrumbs/BreadcrumbItem.tsx +5 -2
  80. package/src/Button/Button.tsx +3 -16
  81. package/src/Calendar/Calendar.tsx +52 -22
  82. package/src/Calendar/hooks/index.ts +0 -6
  83. package/src/Calendar/index.ts +0 -3
  84. package/src/CheckboxList/CheckboxList.tsx +3 -3
  85. package/src/Citation/Citation.doc.mjs +10 -0
  86. package/src/Citation/Citation.test.tsx +117 -0
  87. package/src/Citation/Citation.tsx +9 -1
  88. package/src/ContextMenu/ContextMenu.doc.mjs +0 -6
  89. package/src/ContextMenu/ContextMenu.test.tsx +4 -6
  90. package/src/ContextMenu/ContextMenu.tsx +7 -19
  91. package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -8
  92. package/src/DropdownMenu/DropdownMenu.test.tsx +0 -23
  93. package/src/DropdownMenu/DropdownMenu.tsx +4 -16
  94. package/src/Field/Field.test.tsx +2 -2
  95. package/src/Field/Field.tsx +5 -5
  96. package/src/Field/FieldLabel.tsx +5 -5
  97. package/src/InputGroup/InputGroup.tsx +3 -3
  98. package/src/Link/Link.tsx +2 -13
  99. package/src/MoreMenu/MoreMenu.doc.mjs +2 -17
  100. package/src/MoreMenu/MoreMenu.tsx +0 -9
  101. package/src/MultiSelector/MultiSelector.doc.mjs +25 -0
  102. package/src/MultiSelector/MultiSelector.test.tsx +150 -1
  103. package/src/MultiSelector/MultiSelector.tsx +55 -3
  104. package/src/ProgressBar/ProgressBar.tsx +2 -3
  105. package/src/RadioList/RadioList.tsx +3 -3
  106. package/src/SegmentedControl/SegmentedControl.tsx +51 -77
  107. package/src/Selector/Selector.doc.mjs +21 -0
  108. package/src/Selector/Selector.test.tsx +123 -1
  109. package/src/Selector/Selector.tsx +53 -3
  110. package/src/Switch/Switch.tsx +2 -16
  111. package/src/TabList/TabList.test.tsx +41 -0
  112. package/src/TabList/TabList.tsx +66 -39
  113. package/src/Table/tableContextMenu.tsx +1 -5
  114. package/src/TextArea/TextArea.tsx +3 -13
  115. package/src/Toolbar/Toolbar.test.tsx +64 -6
  116. package/src/Toolbar/Toolbar.tsx +55 -4
  117. package/src/TreeList/TreeList.tsx +17 -28
  118. package/src/TreeList/TreeListItem.tsx +3 -2
  119. package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
  120. package/src/hooks/index.ts +12 -5
  121. package/src/hooks/useGridFocus.doc.mjs +40 -2
  122. package/src/hooks/useGridFocus.test.tsx +132 -0
  123. package/src/hooks/useGridFocus.ts +179 -23
  124. package/src/hooks/useKeyboardHint.doc.mjs +101 -0
  125. package/src/hooks/useKeyboardHint.test.tsx +70 -0
  126. package/src/hooks/useKeyboardHint.tsx +332 -0
  127. package/src/hooks/useTreeFocus.doc.mjs +15 -1
  128. package/src/hooks/useTreeFocus.ts +101 -6
  129. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +0 -57
  130. package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +0 -1
  131. package/dist/Calendar/hooks/useCalendarRovingTabindex.js +0 -96
  132. package/src/Calendar/hooks/useCalendarRovingTabindex.ts +0 -118
@@ -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) — those traversals differ from a tree.' },
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?: (item: HTMLElement, id: string | undefined) => boolean | undefined;
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
- * onActiveChange: id => setActiveId(id),
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"}