@astryxdesign/core 0.4.0 → 0.4.1-canary.1ce0b4c

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 (271) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +11 -9
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/HoverCard/HoverCard.d.ts +1 -1
  52. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  53. package/dist/HoverCard/HoverCard.js +5 -13
  54. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  55. package/dist/HoverCard/useHoverCard.js +6 -5
  56. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  57. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  58. package/dist/Icon/globalIconRegistry.js +5 -4
  59. package/dist/Indicator/index.d.ts +1 -1
  60. package/dist/Indicator/index.d.ts.map +1 -1
  61. package/dist/Indicator/types.d.ts +10 -0
  62. package/dist/Indicator/types.d.ts.map +1 -1
  63. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  64. package/dist/InputGroup/groupStyles.js +7 -2
  65. package/dist/Layer/layerHost.d.ts +24 -0
  66. package/dist/Layer/layerHost.d.ts.map +1 -0
  67. package/dist/Layer/layerHost.js +79 -0
  68. package/dist/Layer/useLayer.d.ts +14 -6
  69. package/dist/Layer/useLayer.d.ts.map +1 -1
  70. package/dist/Layer/useLayer.js +155 -29
  71. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  72. package/dist/Lightbox/Lightbox.js +24 -50
  73. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  74. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  75. package/dist/MultiSelector/MultiSelector.js +134 -102
  76. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  77. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  78. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  79. package/dist/Popover/usePopover.d.ts +15 -0
  80. package/dist/Popover/usePopover.d.ts.map +1 -1
  81. package/dist/Popover/usePopover.js +14 -2
  82. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  83. package/dist/ProgressBar/ProgressBar.js +2 -3
  84. package/dist/Section/Section.d.ts.map +1 -1
  85. package/dist/Section/Section.js +1 -2
  86. package/dist/Section/index.d.ts.map +1 -1
  87. package/dist/Section/index.js +1 -2
  88. package/dist/Selector/Selector.d.ts +10 -0
  89. package/dist/Selector/Selector.d.ts.map +1 -1
  90. package/dist/Selector/Selector.js +135 -95
  91. package/dist/SideNav/SideNav.d.ts +7 -9
  92. package/dist/SideNav/SideNav.d.ts.map +1 -1
  93. package/dist/SideNav/SideNav.js +32 -5
  94. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  95. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  96. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  97. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  98. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  99. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  100. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  101. package/dist/SideNav/SideNavHeading.js +70 -24
  102. package/dist/SideNav/SideNavItem.d.ts +7 -2
  103. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  104. package/dist/SideNav/SideNavItem.js +114 -75
  105. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  106. package/dist/SideNav/SideNavSection.js +7 -16
  107. package/dist/SideNav/index.d.ts +1 -1
  108. package/dist/SideNav/index.d.ts.map +1 -1
  109. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  110. package/dist/Skeleton/Skeleton.js +1 -2
  111. package/dist/Skeleton/index.d.ts.map +1 -1
  112. package/dist/Skeleton/index.js +1 -2
  113. package/dist/Slider/Slider.d.ts.map +1 -1
  114. package/dist/Slider/Slider.js +2 -6
  115. package/dist/Switch/Switch.d.ts.map +1 -1
  116. package/dist/Switch/Switch.js +3 -3
  117. package/dist/Table/TableRow.d.ts +1 -1
  118. package/dist/Table/TableRow.d.ts.map +1 -1
  119. package/dist/Table/TableRow.js +4 -2
  120. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  121. package/dist/TimeInput/TimeInput.js +7 -2
  122. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  123. package/dist/Timestamp/Timestamp.js +14 -3
  124. package/dist/Timestamp/formatInstant.d.ts +11 -1
  125. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  126. package/dist/Timestamp/formatInstant.js +3 -1
  127. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  128. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  129. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  130. package/dist/TopNav/TopNavMenu.js +2 -2
  131. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  132. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  133. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  134. package/dist/VisuallyHidden/index.js +1 -2
  135. package/dist/astryx.css +23 -24
  136. package/dist/astryx.umd.js +48 -48
  137. package/dist/astryx.umd.js.map +4 -4
  138. package/dist/hooks/index.d.ts +1 -1
  139. package/dist/hooks/index.d.ts.map +1 -1
  140. package/dist/hooks/index.js +1 -1
  141. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  142. package/dist/hooks/useFocusTrap.js +16 -2
  143. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  144. package/dist/hooks/useInputStatusIcon.js +17 -4
  145. package/dist/theme/defineTheme.d.ts +2 -2
  146. package/dist/theme/defineTheme.d.ts.map +1 -1
  147. package/dist/theme/defineTheme.js +2 -1
  148. package/dist/theme/index.d.ts +1 -1
  149. package/dist/theme/index.d.ts.map +1 -1
  150. package/dist/theme/index.js +1 -1
  151. package/dist/theme/tokens.stylex.d.ts +12 -0
  152. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  153. package/dist/theme/tokens.stylex.js +23 -0
  154. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  155. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  156. package/dist/utils/focusOutline.stylex.js +46 -33
  157. package/dist/utils/index.d.ts +1 -0
  158. package/dist/utils/index.d.ts.map +1 -1
  159. package/dist/utils/index.js +5 -1
  160. package/dist/utils/interactionModality.d.ts +34 -0
  161. package/dist/utils/interactionModality.d.ts.map +1 -0
  162. package/dist/utils/interactionModality.js +70 -0
  163. package/locales/en.json +44 -0
  164. package/locales/pseudo.json +33 -0
  165. package/package.json +3 -3
  166. package/src/AppShell/AppShell.doc.mjs +3 -0
  167. package/src/AspectRatio/AspectRatio.tsx +0 -2
  168. package/src/AspectRatio/index.ts +0 -2
  169. package/src/Badge/Badge.tsx +0 -2
  170. package/src/Badge/index.ts +0 -2
  171. package/src/Banner/Banner.test.tsx +58 -1
  172. package/src/Banner/Banner.tsx +24 -5
  173. package/src/Blockquote/Blockquote.tsx +0 -2
  174. package/src/Blockquote/index.ts +0 -2
  175. package/src/Button/Button.doc.mjs +2 -2
  176. package/src/Button/Button.tsx +10 -7
  177. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  178. package/src/Card/Card.tsx +0 -2
  179. package/src/Card/index.ts +0 -2
  180. package/src/Center/Center.tsx +0 -2
  181. package/src/Center/index.ts +0 -2
  182. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  183. package/src/Code/Code.tsx +0 -2
  184. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  185. package/src/CommandPalette/CommandPalette.tsx +5 -0
  186. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  187. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  188. package/src/DateInput/DateInput.test.tsx +19 -0
  189. package/src/DateInput/DateInput.tsx +3 -6
  190. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  191. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  192. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  193. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  194. package/src/Field/PanelSearchInput.tsx +271 -0
  195. package/src/FileInput/FileInput.tsx +21 -8
  196. package/src/Grid/Grid.tsx +0 -2
  197. package/src/Grid/index.ts +0 -2
  198. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  199. package/src/HoverCard/HoverCard.test.tsx +283 -55
  200. package/src/HoverCard/HoverCard.tsx +5 -13
  201. package/src/HoverCard/useHoverCard.tsx +8 -11
  202. package/src/Icon/globalIconRegistry.tsx +5 -4
  203. package/src/Indicator/Indicator.doc.mjs +1 -1
  204. package/src/Indicator/index.ts +1 -0
  205. package/src/Indicator/types.ts +11 -0
  206. package/src/InputGroup/groupStyles.ts +7 -8
  207. package/src/Layer/layerHost.test.ts +99 -0
  208. package/src/Layer/layerHost.ts +141 -0
  209. package/src/Layer/useLayer.doc.mjs +14 -4
  210. package/src/Layer/useLayer.test.tsx +318 -7
  211. package/src/Layer/useLayer.tsx +229 -35
  212. package/src/Lightbox/Lightbox.tsx +2 -11
  213. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  214. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  215. package/src/MultiSelector/MultiSelector.tsx +170 -127
  216. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  217. package/src/Popover/Popover.doc.mjs +2 -0
  218. package/src/Popover/usePopover.tsx +37 -4
  219. package/src/ProgressBar/ProgressBar.tsx +2 -8
  220. package/src/Section/Section.tsx +0 -2
  221. package/src/Section/index.ts +0 -2
  222. package/src/Selector/Selector.doc.mjs +10 -0
  223. package/src/Selector/Selector.test.tsx +386 -2
  224. package/src/Selector/Selector.tsx +182 -122
  225. package/src/SideNav/SideNav.doc.mjs +13 -4
  226. package/src/SideNav/SideNav.test.tsx +771 -2
  227. package/src/SideNav/SideNav.tsx +37 -16
  228. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  229. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  230. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  231. package/src/SideNav/SideNavHeading.tsx +28 -10
  232. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  233. package/src/SideNav/SideNavItem.tsx +92 -84
  234. package/src/SideNav/SideNavSection.tsx +6 -20
  235. package/src/SideNav/index.ts +2 -0
  236. package/src/Skeleton/Skeleton.tsx +0 -2
  237. package/src/Skeleton/index.ts +0 -2
  238. package/src/Slider/Slider.tsx +2 -11
  239. package/src/Switch/Switch.tsx +10 -2
  240. package/src/Table/Table.test.tsx +31 -0
  241. package/src/Table/TableRow.tsx +6 -0
  242. package/src/Text/Text.doc.mjs +1 -1
  243. package/src/TimeInput/TimeInput.test.tsx +54 -0
  244. package/src/TimeInput/TimeInput.tsx +17 -2
  245. package/src/Timestamp/Timestamp.test.tsx +125 -30
  246. package/src/Timestamp/Timestamp.tsx +14 -2
  247. package/src/Timestamp/formatInstant.ts +16 -1
  248. package/src/Toolbar/Toolbar.test.tsx +14 -14
  249. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  250. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  251. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  252. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  253. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  254. package/src/TopNav/TopNavMenu.tsx +8 -2
  255. package/src/TreeList/TreeList.test.tsx +3 -2
  256. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  257. package/src/VisuallyHidden/index.ts +0 -2
  258. package/src/hooks/index.ts +5 -1
  259. package/src/hooks/useFocusTrap.test.tsx +16 -0
  260. package/src/hooks/useFocusTrap.ts +20 -2
  261. package/src/hooks/useInputStatusIcon.tsx +8 -8
  262. package/src/serverSafeComponents.test.ts +665 -0
  263. package/src/theme/defineTheme.ts +3 -0
  264. package/src/theme/generateThemeRules.test.ts +17 -0
  265. package/src/theme/index.ts +2 -0
  266. package/src/theme/themingTargets.test.ts +152 -26
  267. package/src/theme/tokens.stylex.ts +18 -0
  268. package/src/theme/tokens.test.ts +37 -0
  269. package/src/utils/focusOutline.stylex.ts +37 -23
  270. package/src/utils/index.ts +4 -0
  271. package/src/utils/interactionModality.ts +74 -0
