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

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 (223) hide show
  1. package/dist/Avatar/Avatar.d.ts.map +1 -1
  2. package/dist/Avatar/Avatar.js +11 -5
  3. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  4. package/dist/Breadcrumbs/BreadcrumbItem.js +20 -4
  5. package/dist/Calendar/Calendar.d.ts.map +1 -1
  6. package/dist/Calendar/Calendar.js +11 -11
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +4 -2
  9. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  10. package/dist/CheckboxList/CheckboxList.js +12 -4
  11. package/dist/ContextMenu/ContextMenu.d.ts +6 -1
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +58 -7
  14. package/dist/DateInput/DateInput.d.ts.map +1 -1
  15. package/dist/DateInput/DateInput.js +17 -2
  16. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  17. package/dist/DateRangeInput/DateRangeInput.js +9 -4
  18. package/dist/DateTimeInput/DateTimeInput.d.ts +7 -1
  19. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  20. package/dist/DateTimeInput/DateTimeInput.js +15 -3
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +12 -0
  23. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenu.js +25 -4
  25. package/dist/Field/Field.d.ts +19 -2
  26. package/dist/Field/Field.d.ts.map +1 -1
  27. package/dist/Field/Field.js +4 -0
  28. package/dist/Field/FieldLabel.d.ts +21 -2
  29. package/dist/Field/FieldLabel.d.ts.map +1 -1
  30. package/dist/Field/FieldLabel.js +37 -22
  31. package/dist/FileInput/FileInput.d.ts.map +1 -1
  32. package/dist/FileInput/FileInput.js +27 -6
  33. package/dist/InputGroup/InputGroup.d.ts.map +1 -1
  34. package/dist/InputGroup/InputGroup.js +4 -1
  35. package/dist/Kbd/Kbd.d.ts.map +1 -1
  36. package/dist/Kbd/Kbd.js +33 -1
  37. package/dist/Layer/useLayer.d.ts.map +1 -1
  38. package/dist/Layer/useLayer.js +36 -5
  39. package/dist/Layout/padding.stylex.d.ts +51 -0
  40. package/dist/Layout/padding.stylex.d.ts.map +1 -1
  41. package/dist/Layout/padding.stylex.js +63 -0
  42. package/dist/Link/Link.d.ts +7 -1
  43. package/dist/Link/Link.d.ts.map +1 -1
  44. package/dist/Link/Link.js +12 -4
  45. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  46. package/dist/MultiSelector/MultiSelector.js +35 -6
  47. package/dist/MultiSelector/hooks.d.ts +12 -1
  48. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  49. package/dist/MultiSelector/hooks.js +14 -1
  50. package/dist/NavMenu/NavHeadingMenu.d.ts.map +1 -1
  51. package/dist/NavMenu/NavHeadingMenu.js +17 -2
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +7 -1
  54. package/dist/Pagination/Pagination.d.ts.map +1 -1
  55. package/dist/Pagination/Pagination.js +8 -0
  56. package/dist/ProgressBar/ProgressBar.js +1 -1
  57. package/dist/RadioList/RadioList.d.ts +1 -1
  58. package/dist/RadioList/RadioList.d.ts.map +1 -1
  59. package/dist/RadioList/RadioList.js +76 -3
  60. package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
  61. package/dist/SegmentedControl/SegmentedControlItem.js +6 -2
  62. package/dist/Selector/Selector.d.ts.map +1 -1
  63. package/dist/Selector/Selector.js +15 -8
  64. package/dist/Selector/hooks.d.ts +8 -1
  65. package/dist/Selector/hooks.d.ts.map +1 -1
  66. package/dist/Selector/hooks.js +14 -1
  67. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  68. package/dist/Skeleton/Skeleton.js +8 -2
  69. package/dist/Stack/Stack.d.ts +28 -1
  70. package/dist/Stack/Stack.d.ts.map +1 -1
  71. package/dist/Stack/Stack.js +17 -1
  72. package/dist/Stack/StackItem.d.ts +12 -1
  73. package/dist/Stack/StackItem.d.ts.map +1 -1
  74. package/dist/Stack/StackItem.js +8 -1
  75. package/dist/TabList/Tab.d.ts.map +1 -1
  76. package/dist/TabList/Tab.js +6 -0
  77. package/dist/TabList/TabList.d.ts +15 -4
  78. package/dist/TabList/TabList.d.ts.map +1 -1
  79. package/dist/TabList/TabList.js +58 -4
  80. package/dist/TabList/TabListContext.d.ts +5 -0
  81. package/dist/TabList/TabListContext.d.ts.map +1 -1
  82. package/dist/TabList/TabListContext.js +5 -0
  83. package/dist/TabList/TabMenu.js +2 -0
  84. package/dist/Toast/Toast.d.ts.map +1 -1
  85. package/dist/Toast/Toast.js +14 -0
  86. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  87. package/dist/Toast/ToastViewport.js +132 -3
  88. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  89. package/dist/Toolbar/Toolbar.js +7 -3
  90. package/dist/TreeList/TreeList.d.ts +1 -1
  91. package/dist/TreeList/TreeList.d.ts.map +1 -1
  92. package/dist/TreeList/TreeList.js +78 -2
  93. package/dist/TreeList/TreeListItem.d.ts +10 -1
  94. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  95. package/dist/TreeList/TreeListItem.js +44 -10
  96. package/dist/TreeList/treeListItem.markers.stylex.d.ts +16 -0
  97. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +1 -0
  98. package/dist/TreeList/treeListItem.markers.stylex.js +22 -0
  99. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  100. package/dist/Typeahead/BaseTypeahead.js +41 -3
  101. package/dist/astryx.css +3 -0
  102. package/dist/astryx.umd.js +47 -47
  103. package/dist/astryx.umd.js.map +4 -4
  104. package/dist/hooks/index.d.ts +10 -2
  105. package/dist/hooks/index.d.ts.map +1 -1
  106. package/dist/hooks/index.js +6 -2
  107. package/dist/hooks/useAnnounce.d.ts +40 -0
  108. package/dist/hooks/useAnnounce.d.ts.map +1 -0
  109. package/dist/hooks/useAnnounce.js +142 -0
  110. package/dist/hooks/useClickableContainer.d.ts +16 -1
  111. package/dist/hooks/useClickableContainer.d.ts.map +1 -1
  112. package/dist/hooks/useClickableContainer.js +16 -4
  113. package/dist/hooks/useFocusTrap.d.ts +18 -0
  114. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  115. package/dist/hooks/useFocusTrap.js +65 -0
  116. package/dist/hooks/useListFocus.d.ts +79 -8
  117. package/dist/hooks/useListFocus.d.ts.map +1 -1
  118. package/dist/hooks/useListFocus.js +262 -48
  119. package/dist/hooks/useLongPress.d.ts +21 -0
  120. package/dist/hooks/useLongPress.d.ts.map +1 -0
  121. package/dist/hooks/useLongPress.js +89 -0
  122. package/dist/hooks/useTreeFocus.d.ts +133 -0
  123. package/dist/hooks/useTreeFocus.d.ts.map +1 -0
  124. package/dist/hooks/useTreeFocus.js +274 -0
  125. package/dist/hooks/useTypeahead.d.ts +63 -0
  126. package/dist/hooks/useTypeahead.d.ts.map +1 -0
  127. package/dist/hooks/useTypeahead.js +117 -0
  128. package/package.json +1 -1
  129. package/src/Avatar/Avatar.test.tsx +37 -0
  130. package/src/Avatar/Avatar.tsx +10 -5
  131. package/src/Breadcrumbs/BreadcrumbItem.tsx +20 -5
  132. package/src/Breadcrumbs/Breadcrumbs.test.tsx +31 -10
  133. package/src/Calendar/Calendar.test.tsx +25 -0
  134. package/src/Calendar/Calendar.tsx +10 -12
  135. package/src/CheckboxInput/CheckboxInput.test.tsx +14 -25
  136. package/src/CheckboxInput/CheckboxInput.tsx +4 -2
  137. package/src/CheckboxList/CheckboxList.test.tsx +30 -14
  138. package/src/CheckboxList/CheckboxList.tsx +18 -3
  139. package/src/ContextMenu/ContextMenu.doc.mjs +6 -0
  140. package/src/ContextMenu/ContextMenu.test.tsx +115 -1
  141. package/src/ContextMenu/ContextMenu.tsx +75 -4
  142. package/src/DateInput/DateInput.test.tsx +59 -0
  143. package/src/DateInput/DateInput.tsx +24 -7
  144. package/src/DateRangeInput/DateRangeInput.test.tsx +69 -18
  145. package/src/DateRangeInput/DateRangeInput.tsx +7 -3
  146. package/src/DateTimeInput/DateTimeInput.doc.mjs +7 -0
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +99 -6
  148. package/src/DateTimeInput/DateTimeInput.tsx +32 -3
  149. package/src/Dialog/Dialog.tsx +12 -0
  150. package/src/DropdownMenu/DropdownMenu.test.tsx +26 -4
  151. package/src/DropdownMenu/DropdownMenu.tsx +36 -2
  152. package/src/Field/Field.test.tsx +32 -0
  153. package/src/Field/Field.tsx +23 -5
  154. package/src/Field/FieldLabel.tsx +53 -17
  155. package/src/FileInput/FileInput.test.tsx +104 -49
  156. package/src/FileInput/FileInput.tsx +27 -3
  157. package/src/InputGroup/InputGroup.test.tsx +21 -63
  158. package/src/InputGroup/InputGroup.tsx +4 -1
  159. package/src/Kbd/Kbd.test.tsx +22 -4
  160. package/src/Kbd/Kbd.tsx +35 -3
  161. package/src/Layer/useLayer.tsx +52 -5
  162. package/src/Layout/padding.stylex.ts +52 -0
  163. package/src/Link/Link.doc.mjs +14 -0
  164. package/src/Link/Link.test.tsx +32 -0
  165. package/src/Link/Link.tsx +23 -1
  166. package/src/MultiSelector/MultiSelector.test.tsx +124 -2
  167. package/src/MultiSelector/MultiSelector.tsx +50 -9
  168. package/src/MultiSelector/hooks.ts +27 -0
  169. package/src/NavMenu/NavHeadingMenu.test.tsx +32 -1
  170. package/src/NavMenu/NavHeadingMenu.tsx +30 -2
  171. package/src/NumberInput/NumberInput.test.tsx +72 -38
  172. package/src/NumberInput/NumberInput.tsx +12 -1
  173. package/src/Pagination/Pagination.test.tsx +58 -5
  174. package/src/Pagination/Pagination.tsx +12 -0
  175. package/src/ProgressBar/ProgressBar.test.tsx +34 -26
  176. package/src/ProgressBar/ProgressBar.tsx +1 -1
  177. package/src/RadioList/RadioList.test.tsx +129 -14
  178. package/src/RadioList/RadioList.tsx +103 -8
  179. package/src/SegmentedControl/SegmentedControl.test.tsx +35 -0
  180. package/src/SegmentedControl/SegmentedControlItem.tsx +4 -1
  181. package/src/Selector/Selector.test.tsx +53 -0
  182. package/src/Selector/Selector.tsx +15 -8
  183. package/src/Selector/hooks.ts +23 -0
  184. package/src/Skeleton/Skeleton.test.tsx +25 -0
  185. package/src/Skeleton/Skeleton.tsx +11 -1
  186. package/src/Stack/Stack.doc.mjs +57 -0
  187. package/src/Stack/Stack.test.tsx +55 -5
  188. package/src/Stack/Stack.tsx +53 -1
  189. package/src/Stack/StackItem.test.tsx +23 -0
  190. package/src/Stack/StackItem.tsx +20 -0
  191. package/src/TabList/Tab.tsx +6 -0
  192. package/src/TabList/TabList.doc.mjs +6 -0
  193. package/src/TabList/TabList.test.tsx +167 -0
  194. package/src/TabList/TabList.tsx +72 -9
  195. package/src/TabList/TabListContext.ts +7 -3
  196. package/src/TabList/TabMenu.tsx +3 -3
  197. package/src/Toast/Toast.tsx +14 -0
  198. package/src/Toast/ToastViewport.test.tsx +204 -0
  199. package/src/Toast/ToastViewport.tsx +167 -21
  200. package/src/Toolbar/Toolbar.test.tsx +45 -0
  201. package/src/Toolbar/Toolbar.tsx +5 -2
  202. package/src/TreeList/TreeList.test.tsx +273 -0
  203. package/src/TreeList/TreeList.tsx +84 -1
  204. package/src/TreeList/TreeListItem.tsx +42 -2
  205. package/src/TreeList/treeListItem.markers.stylex.ts +20 -0
  206. package/src/Typeahead/BaseTypeahead.tsx +48 -2
  207. package/src/Typeahead/Typeahead.test.tsx +125 -0
  208. package/src/hooks/index.ts +21 -2
  209. package/src/hooks/useAnnounce.test.tsx +101 -0
  210. package/src/hooks/useAnnounce.ts +168 -0
  211. package/src/hooks/useClickableContainer.ts +16 -4
  212. package/src/hooks/useFocusTrap.test.tsx +67 -3
  213. package/src/hooks/useFocusTrap.ts +75 -0
  214. package/src/hooks/useListFocus.doc.mjs +49 -10
  215. package/src/hooks/useListFocus.test.tsx +269 -0
  216. package/src/hooks/useListFocus.ts +339 -54
  217. package/src/hooks/useLongPress.test.tsx +134 -0
  218. package/src/hooks/useLongPress.ts +119 -0
  219. package/src/hooks/useTreeFocus.doc.mjs +126 -0
  220. package/src/hooks/useTreeFocus.test.tsx +214 -0
  221. package/src/hooks/useTreeFocus.ts +464 -0
  222. package/src/hooks/useTypeahead.test.tsx +109 -0
  223. package/src/hooks/useTypeahead.ts +183 -0
