@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,220 @@
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
+ import React, { useCallback, useEffect, useRef } from 'react';
19
+ import * as stylex from '@stylexjs/stylex';
20
+ import "../theme/tokens.stylex.js";
21
+ import { colorVars, radiusVars, shadowVars, spacingVars, typeScaleVars } from "../theme/tokens.stylex.js";
22
+ import { Kbd } from "../Kbd/index.js";
23
+ import { useLayer } from "../Layer/useLayer.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
29
+ // ---------------------------------------------------------------------------
30
+ // Styles
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
34
+ const ARROW_HINT_KEYS = {
35
+ horizontal: ['left', 'right'],
36
+ vertical: ['up', 'down'],
37
+ both: ['left', 'right', 'up', 'down']
38
+ };
39
+ const styles = {
40
+ hint: {
41
+ kVAEAm: "xixxii4",
42
+ kpwlN0: "x1anvkwx",
43
+ kogj98: "x1ghz6dp",
44
+ kWkggS: "x1prclbq",
45
+ kaIpWk: "xh6dtrn",
46
+ kGVxlE: "x1i5ehqx",
47
+ kLKAdn: "xfsso4q",
48
+ kGO01o: "xy143xn",
49
+ kZCmMZ: "x12gdq22",
50
+ kwRFfy: "x1djylfy",
51
+ kGuDYH: "x141an7d",
52
+ kLWn49: "x1ltkj2j",
53
+ kMwMTN: "xv1l7n4",
54
+ khDVqt: "xuxw1ft",
55
+ kSiTet: "xg01cxk xofkqq2",
56
+ k1ekBW: "xsm4mo9",
57
+ kIyJzY: "xx6bhzk",
58
+ kzIqYQ: "xd00j3c",
59
+ kfzvcC: "x47corl",
60
+ $$css: true
61
+ }
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Hook
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Shows an ephemeral visual hint ("← → to navigate") anchored to the focused
70
+ * item when a composite widget first receives keyboard focus. Teaches sighted
71
+ * keyboard users that arrows navigate within the group.
72
+ *
73
+ * The hint renders in the top layer (popover="manual") and is CSS-anchor-
74
+ * positioned to the currently focused element, so it is never clipped by
75
+ * overflow containers. It auto-dismisses on first arrow press, timeout, or
76
+ * blur, and does not re-show for that instance.
77
+ *
78
+ * @example
79
+ * ```
80
+ * const hint = useKeyboardHint({orientation: 'horizontal'});
81
+ * <div role="toolbar" onFocus={hint.onFocus} onBlur={hint.onBlur} onKeyDown={hint.onKeyDown}>
82
+ * {children}
83
+ * {hint.hintElement}
84
+ * </div>
85
+ * ```
86
+ */
87
+ export function useKeyboardHint(options = {}) {
88
+ const {
89
+ orientation = 'horizontal',
90
+ dismissAfterMs = 3000,
91
+ isEnabled = true
92
+ } = options;
93
+ const timeoutRef = useRef(null);
94
+ const dismissedRef = useRef(false);
95
+ const isVisibleRef = useRef(false);
96
+ const layerAnchorRef = useRef(() => {});
97
+ const clearDismissTimeout = useCallback(() => {
98
+ if (timeoutRef.current) {
99
+ clearTimeout(timeoutRef.current);
100
+ timeoutRef.current = null;
101
+ }
102
+ }, []);
103
+ const handleLayerShow = useCallback(() => {
104
+ isVisibleRef.current = true;
105
+ }, []);
106
+ const handleLayerHide = useCallback(() => {
107
+ isVisibleRef.current = false;
108
+ clearDismissTimeout();
109
+ layerAnchorRef.current(null);
110
+ }, [clearDismissTimeout]);
111
+ const layer = useLayer({
112
+ mode: 'context',
113
+ onShow: handleLayerShow,
114
+ onHide: handleLayerHide
115
+ });
116
+ layerAnchorRef.current = layer.ref;
117
+
118
+ // Hide + mark dismissed (won't re-show for this instance)
119
+ const dismiss = useCallback(() => {
120
+ dismissedRef.current = true;
121
+ clearDismissTimeout();
122
+ layer.hide();
123
+ isVisibleRef.current = false;
124
+ layerAnchorRef.current(null);
125
+ }, [clearDismissTimeout, layer]);
126
+
127
+ // Show the layer anchored to the focused element
128
+ const show = useCallback(anchor => {
129
+ if (dismissedRef.current || !isEnabled) {
130
+ return;
131
+ }
132
+ layerAnchorRef.current(anchor);
133
+ layer.show();
134
+ clearDismissTimeout();
135
+ timeoutRef.current = setTimeout(() => {
136
+ dismiss();
137
+ }, dismissAfterMs);
138
+ }, [clearDismissTimeout, dismiss, dismissAfterMs, isEnabled, layer]);
139
+
140
+ // Cleanup on unmount
141
+ useEffect(() => () => {
142
+ clearDismissTimeout();
143
+ layerAnchorRef.current(null);
144
+ }, [clearDismissTimeout]);
145
+
146
+ // --- Handlers ---
147
+
148
+ const onFocus = useCallback(e => {
149
+ if (dismissedRef.current || !isEnabled) {
150
+ return;
151
+ }
152
+ // Only show on keyboard focus (focus-visible)
153
+ const target = e.target;
154
+ if (!target.matches(':focus-visible')) {
155
+ return;
156
+ }
157
+ // Only show when focus enters from outside the container
158
+ const container = e.currentTarget;
159
+ if (e.relatedTarget instanceof Node && container.contains(e.relatedTarget)) {
160
+ return;
161
+ }
162
+ show(target);
163
+ }, [show, isEnabled]);
164
+ const onBlur = useCallback(e => {
165
+ if (!isVisibleRef.current) {
166
+ return;
167
+ }
168
+ const container = e.currentTarget;
169
+ // Only dismiss when focus leaves the container entirely
170
+ if (e.relatedTarget instanceof Node && container.contains(e.relatedTarget)) {
171
+ // Focus moved within — re-anchor to the new target
172
+ if (!dismissedRef.current && e.relatedTarget instanceof HTMLElement) {
173
+ layerAnchorRef.current(e.relatedTarget);
174
+ }
175
+ return;
176
+ }
177
+ dismiss();
178
+ }, [dismiss]);
179
+ const onKeyDown = useCallback(e => {
180
+ if (!isVisibleRef.current) {
181
+ return;
182
+ }
183
+ if (ARROW_KEYS.has(e.key)) {
184
+ dismiss();
185
+ }
186
+ }, [dismiss]);
187
+
188
+ // --- Render the hint element ---
189
+
190
+ const arrowContent = /*#__PURE__*/_jsx("span", {
191
+ ...{
192
+ className: "x3nfvp2 x6s0dn4 xzye2dw"
193
+ },
194
+ children: ARROW_HINT_KEYS[orientation].map(key => /*#__PURE__*/_jsx(Kbd, {
195
+ keys: key
196
+ }, key))
197
+ });
198
+ const hintElement = layer.render(/*#__PURE__*/_jsxs("span", {
199
+ "aria-hidden": "true",
200
+ children: [arrowContent, /*#__PURE__*/_jsx("span", {
201
+ ...{
202
+ className: "x11g1kdw"
203
+ },
204
+ children: "to navigate"
205
+ })]
206
+ }), {
207
+ placement: 'below',
208
+ alignment: 'start',
209
+ xstyle: styles.hint,
210
+ style: {
211
+ marginBlockStart: spacingVars['--spacing-2']
212
+ }
213
+ });
214
+ return {
215
+ hintElement,
216
+ onFocus,
217
+ onBlur,
218
+ onKeyDown
219
+ };
220
+ }
@@ -83,6 +83,24 @@ export interface UseTreeFocusOptions {
83
83
  * TreeList uses this to move its single roving tab stop.
84
84
  */
85
85
  onActiveChange?: (id: string | undefined) => void;
86
+ /**
87
+ * Roving-tabindex ownership. When true, the hook manages a single tab stop
88
+ * across the visible treeitems: exactly one enabled treeitem carries
89
+ * `tabindex="0"` and the rest `tabindex="-1"`. The tab stop is repaired on
90
+ * mount and whenever items mount/unmount or toggle disabled, and moves with
91
+ * keyboard navigation. Attach the returned {@link UseTreeFocusReturn.handleFocus}
92
+ * to the container's `onFocus` to keep the stop in sync after clicks or
93
+ * programmatic focus.
94
+ *
95
+ * On mount the hook preserves an existing `tabindex="0"` treeitem (so a
96
+ * consumer can seed the active item in its render); if none exists it
97
+ * promotes the first enabled treeitem.
98
+ *
99
+ * When false (the default), the hook only *moves* focus (`.focus()`) and
100
+ * never touches `tabindex` — the caller owns tab-stop management.
101
+ * @default false
102
+ */
103
+ hasRovingTabIndex?: boolean;
86
104
  }
87
105
  /**
88
106
  * Return type for useTreeFocus hook.
@@ -92,6 +110,12 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
92
110
  treeRef: React.RefObject<T | null>;
93
111
  /** Key down handler to attach to the tree container. */
94
112
  handleKeyDown: (e: React.KeyboardEvent) => void;
113
+ /**
114
+ * Focus handler to attach to the container's `onFocus`. Keeps the roving tab
115
+ * stop in sync when `hasRovingTabIndex` is enabled; a no-op otherwise, so it
116
+ * is always safe to attach.
117
+ */
118
+ handleFocus: (e: React.FocusEvent) => void;
95
119
  /** Focus the first enabled visible treeitem. */
96
120
  focusFirst: () => void;
97
121
  /** Focus the last enabled visible treeitem. */
@@ -119,12 +143,12 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
119
143
  *
120
144
  * @example
121
145
  * ```
122
- * const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
146
+ * const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
123
147
  * onToggleExpand: id => toggle(id),
124
- * onActiveChange: id => setActiveId(id),
148
+ * hasRovingTabIndex: true,
125
149
  * });
126
150
  *
127
- * <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown}>
151
+ * <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown} onFocus={handleFocus}>
128
152
  * {items.map(item => <li role="treeitem" tabIndex={-1}>{item.label}</li>)}
129
153
  * </ul>
130
154
  * ```
@@ -1 +1 @@
1
- {"version":3,"file":"useTreeFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeFocus.ts"],"names":[],"mappings":"AAiCA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEhD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC;IAEzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE5C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE7C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;IAEtD;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,GAAG,SAAS,CAAC;IAEhF;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;CACnD;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE,iEAAiE;IACjE,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC,wDAAwD;IACxD,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD,gDAAgD;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CA0RvB"}
1
+ {"version":3,"file":"useTreeFocus.d.ts","sourceRoot":"","sources":["../../src/hooks/useTreeFocus.ts"],"names":[],"mappings":"AAkCA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAEhD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC;IAEzC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE5C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC;IAE7C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;IAEtD;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,CACX,IAAI,EAAE,WAAW,EACjB,EAAE,EAAE,MAAM,GAAG,SAAS,KACnB,OAAO,GAAG,SAAS,CAAC;IAEzB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAElD;;;;;;;;;;;;;;;;OAgBG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IACrE,iEAAiE;IACjE,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAEnC,wDAAwD;IACxD,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,KAAK,IAAI,CAAC;IAEhD;;;;OAIG;IACH,WAAW,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IAE3C,gDAAgD;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;IAEvB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAC9D,OAAO,GAAE,mBAAwB,GAChC,kBAAkB,CAAC,CAAC,CAAC,CA2VvB"}
@@ -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
  *
