@astryxdesign/core 0.4.1 → 0.4.2-canary.464a445

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 (182) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Avatar/Avatar.d.ts +4 -1
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +19 -23
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
  7. package/dist/Button/Button.d.ts.map +1 -1
  8. package/dist/Button/Button.js +9 -7
  9. package/dist/Chat/ChatMessage.d.ts +7 -0
  10. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/Chat/ChatMessageBubble.d.ts +13 -1
  12. package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageBubble.js +19 -1
  14. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  15. package/dist/CheckboxInput/CheckboxInput.js +5 -0
  16. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
  17. package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
  18. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  19. package/dist/DateTimeInput/DateTimeInput.js +2 -2
  20. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
  22. package/dist/HoverCard/HoverCard.d.ts +1 -1
  23. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  24. package/dist/HoverCard/HoverCard.js +5 -13
  25. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  26. package/dist/HoverCard/useHoverCard.js +6 -5
  27. package/dist/InputGroup/groupStyles.d.ts.map +1 -1
  28. package/dist/InputGroup/groupStyles.js +7 -2
  29. package/dist/Layer/layerHost.d.ts +24 -0
  30. package/dist/Layer/layerHost.d.ts.map +1 -0
  31. package/dist/Layer/layerHost.js +79 -0
  32. package/dist/Layer/useLayer.d.ts +14 -6
  33. package/dist/Layer/useLayer.d.ts.map +1 -1
  34. package/dist/Layer/useLayer.js +158 -30
  35. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  36. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  37. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  38. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  39. package/dist/RadioList/RadioListItem.js +5 -0
  40. package/dist/SideNav/SideNav.d.ts +7 -9
  41. package/dist/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/SideNav/SideNav.js +32 -5
  43. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  44. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  45. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  46. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  47. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  48. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  49. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  50. package/dist/SideNav/SideNavHeading.js +89 -32
  51. package/dist/SideNav/SideNavItem.d.ts +7 -2
  52. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  53. package/dist/SideNav/SideNavItem.js +119 -75
  54. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  55. package/dist/SideNav/SideNavSection.js +7 -16
  56. package/dist/SideNav/index.d.ts +1 -1
  57. package/dist/SideNav/index.d.ts.map +1 -1
  58. package/dist/Slider/Slider.d.ts.map +1 -1
  59. package/dist/Slider/Slider.js +56 -15
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -0
  62. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  63. package/dist/Thumbnail/Thumbnail.js +5 -0
  64. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  65. package/dist/TopNav/TopNavHeading.js +14 -6
  66. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  67. package/dist/TopNav/TopNavMegaMenu.js +25 -93
  68. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  69. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  70. package/dist/TopNav/TopNavMenu.js +10 -5
  71. package/dist/astryx.css +13 -7
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/index.d.ts +1 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +1 -1
  77. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  78. package/dist/hooks/useFocusTrap.js +16 -2
  79. package/dist/hooks/useMenuHover.d.ts +50 -5
  80. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  81. package/dist/hooks/useMenuHover.js +171 -51
  82. package/dist/theme/defineTheme.d.ts +8 -2
  83. package/dist/theme/defineTheme.d.ts.map +1 -1
  84. package/dist/theme/defineTheme.js +8 -1
  85. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  86. package/dist/theme/derivedVarRegistry.js +7 -0
  87. package/dist/theme/expandColorScale.d.ts.map +1 -1
  88. package/dist/theme/expandColorScale.js +1 -0
  89. package/dist/theme/expandMotionScale.d.ts +1 -0
  90. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  91. package/dist/theme/expandMotionScale.js +1 -0
  92. package/dist/theme/expandRadiusScale.d.ts +1 -0
  93. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  94. package/dist/theme/expandRadiusScale.js +1 -0
  95. package/dist/theme/expandTypeScale.d.ts +1 -0
  96. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  97. package/dist/theme/expandTypeScale.js +1 -0
  98. package/dist/theme/tokens.stylex.d.ts +3 -1
  99. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  100. package/dist/theme/tokens.stylex.js +3 -1
  101. package/locales/en.json +84 -0
  102. package/locales/pseudo.json +63 -0
  103. package/package.json +3 -3
  104. package/src/Avatar/Avatar.doc.mjs +5 -2
  105. package/src/Avatar/Avatar.test.tsx +51 -0
  106. package/src/Avatar/Avatar.tsx +30 -20
  107. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  108. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  109. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  110. package/src/Button/Button.tsx +5 -3
  111. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  112. package/src/Card/Card.doc.mjs +2 -0
  113. package/src/Chat/Chat.doc.mjs +4 -1
  114. package/src/Chat/ChatMessage.doc.mjs +3 -3
  115. package/src/Chat/ChatMessage.tsx +7 -0
  116. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  117. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  118. package/src/Chat/ChatMessageBubble.tsx +25 -0
  119. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  120. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  121. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  122. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  123. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  124. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  125. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  126. package/src/HoverCard/HoverCard.test.tsx +283 -55
  127. package/src/HoverCard/HoverCard.tsx +5 -13
  128. package/src/HoverCard/useHoverCard.tsx +8 -11
  129. package/src/InputGroup/groupStyles.ts +7 -8
  130. package/src/Item/Item.doc.mjs +4 -0
  131. package/src/Layer/layerHost.test.ts +99 -0
  132. package/src/Layer/layerHost.ts +141 -0
  133. package/src/Layer/useLayer.doc.mjs +14 -4
  134. package/src/Layer/useLayer.test.tsx +332 -7
  135. package/src/Layer/useLayer.tsx +235 -36
  136. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  137. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  138. package/src/RadioList/RadioListItem.tsx +20 -0
  139. package/src/SideNav/SideNav.doc.mjs +13 -4
  140. package/src/SideNav/SideNav.test.tsx +858 -2
  141. package/src/SideNav/SideNav.tsx +37 -16
  142. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  143. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  144. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  145. package/src/SideNav/SideNavHeading.tsx +53 -25
  146. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  147. package/src/SideNav/SideNavItem.tsx +97 -84
  148. package/src/SideNav/SideNavSection.tsx +6 -20
  149. package/src/SideNav/index.ts +2 -0
  150. package/src/Slider/Slider.test.tsx +69 -33
  151. package/src/Slider/Slider.tsx +65 -13
  152. package/src/Switch/Switch.tsx +20 -0
  153. package/src/TabList/TabList.doc.mjs +3 -0
  154. package/src/Text/Text.doc.mjs +1 -1
  155. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  156. package/src/Thumbnail/Thumbnail.tsx +10 -0
  157. package/src/Timestamp/Timestamp.test.tsx +40 -22
  158. package/src/Toolbar/Toolbar.test.tsx +14 -14
  159. package/src/TopNav/TopNav.test.tsx +86 -1
  160. package/src/TopNav/TopNavHeading.tsx +21 -14
  161. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  162. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  163. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  164. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  165. package/src/TopNav/TopNavMenu.tsx +14 -4
  166. package/src/TreeList/TreeList.doc.mjs +1 -0
  167. package/src/hooks/index.ts +5 -1
  168. package/src/hooks/useFocusTrap.test.tsx +16 -0
  169. package/src/hooks/useFocusTrap.ts +20 -2
  170. package/src/hooks/useMenuHover.test.tsx +364 -0
  171. package/src/hooks/useMenuHover.ts +243 -47
  172. package/src/theme/defineTheme.ts +9 -0
  173. package/src/theme/derivedVarRegistry.test.ts +78 -12
  174. package/src/theme/derivedVarRegistry.ts +4 -0
  175. package/src/theme/expandColorScale.ts +1 -0
  176. package/src/theme/expandMotionScale.ts +1 -0
  177. package/src/theme/expandRadiusScale.ts +1 -0
  178. package/src/theme/expandTypeScale.ts +1 -0
  179. package/src/theme/themingTargets.test.ts +152 -26
  180. package/src/theme/tokens.stylex.ts +3 -1
  181. package/src/theme/tokens.test.ts +12 -0
  182. package/src/theme/useTheme.test.tsx +18 -0