@@ -122,6 +122,47 @@ describe('BaseTypeahead', () => {
122
122
  });
123
123
  });
124
124
 
125
+ it('announces the result count to a live region (comboboxes-6)', async () => {
126
+ render(
127
+ <BaseTypeahead
128
+ searchSource={fruitSource}
129
+ value={null}
130
+ onChange={() => {}}
131
+ debounceMs={0}
132
+ />,
133
+ );
134
+ const input = screen.getByRole('combobox');
135
+ fireEvent.change(input, {target: {value: 'Ap'}});
136
+
137
+ await waitFor(() => {
138
+ const region = document.querySelector(
139
+ '[data-astryx-live-region="polite"]',
140
+ );
141
+ expect(region?.textContent).toMatch(/\d+ results?/);
142
+ });
143
+ });
144
+
145
+ it('announces "no results found" when the search is empty (comboboxes-6)', async () => {
146
+ render(
147
+ <BaseTypeahead
148
+ searchSource={fruitSource}
149
+ value={null}
150
+ onChange={() => {}}
151
+ debounceMs={0}
152
+ emptySearchResultsText="No results found"
153
+ />,
154
+ );
155
+ const input = screen.getByRole('combobox');
156
+ fireEvent.change(input, {target: {value: 'zzzzz'}});
157
+
158
+ await waitFor(() => {
159
+ const region = document.querySelector(
160
+ '[data-astryx-live-region="polite"]',
161
+ );
162
+ expect(region).toHaveTextContent('No results found');
163
+ });
164
+ });
165
+
125
166
  it('disables input when isDisabled', () => {
126
167
  render(
127
168
  <BaseTypeahead
@@ -180,6 +221,90 @@ describe('BaseTypeahead', () => {
180
221
  });
181
222
  });
182
223
 
224
+ describe('BaseTypeahead focus-out', () => {
225
+ it('closes the dropdown when focus leaves the input', async () => {
226
+ render(
227
+ <>
228
+ <BaseTypeahead
229
+ searchSource={fruitSource}
230
+ value={null}
231
+ onChange={() => {}}
232
+ debounceMs={0}
233
+ />
234
+ <button type="button">Outside</button>
235
+ </>,
236
+ );
237
+ const input = screen.getByRole('combobox');
238
+ fireEvent.change(input, {target: {value: 'App'}});
239
+
240
+ await waitFor(() => {
241
+ expect(input).toHaveAttribute('aria-expanded', 'true');
242
+ });
243
+
244
+ // Focus moves to an element outside the field/dropdown → menu closes.
245
+ const outside = screen.getByRole('button', {name: 'Outside'});
246
+ fireEvent.blur(input, {relatedTarget: outside});
247
+
248
+ await waitFor(() => {
249
+ expect(input).toHaveAttribute('aria-expanded', 'false');
250
+ });
251
+ });
252
+
253
+ it('keeps the dropdown open when focus moves into the anchor wrapper', async () => {
254
+ const anchor = document.createElement('div');
255
+ document.body.appendChild(anchor);
256
+ const anchorRef = {current: anchor};
257
+ render(
258
+ <BaseTypeahead
259
+ searchSource={fruitSource}
260
+ value={null}
261
+ onChange={() => {}}
262
+ anchorRef={anchorRef}
263
+ debounceMs={0}
264
+ />,
265
+ );
266
+ const input = screen.getByRole('combobox');
267
+ // The input lives inside the wrapper we hand to anchorRef.
268
+ anchor.appendChild(input.closest('div') ?? input);
269
+ fireEvent.change(input, {target: {value: 'App'}});
270
+
271
+ await waitFor(() => {
272
+ expect(input).toHaveAttribute('aria-expanded', 'true');
273
+ });
274
+
275
+ // A sibling control inside the field (e.g. a clear button) receives focus.
276
+ const sibling = document.createElement('button');
277
+ anchor.appendChild(sibling);
278
+ fireEvent.blur(input, {relatedTarget: sibling});
279
+
280
+ // Menu stays open because focus is still within the field.
281
+ expect(input).toHaveAttribute('aria-expanded', 'true');
282
+ document.body.removeChild(anchor);
283
+ });
284
+
285
+ it('does not close when a dropdown option receives focus', async () => {
286
+ render(
287
+ <BaseTypeahead
288
+ searchSource={fruitSource}
289
+ value={null}
290
+ onChange={() => {}}
291
+ debounceMs={0}
292
+ />,
293
+ );
294
+ const input = screen.getByRole('combobox');
295
+ fireEvent.change(input, {target: {value: 'App'}});
296
+
297
+ await waitFor(() => {
298
+ expect(input).toHaveAttribute('aria-expanded', 'true');
299
+ });
300
+
301
+ const option = screen.getByRole('option', {hidden: true});
302
+ fireEvent.blur(input, {relatedTarget: option});
303
+
304
+ expect(input).toHaveAttribute('aria-expanded', 'true');
305
+ });
306
+ });
307
+
183
308
  describe('Typeahead', () => {
184
309
  it('renders with label', () => {
185
310
  render(
@@ -14,11 +14,24 @@
14
14
  export {useFocusTrap} from './useFocusTrap';
15
15
  export type {UseFocusTrapOptions, UseFocusTrapReturn} from './useFocusTrap';
16
16
 
17
+ export {useAnnounce} from './useAnnounce';
18
+ export type {AnnounceFn, AnnouncePoliteness} from './useAnnounce';
19
+
17
20
  export {useGridFocus} from './useGridFocus';
18
21
  export type {UseGridFocusOptions, UseGridFocusReturn} from './useGridFocus';
19
22
 
20
23
  export {useListFocus} from './useListFocus';
21
- export type {UseListFocusOptions, UseListFocusReturn} from './useListFocus';
24
+ export type {
25
+ UseListFocusOptions,
26
+ UseListFocusReturn,
27
+ ListFocusOrientation,
28
+ } from './useListFocus';
29
+
30
+ export {useTreeFocus} from './useTreeFocus';
31
+ export type {UseTreeFocusOptions, UseTreeFocusReturn} from './useTreeFocus';
32
+
33
+ export {useTypeahead} from './useTypeahead';
34
+ export type {UseTypeaheadOptions, UseTypeaheadReturn} from './useTypeahead';
22
35
 
23
36
  export {useMediaQuery} from './useMediaQuery';
24
37
 
@@ -41,7 +54,7 @@ export type {
41
54
  export {useImageMode} from './useImageMode';
42
55
  export type {ImageSampleRegion, UseImageModeOptions} from './useImageMode';
43
56
 
44
- export {useClickableContainer} from './useClickableContainer';
57
+ export {useClickableContainer, INTERACTIVE_SELECTORS} from './useClickableContainer';
45
58
  export type {
46
59
  UseClickableContainerOptions,
47
60
  ClickableContainerResult,
@@ -55,3 +68,9 @@ export type {
55
68
  InteractiveRole,
56
69
  UseInteractiveRoleOptions,
57
70
  } from './useInteractiveRole';
71
+
72
+ export {useLongPress} from './useLongPress';
73
+ export type {
74
+ UseLongPressOptions,
75
+ UseLongPressHandlers,
76
+ } from './useLongPress';
@@ -0,0 +1,101 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useAnnounce.test.tsx
5
+ * @input Uses vitest, @testing-library/react, useAnnounce hook
6
+ * @output Unit tests for useAnnounce live-region announcements
7
+ * @position Testing; validates useAnnounce.ts
8
+ *
9
+ * SYNC: When useAnnounce.ts changes, update tests to match new behavior
10
+ */
11
+
12
+ import {describe, it, expect, afterEach} from 'vitest';
13
+ import {renderHook, waitFor} from '@testing-library/react';
14
+ import {useAnnounce, __resetLiveRegionsForTest} from './useAnnounce';
15
+
16
+ afterEach(() => {
17
+ __resetLiveRegionsForTest();
18
+ });
19
+
20
+ function politeRegion(): HTMLElement | null {
21
+ return document.querySelector('[data-astryx-live-region="polite"]');
22
+ }
23
+ function assertiveRegion(): HTMLElement | null {
24
+ return document.querySelector('[data-astryx-live-region="assertive"]');
25
+ }
26
+
27
+ describe('useAnnounce', () => {
28
+ it('mounts empty polite and assertive live regions on first announce', async () => {
29
+ const {result} = renderHook(() => useAnnounce());
30
+ // Regions do not exist until first use.
31
+ expect(politeRegion()).toBeNull();
32
+
33
+ result.current('Saved');
34
+
35
+ // Both regions exist after the first announce, before content settles.
36
+ expect(politeRegion()).not.toBeNull();
37
+ expect(assertiveRegion()).not.toBeNull();
38
+ expect(politeRegion()).toHaveAttribute('aria-live', 'polite');
39
+ expect(politeRegion()).toHaveAttribute('aria-atomic', 'true');
40
+ expect(politeRegion()).toHaveAttribute('role', 'status');
41
+ expect(assertiveRegion()).toHaveAttribute('aria-live', 'assertive');
42
+ expect(assertiveRegion()).toHaveAttribute('role', 'alert');
43
+ });
44
+
45
+ it('announces a polite message into the polite region', async () => {
46
+ const {result} = renderHook(() => useAnnounce());
47
+ result.current('12 results');
48
+ await waitFor(() => {
49
+ expect(politeRegion()).toHaveTextContent('12 results');
50
+ });
51
+ // Assertive region stays empty.
52
+ expect(assertiveRegion()).toHaveTextContent('');
53
+ });
54
+
55
+ it('routes assertive messages to the assertive region', async () => {
56
+ const {result} = renderHook(() => useAnnounce());
57
+ result.current('Upload failed', 'assertive');
58
+ await waitFor(() => {
59
+ expect(assertiveRegion()).toHaveTextContent('Upload failed');
60
+ });
61
+ expect(politeRegion()).toHaveTextContent('');
62
+ });
63
+
64
+ it('re-announces an identical message (clears then re-sets)', async () => {
65
+ const {result} = renderHook(() => useAnnounce());
66
+ result.current('No results found');
67
+ await waitFor(() => {
68
+ expect(politeRegion()).toHaveTextContent('No results found');
69
+ });
70
+ // Second identical announce should still land (region is cleared first).
71
+ result.current('No results found');
72
+ expect(politeRegion()).toHaveTextContent('');
73
+ await waitFor(() => {
74
+ expect(politeRegion()).toHaveTextContent('No results found');
75
+ });
76
+ });
77
+
78
+ it('ignores empty messages', () => {
79
+ const {result} = renderHook(() => useAnnounce());
80
+ result.current('');
81
+ // No regions created for an empty announce.
82
+ expect(politeRegion()).toBeNull();
83
+ });
84
+
85
+ it('reuses the same singleton regions across hook instances', async () => {
86
+ const {result: a} = renderHook(() => useAnnounce());
87
+ a.current('first');
88
+ const region = politeRegion();
89
+
90
+ const {result: b} = renderHook(() => useAnnounce());
91
+ b.current('second');
92
+ await waitFor(() => {
93
+ expect(politeRegion()).toHaveTextContent('second');
94
+ });
95
+ // Same DOM node, not a duplicate.
96
+ expect(politeRegion()).toBe(region);
97
+ expect(
98
+ document.querySelectorAll('[data-astryx-live-region="polite"]').length,
99
+ ).toBe(1);
100
+ });
101
+ });
@@ -0,0 +1,168 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useAnnounce.ts
7
+ * @input Uses React useCallback; DOM APIs for a singleton live-region pair
8
+ * @output Exports useAnnounce hook and AnnouncePoliteness type
9
+ * @position Core a11y hook; provides imperative screen-reader announcements
10
+ * via persistently-mounted polite/assertive live regions
11
+ *
12
+ * SYNC: When modified, update:
13
+ * - /packages/core/src/hooks/index.ts
14
+ */
15
+
16
+ import {useCallback} from 'react';
17
+
18
+ /**
19
+ * Announcement urgency:
20
+ * - `'polite'` (default): announced when the screen reader is idle. Use for
21
+ * status updates, result counts, "no results", non-urgent confirmations.
22
+ * - `'assertive'`: interrupts the current announcement. Reserve for errors and
23
+ * time-sensitive alerts.
24
+ */
25
+ export type AnnouncePoliteness = 'polite' | 'assertive';
26
+
27
+ /**
28
+ * Imperative screen-reader announcement function.
29
+ */
30
+ export type AnnounceFn = (
31
+ message: string,
32
+ politeness?: AnnouncePoliteness,
33
+ ) => void;
34
+
35
+ const CONTAINER_ATTR = 'data-astryx-live-region';
36
+
37
+ // Visually-hidden clip block (matches the VisuallyHidden primitive). Applied
38
+ // inline so the regions work without any stylesheet being present.
39
+ const VISUALLY_HIDDEN_CSS =
40
+ 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;' +
41
+ 'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;' +
42
+ 'inset-block-start:0;inset-inline-start:0;pointer-events:none;' +
43
+ 'user-select:none;';
44
+
45
+ interface LiveRegions {
46
+ polite: HTMLElement;
47
+ assertive: HTMLElement;
48
+ }
49
+
50
+ // Singleton: the live regions are created ONCE and stay mounted for the
51
+ // lifetime of the document. This is the crux of reliable announcements — many
52
+ // screen readers will not announce content injected into a live region that is
53
+ // created together with its content ("born with content"). By mounting empty
54
+ // regions up front and only mutating their text later, updates are announced.
55
+ let regions: LiveRegions | null = null;
56
+
57
+ function createRegion(politeness: AnnouncePoliteness): HTMLElement {
58
+ const el = document.createElement('div');
59
+ el.setAttribute(CONTAINER_ATTR, politeness);
60
+ el.setAttribute('aria-live', politeness);
61
+ el.setAttribute('aria-atomic', 'true');
62
+ el.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status');
63
+ el.style.cssText = VISUALLY_HIDDEN_CSS;
64
+ document.body.appendChild(el);
65
+ return el;
66
+ }
67
+
68
+ function getRegions(): LiveRegions | null {
69
+ if (typeof document === 'undefined') {
70
+ return null;
71
+ }
72
+ if (regions) {
73
+ // Re-attach if a region was removed from the DOM (e.g. by a test cleanup).
74
+ if (!regions.polite.isConnected) {
75
+ document.body.appendChild(regions.polite);
76
+ }
77
+ if (!regions.assertive.isConnected) {
78
+ document.body.appendChild(regions.assertive);
79
+ }
80
+ return regions;
81
+ }
82
+ regions = {
83
+ polite: createRegion('polite'),
84
+ assertive: createRegion('assertive'),
85
+ };
86
+ return regions;
87
+ }
88
+
89
+ function announceMessage(
90
+ message: string,
91
+ politeness: AnnouncePoliteness,
92
+ ): void {
93
+ const r = getRegions();
94
+ if (!r) {
95
+ return;
96
+ }
97
+ const target = politeness === 'assertive' ? r.assertive : r.polite;
98
+ // Clearing first guarantees the mutation is observed even when the new
99
+ // message equals the current text — some AT deduplicate identical content.
100
+ target.textContent = '';
101
+ // A microtask/rAF-free re-set in the next frame is more reliable across AT
102
+ // than an immediate re-set, which can be coalesced with the clear.
103
+ requestAnimationFrame(() => {
104
+ target.textContent = message;
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Clear any pending announcement from a region without announcing anything new.
110
+ * Used when the triggering context goes away (e.g. a search query is cleared),
111
+ * so stale status text does not linger in the accessibility tree.
112
+ */
113
+ function clearRegion(politeness: AnnouncePoliteness): void {
114
+ if (!regions) {
115
+ return;
116
+ }
117
+ const target =
118
+ politeness === 'assertive' ? regions.assertive : regions.polite;
119
+ target.textContent = '';
120
+ }
121
+
122
+ /**
123
+ * Returns an imperative `announce(message, politeness?)` function that speaks a
124
+ * message through a persistently-mounted, visually-hidden live region.
125
+ *
126
+ * The polite and assertive regions are created once on first use and kept
127
+ * mounted, so announcements are reliable even for messages that appear
128
+ * immediately (unlike a live region rendered together with its content).
129
+ *
130
+ * @example
131
+ * ```
132
+ * function Search() {
133
+ * const announce = useAnnounce();
134
+ * const onResults = (n: number) => {
135
+ * announce(n === 0 ? 'No results found' : `${n} results`);
136
+ * };
137
+ * // ...
138
+ * }
139
+ * ```
140
+ */
141
+ export function useAnnounce(): AnnounceFn {
142
+ return useCallback(
143
+ (message: string, politeness: AnnouncePoliteness = 'polite') => {
144
+ if (!message) {
145
+ // An empty message clears any lingering status from the region rather
146
+ // than announcing nothing — useful when the triggering context ends.
147
+ clearRegion(politeness);
148
+ return;
149
+ }
150
+ announceMessage(message, politeness);
151
+ },
152
+ [],
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Test-only helper: removes the singleton live regions and resets state so
158
+ * each test starts clean. Not part of the public runtime API.
159
+ *
160
+ * @internal
161
+ */
162
+ export function __resetLiveRegionsForTest(): void {
163
+ if (regions) {
164
+ regions.polite.remove();
165
+ regions.assertive.remove();
166
+ regions = null;
167
+ }
168
+ }
@@ -5,18 +5,30 @@
5
5
  /**
6
6
  * @file useClickableContainer.ts
7
7
  * @input Container ref, interactive element ref, click/href handlers
8
- * @output onClick and onMouseUp handlers for the container
8
+ * @output onClick and onMouseUp handlers for the container; INTERACTIVE_SELECTORS list
9
9
  * @position Core hook for clickable containers that safely handle nested interactive elements
10
10
  *
11
11
  * Solves the "nested interactive elements" problem: when a card is clickable
12
12
  * but contains buttons/links, clicking those should NOT trigger the card's action.
13
+ *
14
+ * SYNC: When modified, update:
15
+ * - /packages/core/src/hooks/index.ts (export)
13
16
  */
14
17
 
15
18
  import {useCallback, useEffect, type RefObject, type MouseEvent} from 'react';
16
19
 
17
- // Interactive element selectors — clicks on these (or their descendants)
18
- // should NOT bubble to the container's click handler.
19
- const INTERACTIVE_SELECTORS = [
20
+ /**
21
+ * Canonical list of interactive element selectors — native controls plus
22
+ * role-based interactive elements. Clicks on these (or their descendants)
23
+ * should NOT bubble to a clickable container's click handler.
24
+ *
25
+ * Exported so other focus/interaction utilities can share one comprehensive
26
+ * definition of "interactive target" rather than hand-rolling divergent lists.
27
+ * Note: this list is about "don't bubble clicks", not focus-eligibility —
28
+ * consumers that need focusable elements must additionally exclude
29
+ * unfocusable/disabled targets (e.g. `[tabindex="-1"]`, `:disabled`).
30
+ */
31
+ export const INTERACTIVE_SELECTORS = [
20
32
  'button',
21
33
  'a',
22
34
  'input',
@@ -3,13 +3,13 @@
3
3
  /**
4
4
  * @file useFocusTrap.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, useFocusTrap hook
6
- * @output Unit tests for useFocusTrap tabbable-element model
7
- * @position Testing; validates useFocusTrap.ts focusable detection
6
+ * @output Unit tests for useFocusTrap tabbable-element model + Escape/IME guard
7
+ * @position Testing; validates useFocusTrap.ts focusable detection + dismissal coordination
8
8
  *
9
9
  * SYNC: When useFocusTrap.ts changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect} from 'vitest';
12
+ import {describe, it, expect, vi} from 'vitest';
13
13
  import {render, screen, fireEvent} from '@testing-library/react';
14
14
  import {useFocusTrap} from './useFocusTrap';
15
15
 
@@ -32,6 +32,23 @@ function Trap({children}: {children: React.ReactNode}) {
32
32
  );
33
33
  }
34
34
 
35
+ function EscapeTrap({
36
+ isActive,
37
+ onEscape,
38
+ label,
39
+ }: {
40
+ isActive: boolean;
41
+ onEscape: () => void;
42
+ label: string;
43
+ }) {
44
+ const {containerRef} = useFocusTrap<HTMLDivElement>({isActive, onEscape});
45
+ return (
46
+ <div ref={containerRef} data-testid={label}>
47
+ <button type="button">{label}-btn</button>
48
+ </div>
49
+ );
50
+ }
51
+
35
52
  describe('useFocusTrap tabbable model (infra-8)', () => {
36
53
  it('treats a contenteditable as focusable (focusFirst lands on it)', () => {
37
54
  render(
@@ -66,3 +83,50 @@ describe('useFocusTrap tabbable model (infra-8)', () => {
66
83
  expect(screen.getByTestId('real-btn')).toHaveFocus();
67
84
  });
68
85
  });
86
+
87
+ describe('useFocusTrap Escape coordination', () => {
88
+ it('calls onEscape for a single active trap', () => {
89
+ const onEscape = vi.fn();
90
+ render(<EscapeTrap isActive onEscape={onEscape} label="only" />);
91
+ fireEvent.keyDown(document, {key: 'Escape'});
92
+ expect(onEscape).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ it('only the top-most trap responds to Escape when nested', () => {
96
+ const outer = vi.fn();
97
+ const inner = vi.fn();
98
+ render(
99
+ <>
100
+ <EscapeTrap isActive onEscape={outer} label="outer" />
101
+ <EscapeTrap isActive onEscape={inner} label="inner" />
102
+ </>,
103
+ );
104
+ // The most recently activated trap (inner) is on top.
105
+ fireEvent.keyDown(document, {key: 'Escape'});
106
+ expect(inner).toHaveBeenCalledTimes(1);
107
+ expect(outer).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('ignores Escape during IME composition', () => {
111
+ const onEscape = vi.fn();
112
+ render(<EscapeTrap isActive onEscape={onEscape} label="ime" />);
113
+ fireEvent.keyDown(document, {key: 'Escape', isComposing: true});
114
+ expect(onEscape).not.toHaveBeenCalled();
115
+ // keyCode 229 (composition) is also ignored
116
+ fireEvent.keyDown(document, {key: 'Escape', keyCode: 229});
117
+ expect(onEscape).not.toHaveBeenCalled();
118
+ // a normal Escape still works
119
+ fireEvent.keyDown(document, {key: 'Escape'});
120
+ expect(onEscape).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it('does not respond after the trap is deactivated', () => {
124
+ const onEscape = vi.fn();
125
+ const {rerender} = render(
126
+ <EscapeTrap isActive onEscape={onEscape} label="toggle" />,
127
+ );
128
+ rerender(<EscapeTrap isActive={false} onEscape={onEscape} label="toggle" />);
129
+ fireEvent.keyDown(document, {key: 'Escape'});
130
+ expect(onEscape).not.toHaveBeenCalled();
131
+ });
132
+ });
@@ -19,6 +19,56 @@ import {useCallback, useEffect, useRef} from 'react';
19
19
 
20
20
  import {FOCUSABLE_SELECTOR} from './focusableSelector';
21
21
 
22
+ /**
23
+ * Module-level stack of active focus-trap Escape handlers.
24
+ *
25
+ * Every active `useFocusTrap` used to attach its own document-level `keydown`
26
+ * listener with no coordination, so a single Escape press closed *every* open
27
+ * layer at once (e.g. a popover nested inside a Dialog closed both). Tracking
28
+ * traps in a shared stack lets only the most recently activated (top-most)
29
+ * trap respond to Escape.
30
+ */
31
+ const escapeStack: (() => void)[] = [];
32
+
33
+ function pushEscapeHandler(handler: () => void): void {
34
+ escapeStack.push(handler);
35
+ }
36
+
37
+ function removeEscapeHandler(handler: () => void): void {
38
+ const index = escapeStack.lastIndexOf(handler);
39
+ if (index !== -1) {
40
+ escapeStack.splice(index, 1);
41
+ }
42
+ }
43
+
44
+ function isTopEscapeHandler(handler: () => void): boolean {
45
+ return escapeStack[escapeStack.length - 1] === handler;
46
+ }
47
+
48
+ /**
49
+ * Whether any focus-trap Escape handler is currently active (i.e. a popover
50
+ * layer is open). Other overlay primitives that manage their own Escape (e.g.
51
+ * Dialog) can consult this to defer to a popover layered on top of them,
52
+ * giving topmost-only dismissal until a full layer stack exists.
53
+ */
54
+ export function hasActiveFocusTrapEscape(): boolean {
55
+ return escapeStack.length > 0;
56
+ }
57
+
58
+ /**
59
+ * Whether an Escape keydown should be ignored because it is cancelling an
60
+ * in-progress IME composition. CJK/IME users press Escape to cancel
61
+ * composition; that must not close the surrounding overlay. `keyCode === 229`
62
+ * covers browsers that fire keydown before `isComposing` is set. Exported so
63
+ * other overlays (Dialog, Drawer, CommandPalette) share one definition.
64
+ */
65
+ export function isImeKeyEvent(event: {
66
+ isComposing?: boolean;
67
+ keyCode?: number;
68
+ }): boolean {
69
+ return event.isComposing === true || event.keyCode === 229;
70
+ }
71
+
22
72
  /**
23
73
  * Whether an element is currently perceivable/focusable — excludes ones hidden
24
74
  * via `display:none`/`visibility:hidden` or inside an `inert`/`hidden` subtree,
@@ -222,6 +272,16 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
222
272
  return;
223
273
  }
224
274
 
275
+ // Register this trap on the shared Escape stack so only the top-most
276
+ // active trap responds to Escape. A stable identity per active period is
277
+ // enough — we push on activate and remove on cleanup.
278
+ const escapeHandler = () => {
279
+ onEscape?.();
280
+ };
281
+ if (onEscape) {
282
+ pushEscapeHandler(escapeHandler);
283
+ }
284
+
225
285
  const handleKeyDown = (event: KeyboardEvent) => {
226
286
  const container = containerRef.current;
227
287
  if (!container) {
@@ -229,7 +289,19 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
229
289
  }
230
290
 
231
291
  if (event.key === 'Escape' && onEscape) {
292
+ // Ignore Escape that is cancelling an IME composition, already handled
293
+ // by a nested handler, or not targeting the top-most trap.
294
+ if (
295
+ event.defaultPrevented ||
296
+ isImeKeyEvent(event) ||
297
+ !isTopEscapeHandler(escapeHandler)
298
+ ) {
299
+ return;
300
+ }
301
+ // Mark handled and stop propagation so an outer layer (e.g. a Dialog
302
+ // hosting this popover) does not also dismiss on the same press.
232
303
  event.preventDefault();
304
+ event.stopPropagation();
233
305
  onEscape();
234
306
  return;
235
307
  }
@@ -266,6 +338,9 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
266
338
 
267
339
  return () => {
268
340
  document.removeEventListener('keydown', handleKeyDown);
341
+ if (onEscape) {
342
+ removeEscapeHandler(escapeHandler);
343
+ }
269
344
  };
270
345
  }, [isActive, onEscape]);
271
346