@@ -14,6 +14,7 @@
14
14
  * - /packages/core/src/hooks/useTreeFocus.test.tsx
15
15
  */
16
16
  import { useCallback, useRef } from 'react';
17
+ import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js";
17
18
 
18
19
  /** Keys handled by the tree keyboard model (used to gate typeahead). */
19
20
  const NAVIGATION_KEYS = new Set(['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' ']);
@@ -58,12 +59,12 @@ const DEFAULT_TYPEAHEAD_RESET_MS = 500;
58
59
  *
59
60
  * @example
60
61
  * ```
61
- * const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
62
+ * const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
62
63
  * onToggleExpand: id => toggle(id),
63
- * onActiveChange: id => setActiveId(id),
64
+ * hasRovingTabIndex: true,
64
65
  * });
65
66
  *
66
- * <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown}>
67
+ * <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown} onFocus={handleFocus}>
67
68
  * {items.map(item => <li role="treeitem" tabIndex={-1}>{item.label}</li>)}
68
69
  * </ul>
69
70
  * ```
@@ -80,7 +81,8 @@ export function useTreeFocus(options = {}) {
80
81
  onActivate,
81
82
  typeahead = true,
82
83
  typeaheadResetMs = DEFAULT_TYPEAHEAD_RESET_MS,
83
- onActiveChange
84
+ onActiveChange,
85
+ hasRovingTabIndex = false
84
86
  } = options;
85
87
  const treeRef = useRef(null);
86
88
  const typeaheadRef = useRef({
@@ -102,14 +104,63 @@ export function useTreeFocus(options = {}) {
102
104
  const collapsedOf = useCallback(el => isCollapsed ? isCollapsed(el) : el.getAttribute('aria-expanded') === 'false', [isCollapsed]);
103
105
  const idOf = useCallback(el => getItemId ? getItemId(el) : el.dataset.treeId, [getItemId]);
104
106
 
107
+ // --- Roving tabindex ownership (opt-in via `hasRovingTabIndex`) -------------
108
+
109
+ /**
110
+ * Set `tabindex` on a treeitem, but only when it differs (avoids redundant
111
+ * DOM writes).
112
+ */
113
+ const setTabIndex = useCallback((el, value) => {
114
+ if (el.getAttribute('tabindex') !== String(value)) {
115
+ el.setAttribute('tabindex', String(value));
116
+ }
117
+ }, []);
118
+
119
+ /**
120
+ * Make `target` the sole tabbable treeitem: 0 on it, -1 on every other
121
+ * visible treeitem.
122
+ */
123
+ const moveTabStop = useCallback((items, target) => {
124
+ for (const el of items) {
125
+ setTabIndex(el, el === target ? 0 : -1);
126
+ }
127
+ }, [setTabIndex]);
128
+
129
+ /**
130
+ * Repair the roving tab stop: exactly one enabled treeitem is tabbable (0),
131
+ * the rest are -1. Prefer an existing `tabindex="0"` treeitem (so a consumer
132
+ * can seed the active item in its render); otherwise promote the first
133
+ * enabled treeitem.
134
+ */
135
+ const syncTabStops = useCallback(() => {
136
+ const items = getItems();
137
+ const enabled = items.filter(el => !itemDisabled(el));
138
+ if (enabled.length === 0) {
139
+ return;
140
+ }
141
+ const current = enabled.find(el => el.getAttribute('tabindex') === '0');
142
+ moveTabStop(items, current ?? enabled[0]);
143
+ }, [getItems, itemDisabled, moveTabStop]);
144
+
145
+ // Keep the tab stop valid across renders (items added/removed, disabled
146
+ // toggled). Runs after every commit but only when roving tabindex is on.
147
+ useIsomorphicLayoutEffect(() => {
148
+ if (hasRovingTabIndex) {
149
+ syncTabStops();
150
+ }
151
+ });
152
+
105
153
  /** Move focus to a treeitem and notify the active-change listener. */
106
154
  const focusItem = useCallback(el => {
107
155
  if (el == null) {
108
156
  return;
109
157
  }
158
+ if (hasRovingTabIndex) {
159
+ moveTabStop(getItems(), el);
160
+ }
110
161
  onActiveChange?.(idOf(el));
111
162
  el.focus();
112
- }, [idOf, onActiveChange]);
163
+ }, [idOf, onActiveChange, hasRovingTabIndex, moveTabStop, getItems]);
113
164
 
114
165
  /**
115
166
  * Focus the first enabled treeitem from `start`, moving by `dir`. No wrap —
@@ -265,9 +316,21 @@ export function useTreeFocus(options = {}) {
265
316
  break;
266
317
  }
267
318
  }, [getItems, typeahead, runTypeahead, focusEnabledFrom, focusItem, collapsedOf, expandedOf, idOf, levelOf, itemDisabled, onToggleExpand, onActivate]);
319
+
320
+ /**
321
+ * Keep the roving stop pointing at whatever ended up focused (e.g. a click
322
+ * or programmatic focus) so the next Tab behaves correctly. No-op unless
323
+ * roving tabindex is enabled.
324
+ */
325
+ const handleFocus = useCallback(() => {
326
+ if (hasRovingTabIndex) {
327
+ syncTabStops();
328
+ }
329
+ }, [hasRovingTabIndex, syncTabStops]);
268
330
  return {
269
331
  treeRef,
270
332
  handleKeyDown,
333
+ handleFocus,
271
334
  focusFirst,
272
335
  focusLast
273
336
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.1.2-canary.bfcbf64",
3
+ "version": "0.1.2-canary.c395fca",
4
4
  "displayName": "XDS Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -179,6 +179,7 @@ export function BreadcrumbItem({
179
179
  className,
180
180
  style,
181
181
  'data-testid': testId,
182
+ ...rest
182
183
  }: BreadcrumbItemProps) {
183
184
  const ctx = use(BreadcrumbContext);
184
185
  const LinkComponent = useLinkComponent(as);
@@ -252,7 +253,8 @@ export function BreadcrumbItem({
252
253
  className,
253
254
  style,
254
255
  )}
255
- data-testid={testId}>
256
+ data-testid={testId}
257
+ {...rest}>
256
258
  <span aria-hidden="true" {...stylex.props(itemStyles.separator)}>
257
259
  {ctx.separator}
258
260
  </span>
@@ -286,7 +288,8 @@ export function BreadcrumbItem({
286
288
  className,
287
289
  style,
288
290
  )}
289
- data-testid={testId}>
291
+ data-testid={testId}
292
+ {...rest}>
290
293
  <span aria-hidden="true" {...stylex.props(itemStyles.separator)}>
291
294
  {ctx.separator}
292
295
  </span>
@@ -34,6 +34,7 @@ import {
34
34
  } from '../theme/tokens.stylex';
35
35
  import {Tooltip} from '../Tooltip/Tooltip';
36
36
  import {Spinner} from '../Spinner';
37
+ import {VisuallyHidden} from '../VisuallyHidden';
37
38
 
38
39
  import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
39
40
  import {useSize} from '../SizeContext/SizeContext';
@@ -124,17 +125,6 @@ const styles = stylex.create({
124
125
  textOverflow: 'ellipsis',
125
126
  minWidth: 0,
126
127
  },
127
- visuallyHidden: {
128
- position: 'absolute',
129
- width: '1px',
130
- height: '1px',
131
- padding: 0,
132
- margin: '-1px',
133
- overflow: 'hidden',
134
- clip: 'rect(0, 0, 0, 0)',
135
- whiteSpace: 'nowrap',
136
- borderWidth: 0,
137
- },
138
128
  link: {
139
129
  textDecoration: 'none',
140
130
  },
@@ -690,12 +680,9 @@ export function Button({
690
680
  )}
691
681
  </span>
692
682
  {/* Live region for loading state announcements */}
693
- <span
694
- {...stylex.props(styles.visuallyHidden)}
695
- role="status"
696
- aria-live="polite">
683
+ <VisuallyHidden role="status" aria-live="polite">
697
684
  {isLoadingState ? 'Loading' : ''}
698
- </span>
685
+ </VisuallyHidden>
699
686
  </>
700
687
  );
701
688
 
@@ -32,7 +32,6 @@ import {useGridFocus} from '../hooks';
32
32
  import {
33
33
  useCalendarDays,
34
34
  useCalendarConstraints,
35
- useCalendarRovingTabindex,
36
35
  type CalendarDay,
37
36
  } from './hooks';
38
37
  import {
@@ -554,14 +553,34 @@ function MonthGrid({
554
553
  return null;
555
554
  }, [mode, value]);
556
555
 
557
- const {isTabbable} = useCalendarRovingTabindex({
558
- days,
559
- today,
560
- year,
561
- month: month.month,
562
- isDateDisabled,
563
- selectedDate: selectedDateForTabindex,
564
- });
556
+ // Seed the initial roving tab stop for this month. useGridFocus owns the
557
+ // live tab stop (see `hasRovingTabIndex` below) — it honors an existing
558
+ // `tabindex="0"` and repairs/moves it thereafter — so this only decides
559
+ // which day button starts tabbable. Priority: selected date (if visible and
560
+ // enabled) > today (if visible and enabled) > first enabled in-month day.
561
+ const seedTabbableIso = useMemo((): ISODateString | null => {
562
+ if (selectedDateForTabindex) {
563
+ const isSelectedInMonth =
564
+ selectedDateForTabindex.year === year &&
565
+ selectedDateForTabindex.month === month.month;
566
+ if (isSelectedInMonth && !isDateDisabled(selectedDateForTabindex)) {
567
+ return plainDateToISO(selectedDateForTabindex);
568
+ }
569
+ }
570
+
571
+ const isTodayInMonth = today.year === year && today.month === month.month;
572
+ if (isTodayInMonth && !isDateDisabled(today)) {
573
+ return plainDateToISO(today);
574
+ }
575
+
576
+ for (const day of days) {
577
+ if (!day.isOutside && !isDateDisabled(day.date)) {
578
+ return day.iso;
579
+ }
580
+ }
581
+
582
+ return null;
583
+ }, [days, today, year, month.month, isDateDisabled, selectedDateForTabindex]);
565
584
 
566
585
  // Helper to get the focused date from the currently focused element.
567
586
  // Reads the machine-readable `data-date` (ISO) attribute rather than parsing
@@ -628,18 +647,22 @@ function MonthGrid({
628
647
  // focus (the day button inside the cell). Arrow keys move to the target
629
648
  // row/column and, if that cell is disabled, continue in the same direction to
630
649
  // the next enabled cell.
631
- const {gridRef, handleKeyDown: handleGridKeyDown} =
632
- useGridFocus<HTMLDivElement>({
633
- columns: 7,
634
- cellSelector: '[role="gridcell"]',
635
- isCellFocusable: cell =>
636
- cell.querySelector('button:not([disabled])') !== null,
637
- getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
638
- onNavigateBefore: handleNavigatePrevious,
639
- onNavigateAfter: handleNavigateNext,
640
- onPageUp: handlePageUp,
641
- onPageDown: handlePageDown,
642
- });
650
+ const {
651
+ gridRef,
652
+ handleKeyDown: handleGridKeyDown,
653
+ handleFocus: handleGridFocus,
654
+ } = useGridFocus<HTMLDivElement>({
655
+ columns: 7,
656
+ cellSelector: '[role="gridcell"]',
657
+ isCellFocusable: cell =>
658
+ cell.querySelector('button:not([disabled])') !== null,
659
+ getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
660
+ hasRovingTabIndex: true,
661
+ onNavigateBefore: handleNavigatePrevious,
662
+ onNavigateAfter: handleNavigateNext,
663
+ onPageUp: handlePageUp,
664
+ onPageDown: handlePageDown,
665
+ });
643
666
 
644
667
  // Handle pending focus after month navigation
645
668
  useEffect(() => {
@@ -719,6 +742,7 @@ function MonthGrid({
719
742
  role="grid"
720
743
  aria-label={monthLabel}
721
744
  onKeyDown={handleGridKeyDown}
745
+ onFocus={handleGridFocus}
722
746
  {...stylex.props(
723
747
  monthGridStyles.daysGrid,
724
748
  hasWeekNumbers && monthGridStyles.daysGridWithNumbers,
@@ -774,7 +798,7 @@ function MonthGrid({
774
798
  today={today}
775
799
  hasOutsideDays={hasOutsideDays}
776
800
  isDisabled={isDateDisabled(day.date)}
777
- isTabbable={isTabbable(day.iso)}
801
+ isTabbable={day.iso === seedTabbableIso}
778
802
  onDayClick={onDayClick}
779
803
  onDayHover={onDayHover}
780
804
  />
@@ -803,6 +827,11 @@ interface DayCellProps {
803
827
  today: PlainDate;
804
828
  hasOutsideDays: boolean;
805
829
  isDisabled: boolean;
830
+ /**
831
+ * Whether this day seeds the initial roving tab stop. useGridFocus
832
+ * (`hasRovingTabIndex`) owns the live tab stop thereafter — it honors an
833
+ * existing `tabindex="0"` and repairs/moves it on navigation and focus.
834
+ */
806
835
  isTabbable: boolean;
807
836
  onDayClick: (date: PlainDate) => void;
808
837
  onDayHover: (date: PlainDate | null) => void;
@@ -894,6 +923,7 @@ function DayCell({
894
923
  aria-selected={state.isSelected || state.isInRange || undefined}
895
924
  aria-disabled={state.effectivelyDisabled || undefined}
896
925
  disabled={isDisabled}
926
+ // Initial roving tab-stop seed; useGridFocus owns it after mount.
897
927
  tabIndex={isTabbableDay ? 0 : -1}
898
928
  onClick={() => !state.effectivelyDisabled && onDayClick(date)}
899
929
  onMouseEnter={() => !state.effectivelyDisabled && onDayHover(date)}
@@ -27,9 +27,3 @@ export type {
27
27
  UseCalendarNavigationOptions,
28
28
  UseCalendarNavigationReturn,
29
29
  } from './useCalendarNavigation';
30
-
31
- export {useCalendarRovingTabindex} from './useCalendarRovingTabindex';
32
- export type {
33
- UseCalendarRovingTabindexOptions,
34
- UseCalendarRovingTabindexReturn,
35
- } from './useCalendarRovingTabindex';
@@ -26,7 +26,6 @@ export {
26
26
  useCalendarDays,
27
27
  useCalendarConstraints,
28
28
  useCalendarNavigation,
29
- useCalendarRovingTabindex,
30
29
  } from './hooks';
31
30
  export type {
32
31
  CalendarDay,
@@ -36,8 +35,6 @@ export type {
36
35
  UseCalendarConstraintsReturn,
37
36
  UseCalendarNavigationOptions,
38
37
  UseCalendarNavigationReturn,
39
- UseCalendarRovingTabindexOptions,
40
- UseCalendarRovingTabindexReturn,
41
38
  } from './hooks';
42
39
 
43
40
  // Re-export calendar-specific utilities for advanced usage
@@ -151,7 +151,7 @@ export function CheckboxList({
151
151
  'data-testid': dataTestId,
152
152
  }: CheckboxListProps) {
153
153
  const inputID = useId();
154
- const labelElementID = useId();
154
+ const labelID = useId();
155
155
  const descriptionID = useId();
156
156
  const statusMessageID = useId();
157
157
 
@@ -211,7 +211,7 @@ export function CheckboxList({
211
211
  isLabelHidden={isLabelHidden}
212
212
  description={description}
213
213
  inputID={inputID}
214
- labelElementID={labelElementID}
214
+ labelID={labelID}
215
215
  isGroupLabel
216
216
  descriptionID={description ? descriptionID : undefined}
217
217
  isDisabled={isDisabled}
@@ -231,7 +231,7 @@ export function CheckboxList({
231
231
  <CheckboxListContext value={contextValue}>
232
232
  <div
233
233
  role="group"
234
- aria-labelledby={labelElementID}
234
+ aria-labelledby={labelID}
235
235
  aria-describedby={
236
236
  [
237
237
  description ? descriptionID : null,
@@ -56,6 +56,16 @@ export const docs = {
56
56
  ],
57
57
  },
58
58
 
59
+ // `source` is a custom object type the docsite preview cannot generate
60
+ // automatically; without these defaults the properties tab shows the
61
+ // missing-required-props placeholder instead of an interactive preview.
62
+ playground: {
63
+ defaults: {
64
+ source: {title: 'Astryx Design', url: 'https://example.com'},
65
+ number: 1,
66
+ },
67
+ },
68
+
59
69
  props: [
60
70
  {
61
71
  name: 'source',