@@ -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
+ });
@@ -50,6 +50,7 @@ import {
50
50
  borderVars,
51
51
  } from '../theme/tokens.stylex';
52
52
  import {usePopover} from '../Popover/usePopover';
53
+ import {useMenuHover} from '../hooks/useMenuHover';
53
54
  import {Grid} from '../Grid/Grid';
54
55
  import {Icon} from '../Icon';
55
56
  import {mergeProps, mergeRefs} from '../utils';
@@ -370,7 +371,9 @@ TopNavMegaMenu.displayName = 'TopNavMegaMenu';
370
371
  // DefaultMegaMenu — desktop popover mode
371
372
  // =============================================================================
372
373
 
373
- const CLICK_GUARD_MS = 500;
374
+ /** The panel is a grid of links, not `role="menuitem"` rows. */
375
+ const PANEL_ITEM_SELECTOR =
376
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
374
377
 
375
378
  function DefaultMegaMenu({
376
379
  ref,
@@ -382,19 +385,13 @@ function DefaultMegaMenu({
382
385
  onOpenChange,
383
386
  }: TopNavMegaMenuProps) {
384
387
  const slot = useTopNavSlot();
385
- const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
386
- const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
387
388
  const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
388
- const hoverOpenedAtRef = useRef(0);
389
- const stickyRef = useRef(false);
390
389
 
391
390
  const handlePopoverShow = useCallback(() => {
392
391
  onOpenChange?.(true);
393
392
  }, [onOpenChange]);
394
393
 
395
394
  const handlePopoverHide = useCallback(() => {
396
- hoverOpenedAtRef.current = 0;
397
- stickyRef.current = false;
398
395
  onOpenChange?.(false);
399
396
  }, [onOpenChange]);
400
397
 
@@ -430,107 +427,31 @@ function DefaultMegaMenu({
430
427
  };
431
428
  }, [popover]);
432
429
 
433
- const clearTimeouts = useCallback(() => {
434
- if (showTimeoutRef.current) {
435
- clearTimeout(showTimeoutRef.current);
436
- showTimeoutRef.current = null;
437
- }
438
- if (hideTimeoutRef.current) {
439
- clearTimeout(hideTimeoutRef.current);
440
- hideTimeoutRef.current = null;
441
- }
442
- }, []);
443
-
444
- const scheduleShow = useCallback(() => {
445
- clearTimeouts();
446
- showTimeoutRef.current = setTimeout(() => {
447
- hoverOpenedAtRef.current = Date.now();
448
- popover.show({skipAutoFocus: true});
449
- }, delay);
450
- }, [clearTimeouts, delay, popover]);
451
-
452
- const scheduleHide = useCallback(() => {
453
- clearTimeouts();
454
- hideTimeoutRef.current = setTimeout(() => {
455
- popover.hide();
456
- }, hideDelay);
457
- }, [clearTimeouts, hideDelay, popover]);
458
-
459
- const focusFirstPanelItem = useCallback(() => {
460
- popover.contentRef.current
461
- ?.querySelector<HTMLElement>(
462
- 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
463
- )
464
- ?.focus();
465
- }, [popover.contentRef]);
466
-
467
- const handleTriggerMouseEnter = useCallback(() => {
468
- clearTimeouts();
469
- if (!popover.isOpen) {
470
- scheduleShow();
471
- }
472
- }, [clearTimeouts, popover.isOpen, scheduleShow]);
473
-
474
- const handlePanelMouseEnter = useCallback(() => {
475
- clearTimeouts();
476
- }, [clearTimeouts]);
477
-
478
- const handleMouseLeave = useCallback(() => {
479
- if (!stickyRef.current) {
480
- scheduleHide();
481
- }
482
- }, [scheduleHide]);
483
-
484
- const handleClick = useCallback(
485
- (event: React.MouseEvent<HTMLButtonElement>) => {
486
- // Cancel the native invoker toggle so this guard is the single source of
487
- // truth for trigger activation. popoverTarget still establishes the
488
- // invoker relationship used by native light-dismiss and stacking.
489
- event.preventDefault();
490
- clearTimeouts();
491
-
492
- if (event.detail === 0) {
493
- stickyRef.current = true;
494
- hoverOpenedAtRef.current = 0;
495
- if (popover.isOpen) {
496
- focusFirstPanelItem();
497
- } else {
498
- popover.show();
499
- }
500
- } else if (!popover.isOpen) {
501
- stickyRef.current = true;
502
- popover.show({skipAutoFocus: true});
503
- } else if (Date.now() - hoverOpenedAtRef.current < CLICK_GUARD_MS) {
504
- // A click that naturally follows a hover-open confirms the open state
505
- // instead of toggling the panel shut. From here it behaves like any
506
- // other click-open and stays pinned until explicit dismissal.
507
- stickyRef.current = true;
508
- hoverOpenedAtRef.current = 0;
509
- } else {
510
- popover.hide();
511
- triggerButtonRef.current?.focus();
512
- }
513
- },
514
- [clearTimeouts, focusFirstPanelItem, popover],
515
- );
516
-
517
- useEffect(() => {
518
- return () => {
519
- clearTimeouts();
520
- };
521
- }, [clearTimeouts]);
430
+ const {
431
+ triggerProps: hoverTriggerProps,
432
+ contentProps,
433
+ menuRef,
434
+ setTriggerEl,
435
+ } = useMenuHover<HTMLDivElement>({
436
+ show: popover.show,
437
+ hide: popover.hide,
438
+ isOpen: popover.isOpen,
439
+ isEnabled: true,
440
+ showDelay: delay,
441
+ hideDelay,
442
+ itemSelector: PANEL_ITEM_SELECTOR,
443
+ // Trigger sits outside an auto popover; the invoker relationship exempts it
444
+ // from light dismiss.
445
+ popoverId: popover.id,
446
+ });
522
447
 
523
448
  return (
524
449
  <>
525
450
  <button
526
- ref={mergeRefs(triggerButtonRef, ref)}
451
+ ref={mergeRefs(triggerButtonRef, setTriggerEl, ref)}
527
452
  type="button"
528
453
  {...popover.triggerProps}
529
- // Native invoker prevents trigger light-dismiss.
530
- popoverTarget={popover.id}
531
- onClick={handleClick}
532
- onMouseEnter={handleTriggerMouseEnter}
533
- onMouseLeave={handleMouseLeave}
454
+ {...hoverTriggerProps}
534
455
  {...mergeProps(
535
456
  themeProps('top-nav-mega-menu'),
536
457
  focusOutlineProps.focusVisible(
@@ -551,10 +472,10 @@ function DefaultMegaMenu({
551
472
  // role="group" — a mega menu is a browsing grid of links, not an
552
473
  // ARIA menu of menuitems (per the WAI-ARIA APG, the menu role is
553
474
  // for action menus; link mega menus are the documented anti-case).
475
+ ref={menuRef}
554
476
  role="group"
555
477
  aria-label={label}
556
- onMouseEnter={handlePanelMouseEnter}
557
- onMouseLeave={handleMouseLeave}
478
+ {...contentProps}
558
479
  {...stylex.props(styles.panelContainer)}>
559
480
  <div {...stylex.props(styles.panelContent)}>
560
481
  {/* Menu items section */}
@@ -602,7 +523,10 @@ function DrawerMegaMenu({
602
523
  aria-controls={`${menuId}-items`}
603
524
  {...mergeProps(
604
525
  themeProps('top-nav-mega-menu', {mode: 'drawer'}),
605
- stylex.props(navItemStyles.item, styles.drawerHeader),
526
+ focusOutlineProps.focusVisible(
527
+ navItemStyles.item,
528
+ styles.drawerHeader,
529
+ ),
606
530
  )}>
607
531
  {label}
608
532
  <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
+ });
@@ -342,7 +342,7 @@ export function TopNavMenu({
342
342
  role: 'none',
343
343
  });
344
344
 
345
- const {triggerProps, contentProps, menuRef, setTriggerEl} =
345
+ const {triggerProps, contentProps, menuRef, setTriggerEl, close} =
346
346
  useMenuHover<HTMLDivElement>({
347
347
  show: popover.show,
348
348
  hide: popover.hide,
@@ -350,6 +350,9 @@ export function TopNavMenu({
350
350
  isEnabled: true,
351
351
  showDelay: delay,
352
352
  hideDelay,
353
+ // Trigger sits outside an auto popover; the invoker relationship exempts
354
+ // it from light dismiss.
355
+ popoverId: popover.id,
353
356
  });
354
357
 
355
358
  const setTriggerRef = mergeRefs<HTMLButtonElement>(
@@ -368,7 +371,8 @@ export function TopNavMenu({
368
371
  useListFocus<HTMLDivElement>({
369
372
  itemSelector: '[role="menuitem"]',
370
373
  hasRovingTabIndex: true,
371
- onEscape: popover.hide,
374
+ // Not popover.hide: Escape must also restore focus to the trigger.
375
+ onEscape: close,
372
376
  });
373
377
 
374
378
  // First-character typeahead over the menu items (menus-11).
@@ -432,7 +436,10 @@ export function TopNavMenu({
432
436
  onClick={() => setDrawerExpanded(v => !v)}
433
437
  aria-expanded={drawerExpanded}
434
438
  aria-controls={`${menuId}-items`}
435
- {...stylex.props(navItemStyles.item, drawerStyles.header)}>
439
+ {...focusOutlineProps.focusVisible(
440
+ navItemStyles.item,
441
+ drawerStyles.header,
442
+ )}>
436
443
  {label}
437
444
  <Icon
438
445
  icon="chevronDown"
@@ -459,7 +466,10 @@ export function TopNavMenu({
459
466
  item.onClick?.();
460
467
  closeMobileNav();
461
468
  }}
462
- {...stylex.props(navItemStyles.item, drawerStyles.item)}>
469
+ {...focusOutlineProps.focusVisible(
470
+ navItemStyles.item,
471
+ drawerStyles.item,
472
+ )}>
463
473
  {item.icon && (
464
474
  <span {...stylex.props(drawerStyles.itemIcon)}>
465
475
  {item.icon}
@@ -33,6 +33,7 @@ export const docs = {
33
33
  vars: [
34
34
  {name: '--tree-list-indent', description: 'Per-level indentation step. Each nesting level indents its rows by this distance, and the guide lines follow it so they stay aligned. Set it on the `tree-list` target to retune the metric (e.g. `var(--spacing-5)` for a wider indent).', default: 'var(--spacing-4)'},
35
35
  {name: '--tree-list-row-gap', description: 'Vertical gap between adjacent rows. Default `2px` (var(--spacing-0-5)) gives a subtle separation; set it on the `tree-list` target to widen or close the gap. The connector guides span the gap automatically (the line stays continuous) and do not overhang the last row, so no guide-height tuning is needed.', default: 'var(--spacing-0-5)'},
36
+ {name: '--_tree-indent', description: 'Distance one row is indented, computed per row from --tree-list-indent and the row depth. Set --tree-list-indent to retune indentation; this is the resolved value.', default: '0px', private: true},
36
37
  ],
37
38
  },
38
39
  components: [
@@ -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';
@@ -192,6 +192,22 @@ describe('useFocusTrap tabbable model (infra-8)', () => {
192
192
  fireEvent.keyDown(document, {key: 'Tab'});
193
193
  expect(screen.getByTestId('first')).toHaveFocus();
194
194
  });
195
+
196
+ it('keeps a programmatic focus target when the trap has no tabbable controls', () => {
197
+ render(
198
+ <Trap>
199
+ <div tabIndex={-1} data-testid="programmatic-target">
200
+ Read-only dialog content
201
+ </div>
202
+ </Trap>,
203
+ );
204
+ const target = screen.getByTestId('programmatic-target');
205
+ target.focus();
206
+
207
+ expect(fireEvent.keyDown(target, {key: 'Tab'})).toBe(false);
208
+ expect(target).toHaveFocus();
209
+ expect(screen.getByTestId('outside')).not.toHaveFocus();
210
+ });
195
211
  });
196
212
 
197
213
  describe('FOCUSABLE_SELECTOR href matching', () => {
@@ -332,12 +332,21 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
332
332
  lastFocusRef.current = target as Element;
333
333
  } else if (isKeyboardNavigationRef.current) {
334
334
  // Focus escaped via keyboard - redirect it back
335
- focusFirstDescendant(container);
335
+ const focusedFirst = focusFirstDescendant(container);
336
336
 
337
337
  // If we're back at the same element (Shift+Tab from first element),
338
338
  // try focusing the last element instead
339
- if (lastFocusRef.current === document.activeElement) {
339
+ if (focusedFirst && lastFocusRef.current === document.activeElement) {
340
340
  focusLastDescendant(container);
341
+ } else if (
342
+ !focusedFirst &&
343
+ lastFocusRef.current instanceof HTMLElement &&
344
+ container.contains(lastFocusRef.current)
345
+ ) {
346
+ // A modal surface may intentionally have no tabbable controls and
347
+ // place initial focus on a tabIndex={-1} heading or panel. Preserve
348
+ // that programmatic focus target instead of letting Tab escape.
349
+ attemptFocus(lastFocusRef.current);
341
350
  }
342
351
 
343
352
  lastFocusRef.current = document.activeElement;
@@ -410,6 +419,15 @@ export function useFocusTrap<T extends HTMLElement = HTMLElement>(
410
419
 
411
420
  const focusable = getFocusableElements(container);
412
421
  if (focusable.length === 0) {
422
+ // There is nowhere to advance to. Keep focus on the current
423
+ // programmatic target (for example a dialog panel with tabIndex=-1)
424
+ // rather than allowing the browser to move into background content.
425
+ event.preventDefault();
426
+ const active = document.activeElement;
427
+ if (active instanceof HTMLElement && container.contains(active)) {
428
+ lastFocusRef.current = active;
429
+ }
430
+ isKeyboardNavigationRef.current = false;
413
431
  return;
414
432
  }
415
433