@@ -16,6 +16,24 @@ import {formatTooltipLines} from './tooltipEntries';
16
16
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17
17
  import {InternationalizationProvider} from '../i18n';
18
18
 
19
+ async function openTimestampHoverCard(): Promise<HTMLElement> {
20
+ const timestamp = document.querySelector('time');
21
+ if (timestamp == null) {
22
+ throw new Error('Expected Timestamp to render a <time> element');
23
+ }
24
+ const trigger = timestamp.parentElement;
25
+ if (trigger == null) {
26
+ throw new Error('Expected Timestamp to render a hover-card trigger');
27
+ }
28
+
29
+ fireEvent.mouseEnter(trigger);
30
+ await waitFor(() => {
31
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
32
+ });
33
+
34
+ return screen.getByRole('dialog', {hidden: true});
35
+ }
36
+
19
37
  describe('Timestamp', () => {
20
38
  // The hover card is loaded lazily (React.lazy + Suspense), so its chunk
21
39
  // resolves asynchronously the first time a card renders. Warm the module
@@ -408,6 +426,50 @@ describe('Timestamp', () => {
408
426
  expect(el.getAttribute('aria-label')).toBeNull();
409
427
  });
410
428
 
429
+ // Timezone abbreviations like "PST" or "GMT+2" are unexpanded abbreviations
430
+ // to a screen-reader user (WCAG 3.1.4) — the AT-facing aria-label must spell
431
+ // the timezone out in full, while visible text keeps the compact short form.
432
+ it('uses the long timezone name in the aria-label (WCAG 3.1.4)', () => {
433
+ const oneHourAgo = new Date(Date.now() - 3600 * 1000);
434
+ render(
435
+ <Timestamp
436
+ value={oneHourAgo.getTime() / 1000}
437
+ format="relative"
438
+ hasTooltip={false}
439
+ data-testid="ts"
440
+ />,
441
+ );
442
+
443
+ const longTz =
444
+ new Intl.DateTimeFormat(undefined, {timeZoneName: 'long'})
445
+ .formatToParts(oneHourAgo)
446
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
447
+ expect(longTz).not.toBe('');
448
+ expect(screen.getByTestId('ts').getAttribute('aria-label')).toContain(
449
+ longTz,
450
+ );
451
+ });
452
+
453
+ it('keeps the short timezone form in visible text when isTimezoneShown', () => {
454
+ const date = new Date('2026-03-25T10:00:00Z');
455
+ render(
456
+ <Timestamp
457
+ value="2026-03-25T10:00:00Z"
458
+ format="time"
459
+ isTimezoneShown
460
+ data-testid="ts"
461
+ />,
462
+ );
463
+
464
+ const tzPart = (form: 'short' | 'long') =>
465
+ new Intl.DateTimeFormat(undefined, {timeZoneName: form})
466
+ .formatToParts(date)
467
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
468
+ const text = screen.getByTestId('ts').textContent ?? '';
469
+ expect(text).toContain(tzPart('short'));
470
+ expect(text).not.toContain(tzPart('long'));
471
+ });
472
+
411
473
  // --- Input handling ---
412
474
 
413
475
  it('accepts Unix timestamp in seconds', () => {
@@ -602,22 +664,32 @@ describe('Timestamp', () => {
602
664
  );
603
665
  const el = screen.getByTestId('ts');
604
666
 
605
- // The card layer mounts inline and carries the full absolute time (the
606
- // same string as the aria-label) as its single default copyable row.
667
+ // The card carries the full absolute time in its visible form (short
668
+ // timezone abbreviation) as its single default copyable row. The
669
+ // aria-label spells the timezone out in full (WCAG 3.1.4), so the two
670
+ // strings intentionally differ — compare the card against an
671
+ // independently formatted short-form string after opening it.
607
672
  // Compare with normalized whitespace: Intl output can contain narrow
608
673
  // no-break spaces that jest-dom's matcher normalization would break on.
609
- const card = await screen.findByRole('dialog', {hidden: true});
610
674
  const normalize = (s: string) => s.replace(/\s+/g, ' ');
611
- expect(normalize(card.textContent ?? '')).toContain(
612
- normalize(el.getAttribute('aria-label') ?? '\0'),
613
- );
614
-
675
+ const datetime = new Date(el.getAttribute('datetime') ?? '');
676
+ const expected = new Intl.DateTimeFormat(undefined, {
677
+ year: 'numeric',
678
+ month: 'long',
679
+ day: 'numeric',
680
+ hour: 'numeric',
681
+ minute: '2-digit',
682
+ second: '2-digit',
683
+ timeZoneName: 'short',
684
+ }).format(datetime);
615
685
  // Tab onto the timestamp — the only tab stop in the document.
616
686
  await user.tab();
617
687
  expect(el).toHaveFocus();
618
688
  await waitFor(() => {
619
689
  expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
620
690
  });
691
+ const card = screen.getByRole('dialog', {hidden: true});
692
+ expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
621
693
  });
622
694
 
623
695
  it('does not add a tab stop when the hover card is disabled', () => {
@@ -701,24 +773,37 @@ describe('Timestamp', () => {
701
773
  .queryAllByRole('tooltip', {hidden: true})
702
774
  .filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
703
775
  ).toHaveLength(0);
704
- const card = await screen.findByRole('dialog', {hidden: true});
776
+ const card = await openTimestampHoverCard();
705
777
  // The default card is the named details card, exactly as the configured
706
778
  // one is.
707
779
  expect(card).toHaveAttribute('aria-label', 'Timestamp details');
708
- // Exactly one row, carrying the full absolute time (the same string as
709
- // the aria-label) with its own copy button.
780
+ // Exactly one row, carrying the full absolute time in its visible form
781
+ // (short timezone abbreviation) with its own copy button. The
782
+ // aria-label spells the timezone out in full (WCAG 3.1.4), so the two
783
+ // strings intentionally differ — compare the row against an
784
+ // independently formatted short-form string.
710
785
  expect(card.querySelectorAll('dd')).toHaveLength(1);
711
786
  expect(card.querySelectorAll('button')).toHaveLength(1);
712
787
 
713
788
  const normalize = (s: string) => s.replace(/\s+/g, ' ');
714
- expect(normalize(card.textContent ?? '')).toContain(
715
- normalize(screen.getByTestId('ts').getAttribute('aria-label') ?? '\0'),
789
+ const datetime = new Date(
790
+ screen.getByTestId('ts').getAttribute('datetime') ?? '',
716
791
  );
792
+ const expected = new Intl.DateTimeFormat(undefined, {
793
+ year: 'numeric',
794
+ month: 'long',
795
+ day: 'numeric',
796
+ hour: 'numeric',
797
+ minute: '2-digit',
798
+ second: '2-digit',
799
+ timeZoneName: 'short',
800
+ }).format(datetime);
801
+ expect(normalize(card.textContent ?? '')).toContain(normalize(expected));
717
802
  });
718
803
 
719
804
  it("copies the default absolute row's value", async () => {
720
805
  render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
721
- const card = await screen.findByRole('dialog', {hidden: true});
806
+ const card = await openTimestampHoverCard();
722
807
  const rowValue = card.querySelector('dd')?.textContent ?? '';
723
808
  expect(rowValue).toBeTruthy();
724
809
  fireEvent.click(card.querySelector('button')!);
@@ -734,10 +819,11 @@ describe('Timestamp', () => {
734
819
 
735
820
  it("shows a 'Copy' tooltip on the copy button, flipping to 'Copied' after a copy", async () => {
736
821
  render(<Timestamp value={Date.now() / 1000 - 3600} format="relative" />);
737
- const card = await screen.findByRole('dialog', {hidden: true});
822
+ const card = await openTimestampHoverCard();
738
823
  const button = card.querySelector('button')!;
739
824
  // The visible tooltip content defaults to the short imperative 'Copy'
740
825
  // (the full "Copy <value>" string stays the button's aria-label).
826
+ fireEvent.mouseEnter(button);
741
827
  await waitFor(() =>
742
828
  expect(
743
829
  screen.getByText('Copy', {
@@ -804,7 +890,7 @@ describe('Timestamp', () => {
804
890
  .queryAllByRole('tooltip', {hidden: true})
805
891
  .filter(el => !/^(Copy|Copied)$/.test(el.textContent?.trim() ?? '')),
806
892
  ).toHaveLength(0);
807
- const card = await screen.findByRole('dialog', {hidden: true});
893
+ const card = await openTimestampHoverCard();
808
894
  expect(card).toHaveAttribute('aria-label', 'Timestamp details');
809
895
  });
810
896
 
@@ -820,7 +906,7 @@ describe('Timestamp', () => {
820
906
  ]}
821
907
  />,
822
908
  );
823
- const card = await screen.findByRole('dialog', {hidden: true});
909
+ const card = await openTimestampHoverCard();
824
910
  expect(card.querySelectorAll('dd')).toHaveLength(3);
825
911
  // Entries are read-only unless they opt in, so no copy buttons and no
826
912
  // trailing action column are rendered.
@@ -847,7 +933,7 @@ describe('Timestamp', () => {
847
933
  ]}
848
934
  />,
849
935
  );
850
- const card = await screen.findByRole('dialog', {hidden: true});
936
+ const card = await openTimestampHoverCard();
851
937
  // Three rows, but only the opted-in row carries a copy button.
852
938
  expect(card.querySelectorAll('dd')).toHaveLength(3);
853
939
  expect(card.querySelectorAll('button')).toHaveLength(1);
@@ -868,7 +954,7 @@ describe('Timestamp', () => {
868
954
  ]}
869
955
  />,
870
956
  );
871
- const card = await screen.findByRole('dialog', {hidden: true});
957
+ const card = await openTimestampHoverCard();
872
958
  const values = Array.from(card.querySelectorAll('dd')).map(
873
959
  el => el.textContent,
874
960
  );
@@ -892,7 +978,7 @@ describe('Timestamp', () => {
892
978
  ]}
893
979
  />,
894
980
  );
895
- const card = await screen.findByRole('dialog', {hidden: true});
981
+ const card = await openTimestampHoverCard();
896
982
  const [rowValue] = Array.from(card.querySelectorAll('dd')).map(
897
983
  el => el.textContent ?? '',
898
984
  );
@@ -923,7 +1009,7 @@ describe('Timestamp', () => {
923
1009
  />
924
1010
  </InternationalizationProvider>,
925
1011
  );
926
- const card = await screen.findByRole('dialog', {hidden: true});
1012
+ const card = await openTimestampHoverCard();
927
1013
  fireEvent.click(card.querySelector('button')!);
928
1014
  await waitFor(() => {
929
1015
  expect(
@@ -943,7 +1029,7 @@ describe('Timestamp', () => {
943
1029
  );
944
1030
  // Without entries an absolute format has no hover surface at all;
945
1031
  // configuring entries must not be silently ignored.
946
- const card = await screen.findByRole('dialog', {hidden: true});
1032
+ const card = await openTimestampHoverCard();
947
1033
  expect(card.textContent).toContain('UTC');
948
1034
  // ...and the anchor becomes keyboard-reachable, as it is for relative.
949
1035
  expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
@@ -1052,7 +1138,7 @@ describe('Timestamp', () => {
1052
1138
  />,
1053
1139
  );
1054
1140
  // autoThreshold={0} forces the absolute branch regardless of the clock.
1055
- const card = await screen.findByRole('dialog', {hidden: true});
1141
+ const card = await openTimestampHoverCard();
1056
1142
  expect(card.textContent).toContain('2026-02-19 17:00:00');
1057
1143
  expect(screen.getByTestId('ts')).toHaveAttribute('tabindex', '0');
1058
1144
  });
@@ -1067,7 +1153,7 @@ describe('Timestamp', () => {
1067
1153
  ]}
1068
1154
  />,
1069
1155
  );
1070
- const card = await screen.findByRole('dialog', {hidden: true});
1156
+ const card = await openTimestampHoverCard();
1071
1157
  expect(card.textContent).toContain('2026-02-19 17:00:00');
1072
1158
  });
1073
1159
 
@@ -1083,7 +1169,7 @@ describe('Timestamp', () => {
1083
1169
  ]}
1084
1170
  />,
1085
1171
  );
1086
- const card = await screen.findByRole('dialog', {hidden: true});
1172
+ const card = await openTimestampHoverCard();
1087
1173
  // An unlabeled entry still emits its label cell, so the grid stays
1088
1174
  // aligned and the <dl> stays valid markup.
1089
1175
  expect(card.querySelectorAll('dt')).toHaveLength(3);
@@ -1121,7 +1207,7 @@ describe('Timestamp', () => {
1121
1207
  ]}
1122
1208
  />,
1123
1209
  );
1124
- const card = await screen.findByRole('dialog', {hidden: true});
1210
+ const card = await openTimestampHoverCard();
1125
1211
  const values = Array.from(card.querySelectorAll('dd')).map(
1126
1212
  el => el.textContent,
1127
1213
  );
@@ -1171,10 +1257,14 @@ describe('Timestamp', () => {
1171
1257
  },
1172
1258
  );
1173
1259
 
1174
- it('renders the full style identically as the aria-label and as a line', () => {
1260
+ it('renders the full style as the aria-label and as a line, differing only in zone-name spelling', () => {
1175
1261
  // 'full' is the one member with no visible-text counterpart — it backs
1176
1262
  // the accessible name of a relative timestamp and the tooltip's default
1177
- // line. Those two must not drift apart either.
1263
+ // line. The two must not drift apart on anything but the zone name: the
1264
+ // aria-label spells the zone out in full for assistive tech (WCAG 3.1.4),
1265
+ // the visible line keeps the abbreviation. Comparing them after swapping
1266
+ // the abbreviation for the spelled-out form keeps this a direct
1267
+ // surface-to-surface check, so re-forking either path still fails here.
1178
1268
  render(
1179
1269
  <Timestamp
1180
1270
  value={VALUE}
@@ -1183,9 +1273,14 @@ describe('Timestamp', () => {
1183
1273
  data-testid="ts"
1184
1274
  />,
1185
1275
  );
1186
- const [line] = formatTooltipLines(new Date(VALUE), [{format: 'full'}]);
1187
- expect(line.value).toBe(
1188
- screen.getByTestId('ts').getAttribute('aria-label'),
1276
+ const date = new Date(VALUE);
1277
+ const tzPart = (form: 'short' | 'long') =>
1278
+ new Intl.DateTimeFormat(undefined, {timeZoneName: form})
1279
+ .formatToParts(date)
1280
+ .find(p => p.type === 'timeZoneName')?.value ?? '';
1281
+ const [line] = formatTooltipLines(date, [{format: 'full'}]);
1282
+ expect(screen.getByTestId('ts').getAttribute('aria-label')).toBe(
1283
+ line.value.replace(tzPart('short'), tzPart('long')),
1189
1284
  );
1190
1285
  });
1191
1286
  });
@@ -451,8 +451,14 @@ export function Timestamp({
451
451
  ? formatInstant(date, effectiveFormat, {isTimezoneShown})
452
452
  : '';
453
453
 
454
- // Full absolute text for tooltip and aria-label
454
+ // Full absolute text for the tooltip (visible — keeps the compact timezone
455
+ // abbreviation) and for the AT-facing aria-label, which spells the timezone
456
+ // out in full: abbreviations like "PST" or "GMT+2" are unexpanded
457
+ // abbreviations to a screen-reader user (WCAG 3.1.4).
455
458
  const fullAbsoluteText = isValidDate ? formatInstant(date, 'full') : '';
459
+ const ariaLabelText = isValidDate
460
+ ? formatInstant(date, 'full', {timeZoneNameStyle: 'long'})
461
+ : '';
456
462
 
457
463
  // Live updates
458
464
  useEffect(() => {
@@ -518,8 +524,14 @@ export function Timestamp({
518
524
  <time
519
525
  ref={mergeRefs(ref, timeRef)}
520
526
  dateTime={isoString}
527
+ // `ariaLabelText` is '' only for an invalid date, which bails out
528
+ // before rendering — but keep the guard local: an empty aria-label
529
+ // must be omitted entirely (not rendered as aria-label="") so AT
530
+ // falls back to reading the visible <time> content.
521
531
  aria-label={
522
- isRelativeFormat(effectiveFormat) ? fullAbsoluteText : undefined
532
+ isRelativeFormat(effectiveFormat) && ariaLabelText !== ''
533
+ ? ariaLabelText
534
+ : undefined
523
535
  }
524
536
  // The hover card is anchored here with focusTrigger="always", which
525
537
  // attaches focus listeners but does not itself make the anchor
@@ -69,6 +69,16 @@ export interface FormatInstantOptions {
69
69
  * @default false
70
70
  */
71
71
  isTimezoneShown?: boolean;
72
+ /**
73
+ * How the zone name is spelled when a format carries one. `'short'` is the
74
+ * abbreviation ("PST"); `'long'` spells it out ("Pacific Standard Time").
75
+ * Only `'full'` honours it — it is the one format that always names its zone.
76
+ * The spelled-out form backs a relative timestamp's accessible name, where an
77
+ * abbreviation reads as an unexpanded initialism to a screen reader
78
+ * (WCAG 3.1.4); every visible surface keeps the abbreviation.
79
+ * @default 'short'
80
+ */
81
+ timeZoneNameStyle?: 'short' | 'long';
72
82
  }
73
83
 
74
84
  // =============================================================================
@@ -155,7 +165,11 @@ function getWallClock(date: Date, timeZone: string | undefined): WallClock {
155
165
  export function formatInstant(
156
166
  date: Date,
157
167
  format: InstantFormat,
158
- {timeZone, isTimezoneShown = false}: FormatInstantOptions = {},
168
+ {
169
+ timeZone,
170
+ isTimezoneShown = false,
171
+ timeZoneNameStyle = 'short',
172
+ }: FormatInstantOptions = {},
159
173
  ): string {
160
174
  const zone = timeZone === undefined ? {} : {timeZone};
161
175
  const zoneName = isTimezoneShown ? {timeZoneName: 'short' as const} : {};
@@ -164,6 +178,7 @@ export function formatInstant(
164
178
  case 'full':
165
179
  return new Intl.DateTimeFormat(undefined, {
166
180
  ...FULL_OPTIONS,
181
+ timeZoneName: timeZoneNameStyle,
167
182
  ...zone,
168
183
  }).format(date);
169
184
 
@@ -72,11 +72,11 @@ describe('Toolbar', () => {
72
72
  expect(screen.getByTestId('end')).toBeInTheDocument();
73
73
 
74
74
  // Three-slot layout produces 3 child divs (plus the aria-hidden
75
- // keyboard-hint popover, which is excluded here as an implementation detail)
75
+ // keyboard-hint layer infrastructure, excluded as an implementation detail)
76
76
  const toolbar = screen.getByRole('toolbar');
77
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
78
- 3,
79
- );
77
+ expect(
78
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
79
+ ).toHaveLength(3);
80
80
  });
81
81
 
82
82
  it('renders two-slot layout without centerContent', () => {
@@ -91,11 +91,11 @@ describe('Toolbar', () => {
91
91
  expect(screen.getByTestId('end')).toBeInTheDocument();
92
92
 
93
93
  // Two-slot layout produces 2 child divs (plus the aria-hidden
94
- // keyboard-hint popover, excluded here)
94
+ // keyboard-hint layer infrastructure, excluded here)
95
95
  const toolbar = screen.getByRole('toolbar');
96
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
97
- 2,
98
- );
96
+ expect(
97
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
98
+ ).toHaveLength(2);
99
99
  });
100
100
 
101
101
  it('renders start-only layout', () => {
@@ -107,9 +107,9 @@ describe('Toolbar', () => {
107
107
  );
108
108
  expect(screen.getByTestId('start')).toBeInTheDocument();
109
109
  const toolbar = screen.getByRole('toolbar');
110
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
111
- 1,
112
- );
110
+ expect(
111
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
112
+ ).toHaveLength(1);
113
113
  });
114
114
 
115
115
  it('renders end-only layout', () => {
@@ -121,9 +121,9 @@ describe('Toolbar', () => {
121
121
  );
122
122
  expect(screen.getByTestId('end')).toBeInTheDocument();
123
123
  const toolbar = screen.getByRole('toolbar');
124
- expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
125
- 1,
126
- );
124
+ expect(
125
+ toolbar.querySelectorAll(':scope > :not([popover]):not(template)'),
126
+ ).toHaveLength(1);
127
127
  });
128
128
 
129
129
  it('sets aria-orientation to horizontal by default', () => {
@@ -7,6 +7,11 @@ export const docs = {
7
7
  subComponentOf: 'TopNav',
8
8
  displayName: 'Top Nav Mega Menu',
9
9
  description: 'Navigation item that displays a full-width mega menu panel on hover. Uses a slots API with items and featured props. TopNavMegaMenuItem renders itself in both desktop and mobile drawer modes. Supports inline collapsible drawer via render mode context.',
10
+ // The panel anchors to the nearest <nav>; wrap the preview in a TopNav so
11
+ // it opens below the bar instead of pinning to the viewport corner (#4905).
12
+ playground: {
13
+ wrapper: {component: 'TopNav'},
14
+ },
10
15
  props: [
11
16
  {
12
17
  name: 'label',
@@ -9,6 +9,8 @@
9
9
  * SYNC: When TopNavMegaMenu changes, update tests to match new behavior
10
10
  */
11
11
 
12
+ import * as stylex from '@stylexjs/stylex';
13
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
12
14
  import {describe, it, expect, vi, beforeAll, afterAll, afterEach} from 'vitest';
13
15
  import {render, screen, act, fireEvent} from '@testing-library/react';
14
16
  import userEvent from '@testing-library/user-event';
@@ -817,3 +819,44 @@ describe('TopNavMegaMenuItem', () => {
817
819
  expect(screen.getByRole('link', {name: /Analytics/})).toBeInTheDocument();
818
820
  });
819
821
  });
822
+
823
+ // =============================================================================
824
+ // The shared focus ring (#4654) — see the note in SideNav.test.tsx: jsdom will
825
+ // not derive `:focus-visible` here, so what is pinned is that the focusable
826
+ // element composes the shared utility's classes rather than falling back to
827
+ // the browser's own outline.
828
+ // =============================================================================
829
+
830
+ const sharedFocusRingClasses = stylex
831
+ .props(focusOutlineStyles.focusVisible)
832
+ .className!.split(' ');
833
+
834
+ function expectSharedFocusRing(el: Element) {
835
+ const classes = el.className.split(' ');
836
+ for (const c of sharedFocusRingClasses) {
837
+ expect(classes).toContain(c);
838
+ }
839
+ }
840
+
841
+ describe('TopNavMegaMenu — drawer focus ring', () => {
842
+ it('draws the shared ring on the drawer section header', () => {
843
+ render(
844
+ <TopNavRenderContext value="drawer">
845
+ <TopNavMegaMenu
846
+ label="Products"
847
+ items={<TopNavMegaMenuItem title="Analytics" href="/analytics" />}
848
+ />
849
+ </TopNavRenderContext>,
850
+ );
851
+ expectSharedFocusRing(screen.getByRole('button', {name: 'Products'}));
852
+ });
853
+
854
+ it('draws the shared ring on a drawer item', () => {
855
+ render(
856
+ <TopNavRenderContext value="drawer">
857
+ <TopNavMegaMenuItem title="Analytics" href="/analytics" />
858
+ </TopNavRenderContext>,
859
+ );
860
+ expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
861
+ });
862
+ });
@@ -602,7 +602,10 @@ function DrawerMegaMenu({
602
602
  aria-controls={`${menuId}-items`}
603
603
  {...mergeProps(
604
604
  themeProps('top-nav-mega-menu', {mode: 'drawer'}),
605
- stylex.props(navItemStyles.item, styles.drawerHeader),
605
+ focusOutlineProps.focusVisible(
606
+ navItemStyles.item,
607
+ styles.drawerHeader,
608
+ ),
606
609
  )}>
607
610
  {label}
608
611
  <Icon
@@ -215,7 +215,7 @@ export function TopNavMegaMenuItem({
215
215
  {...elementProps}
216
216
  {...mergeProps(
217
217
  themeProps('top-nav-mega-menu-item', {mode: 'drawer'}),
218
- stylex.props(navItemStyles.item, styles.drawerItem),
218
+ focusOutlineProps.focusVisible(navItemStyles.item, styles.drawerItem),
219
219
  )}>
220
220
  {icon && <div {...stylex.props(styles.drawerItemIcon)}>{icon}</div>}
221
221
  <div {...stylex.props(styles.drawerItemContent)}>
@@ -12,6 +12,9 @@
12
12
  import {describe, it, expect, vi} from 'vitest';
13
13
  import {render, screen, fireEvent} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import * as stylex from '@stylexjs/stylex';
16
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
17
+ import {TopNavRenderContext} from './TopNavRenderContext';
15
18
  import {TopNavMenu} from './TopNavMenu';
16
19
 
17
20
  const mockItems = [
@@ -171,3 +174,41 @@ describe('keyboard navigation (APG menu pattern)', () => {
171
174
  expect(trigger).toHaveAttribute('aria-expanded', 'false');
172
175
  });
173
176
  });
177
+
178
+ // =============================================================================
179
+ // The shared focus ring (#4654) — see the note in SideNav.test.tsx: jsdom will
180
+ // not derive `:focus-visible` here, so what is pinned is that the focusable
181
+ // element composes the shared utility's classes rather than falling back to
182
+ // the browser's own outline.
183
+ // =============================================================================
184
+
185
+ const sharedFocusRingClasses = stylex
186
+ .props(focusOutlineStyles.focusVisible)
187
+ .className!.split(' ');
188
+
189
+ function expectSharedFocusRing(el: Element) {
190
+ const classes = el.className.split(' ');
191
+ for (const c of sharedFocusRingClasses) {
192
+ expect(classes).toContain(c);
193
+ }
194
+ }
195
+
196
+ describe('TopNavMenu — drawer focus ring', () => {
197
+ it('draws the shared ring on the drawer section header', () => {
198
+ render(
199
+ <TopNavRenderContext value="drawer">
200
+ <TopNavMenu label="Products" items={mockItems} />
201
+ </TopNavRenderContext>,
202
+ );
203
+ expectSharedFocusRing(screen.getByRole('button', {name: /Products/}));
204
+ });
205
+
206
+ it('draws the shared ring on a drawer item', () => {
207
+ render(
208
+ <TopNavRenderContext value="drawer">
209
+ <TopNavMenu label="Products" items={mockItems} />
210
+ </TopNavRenderContext>,
211
+ );
212
+ expectSharedFocusRing(screen.getByRole('link', {name: /Analytics/}));
213
+ });
214
+ });
@@ -432,7 +432,10 @@ export function TopNavMenu({
432
432
  onClick={() => setDrawerExpanded(v => !v)}
433
433
  aria-expanded={drawerExpanded}
434
434
  aria-controls={`${menuId}-items`}
435
- {...stylex.props(navItemStyles.item, drawerStyles.header)}>
435
+ {...focusOutlineProps.focusVisible(
436
+ navItemStyles.item,
437
+ drawerStyles.header,
438
+ )}>
436
439
  {label}
437
440
  <Icon
438
441
  icon="chevronDown"
@@ -459,7 +462,10 @@ export function TopNavMenu({
459
462
  item.onClick?.();
460
463
  closeMobileNav();
461
464
  }}
462
- {...stylex.props(navItemStyles.item, drawerStyles.item)}>
465
+ {...focusOutlineProps.focusVisible(
466
+ navItemStyles.item,
467
+ drawerStyles.item,
468
+ )}>
463
469
  {item.icon && (
464
470
  <span {...stylex.props(drawerStyles.itemIcon)}>
465
471
  {item.icon}
@@ -16,6 +16,7 @@ import {TreeList} from './TreeList';
16
16
  import type {TreeListItemData} from './TreeListTypes';
17
17
  import {defineTheme} from '../theme/defineTheme';
18
18
  import {generateThemeCSS} from '../theme/generateThemeRules';
19
+ import {FOCUS_OUTLINE} from '../utils/focusOutline.stylex';
19
20
 
20
21
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
21
22
  const {prose, component} = generateThemeCSS(theme);
@@ -299,8 +300,8 @@ describe('TreeList', () => {
299
300
  expect(root).toHaveFocus();
300
301
 
301
302
  expect(
302
- getComputedStyle(root).getPropertyValue('--_focus-outline'),
303
- ).toContain('solid');
303
+ getComputedStyle(root).getPropertyValue('--_focus-outline').trim(),
304
+ ).toBe(FOCUS_OUTLINE);
304
305
  // Mid and Leaf are DOM descendants of Root's <li> (nested <ul role="group">
305
306
  // subtrees) — their own outline var must stay unset, not inherit Root's.
306
307
  expect(getComputedStyle(mid).getPropertyValue('--_focus-outline')).toBe(
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file VisuallyHidden.tsx
7
5
  * @input Uses React createElement/ElementType, stylex
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports from VisuallyHidden.tsx
@@ -11,7 +11,11 @@
11
11
  * SYNC: When modified, update this header
12
12
  */
13
13
 
14
- export {useFocusTrap} from './useFocusTrap';
14
+ export {
15
+ hasActiveFocusTrapEscape,
16
+ isImeKeyEvent,
17
+ useFocusTrap,
18
+ } from './useFocusTrap';
15
19
  export type {UseFocusTrapOptions, UseFocusTrapReturn} from './useFocusTrap';
16
20
 
17
21
  export {useAnnounce} from './useAnnounce';