@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9

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 (201) 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 +40 -8
  72. package/dist/astryx.umd.js +50 -50
  73. package/dist/astryx.umd.js.map +4 -4
  74. package/dist/hooks/containerReveal.stylex.d.ts +71 -4
  75. package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
  76. package/dist/hooks/containerReveal.stylex.js +57 -5
  77. package/dist/hooks/index.d.ts +2 -2
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/index.js +1 -1
  80. package/dist/hooks/useContainerReveal.d.ts +60 -3
  81. package/dist/hooks/useContainerReveal.d.ts.map +1 -1
  82. package/dist/hooks/useContainerReveal.js +27 -5
  83. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  84. package/dist/hooks/useFocusTrap.js +16 -2
  85. package/dist/hooks/useMenuHover.d.ts +50 -5
  86. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  87. package/dist/hooks/useMenuHover.js +171 -51
  88. package/dist/theme/defineTheme.d.ts +16 -5
  89. package/dist/theme/defineTheme.d.ts.map +1 -1
  90. package/dist/theme/defineTheme.js +44 -48
  91. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  92. package/dist/theme/derivedVarRegistry.js +7 -0
  93. package/dist/theme/expandColorScale.d.ts.map +1 -1
  94. package/dist/theme/expandColorScale.js +1 -0
  95. package/dist/theme/expandMotionScale.d.ts +1 -0
  96. package/dist/theme/expandMotionScale.d.ts.map +1 -1
  97. package/dist/theme/expandMotionScale.js +1 -0
  98. package/dist/theme/expandRadiusScale.d.ts +1 -0
  99. package/dist/theme/expandRadiusScale.d.ts.map +1 -1
  100. package/dist/theme/expandRadiusScale.js +1 -0
  101. package/dist/theme/expandTypeScale.d.ts +1 -0
  102. package/dist/theme/expandTypeScale.d.ts.map +1 -1
  103. package/dist/theme/expandTypeScale.js +1 -0
  104. package/dist/theme/mergeComponents.d.ts +20 -0
  105. package/dist/theme/mergeComponents.d.ts.map +1 -0
  106. package/dist/theme/mergeComponents.js +56 -0
  107. package/dist/theme/onMediaTokens.d.ts +6 -1
  108. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  109. package/dist/theme/onMediaTokens.js +11 -3
  110. package/dist/theme/tokens.stylex.d.ts +3 -1
  111. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  112. package/dist/theme/tokens.stylex.js +3 -1
  113. package/locales/en.json +84 -0
  114. package/locales/pseudo.json +63 -0
  115. package/package.json +3 -3
  116. package/src/Avatar/Avatar.doc.mjs +5 -2
  117. package/src/Avatar/Avatar.test.tsx +51 -0
  118. package/src/Avatar/Avatar.tsx +30 -20
  119. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  120. package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
  121. package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
  122. package/src/Button/Button.tsx +5 -3
  123. package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
  124. package/src/Card/Card.doc.mjs +2 -0
  125. package/src/Chat/Chat.doc.mjs +4 -1
  126. package/src/Chat/ChatMessage.doc.mjs +3 -3
  127. package/src/Chat/ChatMessage.tsx +7 -0
  128. package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
  129. package/src/Chat/ChatMessageBubble.test.tsx +95 -6
  130. package/src/Chat/ChatMessageBubble.tsx +25 -0
  131. package/src/CheckboxInput/CheckboxInput.tsx +20 -0
  132. package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
  133. package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
  134. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  135. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
  136. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
  137. package/src/HoverCard/HoverCard.doc.mjs +3 -3
  138. package/src/HoverCard/HoverCard.test.tsx +283 -55
  139. package/src/HoverCard/HoverCard.tsx +5 -13
  140. package/src/HoverCard/useHoverCard.tsx +8 -11
  141. package/src/InputGroup/groupStyles.ts +7 -8
  142. package/src/Item/Item.doc.mjs +4 -0
  143. package/src/Layer/layerHost.test.ts +99 -0
  144. package/src/Layer/layerHost.ts +141 -0
  145. package/src/Layer/useLayer.doc.mjs +14 -4
  146. package/src/Layer/useLayer.test.tsx +332 -7
  147. package/src/Layer/useLayer.tsx +235 -36
  148. package/src/MobileNav/MobileNav.doc.mjs +7 -0
  149. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  150. package/src/RadioList/RadioListItem.tsx +20 -0
  151. package/src/SideNav/SideNav.doc.mjs +13 -4
  152. package/src/SideNav/SideNav.test.tsx +858 -2
  153. package/src/SideNav/SideNav.tsx +37 -16
  154. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  155. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  156. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  157. package/src/SideNav/SideNavHeading.tsx +53 -25
  158. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  159. package/src/SideNav/SideNavItem.tsx +97 -84
  160. package/src/SideNav/SideNavSection.tsx +6 -20
  161. package/src/SideNav/index.ts +2 -0
  162. package/src/Slider/Slider.test.tsx +69 -33
  163. package/src/Slider/Slider.tsx +65 -13
  164. package/src/Switch/Switch.tsx +20 -0
  165. package/src/TabList/TabList.doc.mjs +3 -0
  166. package/src/Text/Text.doc.mjs +1 -1
  167. package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
  168. package/src/Thumbnail/Thumbnail.tsx +10 -0
  169. package/src/Timestamp/Timestamp.test.tsx +40 -22
  170. package/src/Toolbar/Toolbar.test.tsx +14 -14
  171. package/src/TopNav/TopNav.test.tsx +86 -1
  172. package/src/TopNav/TopNavHeading.tsx +21 -14
  173. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  174. package/src/TopNav/TopNavMegaMenu.tsx +29 -105
  175. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  176. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  177. package/src/TopNav/TopNavMenu.tsx +14 -4
  178. package/src/TreeList/TreeList.doc.mjs +1 -0
  179. package/src/hooks/containerReveal.stylex.ts +140 -9
  180. package/src/hooks/index.ts +6 -1
  181. package/src/hooks/useContainerReveal.doc.mjs +11 -5
  182. package/src/hooks/useContainerReveal.test.tsx +70 -3
  183. package/src/hooks/useContainerReveal.ts +86 -7
  184. package/src/hooks/useFocusTrap.test.tsx +16 -0
  185. package/src/hooks/useFocusTrap.ts +20 -2
  186. package/src/hooks/useMenuHover.test.tsx +364 -0
  187. package/src/hooks/useMenuHover.ts +243 -47
  188. package/src/theme/defineTheme.test.ts +127 -0
  189. package/src/theme/defineTheme.ts +57 -51
  190. package/src/theme/derivedVarRegistry.test.ts +78 -12
  191. package/src/theme/derivedVarRegistry.ts +4 -0
  192. package/src/theme/expandColorScale.ts +1 -0
  193. package/src/theme/expandMotionScale.ts +1 -0
  194. package/src/theme/expandRadiusScale.ts +1 -0
  195. package/src/theme/expandTypeScale.ts +1 -0
  196. package/src/theme/mergeComponents.ts +59 -0
  197. package/src/theme/onMediaTokens.ts +9 -2
  198. package/src/theme/themingTargets.test.ts +152 -26
  199. package/src/theme/tokens.stylex.ts +3 -1
  200. package/src/theme/tokens.test.ts +12 -0
  201. package/src/theme/useTheme.test.tsx +18 -0
@@ -4,12 +4,17 @@
4
4
 
5
5
  /**
6
6
  * @file SideNavItem.tsx
7
- * @input Uses React, ReactNode, StyleX, Icon, IconType
7
+ * @input Uses React, ReactNode, StyleX, Icon, IconType, useMenuHover
8
8
  * @output Exports SideNavItem component and SideNavItemProps
9
9
  * @position Core implementation; used inside SideNav children
10
10
  *
11
11
  * Navigation item with icon, selected state, and nesting.
12
12
  *
13
+ * Collapsed items with children open their submenu flyout through
14
+ * `useMenuHover`, the shared hover-intent hook (same one `SideNavHeading` and
15
+ * `TopNavMenu` use). Hover is a progressive enhancement over the popover's
16
+ * click behavior and is inert on coarse pointers.
17
+ *
13
18
  * SYNC: When modified, update these files to stay in sync:
14
19
  * - /packages/core/src/SideNav/SideNav.doc.mjs
15
20
  * - /packages/core/src/SideNav/SideNav.test.tsx
@@ -35,17 +40,18 @@ import {
35
40
  typeScaleVars,
36
41
  durationVars,
37
42
  easeVars,
38
- borderVars,
39
43
  radiusVars,
40
44
  } from '../theme/tokens.stylex';
41
45
  import {Icon, renderIconSlot, type IconType} from '../Icon';
42
46
  import {useLinkComponent} from '../Link/useLinkComponent';
43
47
  import type {LinkComponentType} from '../Link/types';
44
48
  import {usePopover} from '../Popover/usePopover';
49
+ import {useMenuHover} from '../hooks/useMenuHover';
45
50
  import {mergeProps, mergeRefs} from '../utils';
46
51
  import type {BaseProps} from '../BaseProps';
47
52
  import {Tooltip} from '../Tooltip';
48
53
  import {navItemStyles, type NavItemSize} from '../NavItem/navItemStyles.stylex';
54
+ import {focusOutlineProps} from '../utils/focusOutline.stylex';
49
55
  import {
50
56
  useSideNavCollapse,
51
57
  SideNavCollapseContext,
@@ -91,7 +97,10 @@ const styles = stylex.create({
91
97
  display: 'grid',
92
98
  gridTemplateRows: '1fr',
93
99
  transitionProperty: 'grid-template-rows',
94
- transitionDuration: durationVars['--duration-medium'],
100
+ transitionDuration: {
101
+ default: durationVars['--duration-medium'],
102
+ '@media (prefers-reduced-motion: reduce)': '0s',
103
+ },
95
104
  transitionTimingFunction: easeVars['--ease-standard'],
96
105
  },
97
106
  childrenCollapsed: {
@@ -114,7 +123,10 @@ const styles = stylex.create({
114
123
  // target, not the glyph size, so keep the glyph on the inherited size.
115
124
  fontSize: 'inherit',
116
125
  transitionProperty: 'transform',
117
- transitionDuration: durationVars['--duration-fast'],
126
+ transitionDuration: {
127
+ default: durationVars['--duration-fast'],
128
+ '@media (prefers-reduced-motion: reduce)': '0s',
129
+ },
118
130
  transitionTimingFunction: easeVars['--ease-standard'],
119
131
  flexShrink: 0,
120
132
  },
@@ -146,10 +158,14 @@ const styles = stylex.create({
146
158
  },
147
159
  // Primary action element inside the split-action row (link or button).
148
160
  // Flex:1 so it fills remaining space, giving a wide click target.
161
+ // `alignSelf: stretch` makes it fill the row's height too — without it the
162
+ // element collapses to its line box (20px), under the 24px WCAG 2.5.8
163
+ // target-size floor even though the row it sits in is 32px tall.
149
164
  // Resets both link and button appearance so it blends into the row.
150
165
  splitAction: {
151
166
  display: 'flex',
152
167
  alignItems: 'center',
168
+ alignSelf: 'stretch',
153
169
  gap: spacingVars['--spacing-2'],
154
170
  flex: 1,
155
171
  minWidth: 0,
@@ -167,16 +183,23 @@ const styles = stylex.create({
167
183
  textAlign: 'start',
168
184
  cursor: 'pointer',
169
185
  },
170
- // Popover surface for collapsed items with children
186
+ // No border and no background: `usePopover` paints the panel this renders
187
+ // into. Drawing a second surface here put square corners inside its rounded
188
+ // ones. The radius matches so a theme retargeting `--radius-container`
189
+ // keeps the two in step.
171
190
  popoverSurface: {
172
- borderWidth: borderVars['--border-width'],
173
- borderStyle: 'solid',
174
- borderColor: colorVars['--color-border'],
191
+ borderRadius: radiusVars['--radius-container'],
175
192
  paddingBlock: spacingVars['--spacing-1'],
176
193
  paddingInline: spacingVars['--spacing-1'],
177
- marginInlineStart: spacingVars['--spacing-1'],
178
194
  minWidth: 180,
179
195
  },
196
+ // The gap from the rail belongs on the positioned layer, where
197
+ // `DropdownMenu` keeps it. On the content div it insets the content instead,
198
+ // leaving the panel flush against the rail.
199
+ popoverGap: {
200
+ marginInlineStart: spacingVars['--spacing-1'],
201
+ marginInlineEnd: spacingVars['--spacing-1'],
202
+ },
180
203
  popoverHeader: {
181
204
  paddingInline: spacingVars['--spacing-2'],
182
205
  paddingBlock: spacingVars['--spacing-1'],
@@ -361,6 +384,7 @@ export function SideNavItem({
361
384
  'data-testid': testId,
362
385
  ref,
363
386
  xstyle,
387
+ ...rest
364
388
  }: SideNavItemProps) {
365
389
  const t = useTranslator();
366
390
  const {isCollapsed} = useSideNavCollapse();
@@ -376,7 +400,7 @@ export function SideNavItem({
376
400
  hasLightDismiss: true,
377
401
  hasAutoFocus: true,
378
402
  hasCloseButton: false,
379
- dialogLabel: `${label} submenu`,
403
+ dialogLabel: t('@astryx.sideNavItem.submenuLabel', {label}),
380
404
  });
381
405
 
382
406
  // Collapse state for items with children
@@ -434,42 +458,22 @@ export function SideNavItem({
434
458
  toggleItemCollapse();
435
459
  };
436
460
 
437
- // Hover handlers for collapsed popover (mirrors TopNavMenu pattern)
438
- const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
439
- const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
440
-
441
- const clearPopoverTimeouts = useCallback(() => {
442
- if (showTimeoutRef.current) {
443
- clearTimeout(showTimeoutRef.current);
444
- showTimeoutRef.current = null;
445
- }
446
- if (hideTimeoutRef.current) {
447
- clearTimeout(hideTimeoutRef.current);
448
- hideTimeoutRef.current = null;
449
- }
450
- }, []);
451
-
452
- const schedulePopoverShow = useCallback(() => {
453
- clearPopoverTimeouts();
454
- showTimeoutRef.current = setTimeout(() => {
455
- popover.show({skipAutoFocus: true});
456
- }, 150);
457
- }, [clearPopoverTimeouts, popover]);
458
-
459
- const schedulePopoverHide = useCallback(() => {
460
- clearPopoverTimeouts();
461
- hideTimeoutRef.current = setTimeout(() => {
462
- popover.hide();
463
- }, 200);
464
- }, [clearPopoverTimeouts, popover]);
465
-
466
- const handlePopoverMouseEnter = useCallback(() => {
467
- schedulePopoverShow();
468
- }, [schedulePopoverShow]);
469
-
470
- const handlePopoverMouseLeave = useCallback(() => {
471
- schedulePopoverHide();
472
- }, [schedulePopoverHide]);
461
+ // Pointer half only. The hook's `onKeyDown`/`menuRef` drive a `useListFocus`
462
+ // over `[role="menuitem"]`, and this flyout is a focus-trapped dialog of
463
+ // links — wiring them would swallow arrow keys rather than navigate with
464
+ // them. Keyboard stays with `usePopover`'s trap, as in DropdownMenuSubMenu.
465
+ const {triggerProps: hoverTriggerProps, contentProps: hoverContentProps} =
466
+ useMenuHover({
467
+ show: popover.show,
468
+ hide: popover.hide,
469
+ isOpen: popover.isOpen,
470
+ isEnabled: isCollapsed && hasChildren,
471
+ // Standard popover toggling: the flyout opens beside the rail, not over
472
+ // the icon, so the click after a hover-open is a deliberate dismissal
473
+ // rather than the #3121 confirmation the nav menus need.
474
+ clickGuardMs: 0,
475
+ ownsFocus: false,
476
+ });
473
477
 
474
478
  // In collapsed mode: hide items without icons
475
479
  if (isCollapsed && !icon) {
@@ -484,16 +488,21 @@ export function SideNavItem({
484
488
  displayIcon &&
485
489
  renderIconSlot(displayIcon, {
486
490
  size: 'sm',
487
- color: isSelected ? 'primary' : isDisabled ? 'disabled' : 'secondary',
491
+ // `inherit` so a selected row's icon follows the row to HighlightText
492
+ // under forced colors. Identical to `primary` otherwise: both token
493
+ // families are emitted from one expression.
494
+ color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary',
488
495
  });
489
496
 
490
- // Shared collapsed item styles — used by trigger, link, and button
497
+ // Shared collapsed item styles — used by trigger, link, and button.
498
+ // All three render a focusable element, so each draws the shared ring.
491
499
  const collapsedItemStyles = mergeProps(
492
500
  themeProps('side-nav-item', {
493
501
  size,
494
502
  selected: isSelected ? 'selected' : null,
503
+ disabled: isDisabled ? 'disabled' : null,
495
504
  }),
496
- stylex.props(
505
+ focusOutlineProps.focusVisible(
497
506
  navItemStyles.item,
498
507
  navItemStyles[size],
499
508
  styles.itemCollapsed,
@@ -511,9 +520,8 @@ export function SideNavItem({
511
520
  <button
512
521
  ref={mergeRefs(ref, popover.triggerRef)}
513
522
  type="button"
514
- onClick={popover.toggle}
515
- onMouseEnter={handlePopoverMouseEnter}
516
- onMouseLeave={handlePopoverMouseLeave}
523
+ {...rest}
524
+ {...hoverTriggerProps}
517
525
  aria-label={label}
518
526
  data-testid={testId}
519
527
  {...popover.triggerProps}
@@ -523,15 +531,15 @@ export function SideNavItem({
523
531
  {popover.render(
524
532
  <div
525
533
  {...stylex.props(styles.popoverSurface)}
526
- onMouseEnter={handlePopoverMouseEnter}
527
- onMouseLeave={handlePopoverMouseLeave}
534
+ onMouseEnter={hoverContentProps.onMouseEnter}
535
+ onMouseLeave={hoverContentProps.onMouseLeave}
528
536
  onClick={() => popover.hide()}>
529
537
  <div {...stylex.props(styles.popoverHeader)}>{label}</div>
530
538
  <SideNavCollapseContext value={EXPANDED_COLLAPSE_STATE}>
531
539
  {children}
532
540
  </SideNavCollapseContext>
533
541
  </div>,
534
- {placement: 'end', alignment: 'start'},
542
+ {placement: 'end', alignment: 'start', xstyle: styles.popoverGap},
535
543
  )}
536
544
  </div>
537
545
  );
@@ -552,6 +560,7 @@ export function SideNavItem({
552
560
  as={as}
553
561
  isDisabled={isDisabled}
554
562
  onClick={handleClick}
563
+ {...rest}
555
564
  {...collapsedAriaProps}
556
565
  {...collapsedItemStyles}>
557
566
  {collapsedIcon}
@@ -571,7 +580,8 @@ export function SideNavItem({
571
580
  {displayIcon &&
572
581
  renderIconSlot(displayIcon, {
573
582
  size: 'sm',
574
- color: isSelected ? 'primary' : isDisabled ? 'disabled' : 'secondary',
583
+ // `inherit` — see the collapsed path above.
584
+ color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary',
575
585
  })}
576
586
  {!isCollapsed && <span {...stylex.props(styles.label)}>{label}</span>}
577
587
  {!isCollapsed && endContent && (
@@ -591,45 +601,47 @@ export function SideNavItem({
591
601
  </>
592
602
  );
593
603
 
594
- const navItemStyleProps = mergeProps(
595
- themeProps('side-nav-item', {
596
- size,
597
- selected: isSelected ? 'selected' : null,
598
- }),
599
- stylex.props(
600
- navItemStyles.item,
601
- navItemStyles[size],
602
- isSelected && navItemStyles.selected,
603
- isDisabled && navItemStyles.disabled,
604
- ),
605
- );
606
-
607
- // ── Split-action path: <div> row with <a> + <button> as siblings ──
608
- //
609
- // When both collapsible and href are set, we can't nest a <button>
610
- // inside an <a>. Instead we use a <div> as the flex row container,
611
- // an <a display:contents> for the link (with ::before covering the row),
612
- // and a <button> sibling lifted above the overlay via z-index.
604
+ const itemThemeProps = themeProps('side-nav-item', {
605
+ size,
606
+ selected: isSelected ? 'selected' : null,
607
+ disabled: isDisabled ? 'disabled' : null,
608
+ });
613
609
 
614
- // ── Split-action path: primary element + toggle button as siblings ──
615
- //
616
- // When collapsible and a primary action (href or onClick) are both set,
617
- // we render a <div> row with the primary element and chevron toggle as
618
- // siblings. This avoids nesting interactive elements (<button> inside <a>).
610
+ const itemStyleArgs = [
611
+ navItemStyles.item,
612
+ navItemStyles[size],
613
+ isSelected && navItemStyles.selected,
614
+ isDisabled && navItemStyles.disabled,
615
+ ] as const;
616
+
617
+ // Two shapes of the same row appearance:
618
+ // - `rowProps` for the split-action path, where the row is a plain <div>
619
+ // container and its children take focus (so the ring belongs on them);
620
+ // - `focusableRowProps` for every other path, where the row element is
621
+ // itself the focusable control.
622
+ const rowProps = mergeProps(itemThemeProps, stylex.props(...itemStyleArgs));
623
+ const focusableRowProps = mergeProps(
624
+ itemThemeProps,
625
+ focusOutlineProps.focusVisible(...itemStyleArgs),
626
+ );
619
627
 
628
+ // Split-action path: collapsible plus a primary action renders the primary
629
+ // element and the chevron toggle as siblings in a <div> row, since a
630
+ // <button> cannot nest inside an <a>.
620
631
  let itemElement;
621
632
 
622
633
  if (hasIndependentToggle) {
623
634
  itemElement = (
624
- <div data-testid={testId} {...navItemStyleProps}>
635
+ <div data-testid={testId} {...rowProps}>
625
636
  <NavItemElement
626
637
  ref={ref}
627
638
  href={href}
628
639
  as={as}
629
640
  isDisabled={isDisabled}
630
641
  onClick={handleClick}
642
+ {...rest}
631
643
  aria-current={isSelected ? ('page' as const) : undefined}
632
- {...stylex.props(styles.splitAction)}>
644
+ {...focusOutlineProps.focusVisible(styles.splitAction)}>
633
645
  {itemContent}
634
646
  </NavItemElement>
635
647
  <button
@@ -642,7 +654,7 @@ export function SideNavItem({
642
654
  }
643
655
  aria-expanded={!isItemCollapsed}
644
656
  aria-controls={`${id}-children`}
645
- {...stylex.props(styles.expandToggle)}>
657
+ {...focusOutlineProps.focusVisible(styles.expandToggle)}>
646
658
  <Icon
647
659
  icon="chevronDown"
648
660
  size="lg"
@@ -671,8 +683,9 @@ export function SideNavItem({
671
683
  as={as}
672
684
  isDisabled={isDisabled}
673
685
  onClick={handleClick}
686
+ {...rest}
674
687
  {...ariaProps}
675
- {...navItemStyleProps}>
688
+ {...focusableRowProps}>
676
689
  {itemContent}
677
690
  </NavItemElement>
678
691
  );
@@ -29,6 +29,7 @@ import {
29
29
  import {mergeProps} from '../utils';
30
30
  import type {BaseProps} from '../BaseProps';
31
31
  import {useSideNavCollapse} from './SideNavCollapseContext';
32
+ import {VisuallyHidden} from '../VisuallyHidden';
32
33
  import {themeProps} from '../utils/themeProps';
33
34
  // =============================================================================
34
35
  // Styles
@@ -167,20 +168,6 @@ export function SideNavSection({
167
168
 
168
169
  const shouldHideHeader = isHeaderHidden || isCollapsed;
169
170
 
170
- const visuallyHiddenStyle: React.CSSProperties = shouldHideHeader
171
- ? {
172
- position: 'absolute',
173
- width: 1,
174
- height: 1,
175
- padding: 0,
176
- margin: -1,
177
- overflow: 'hidden',
178
- clip: 'rect(0, 0, 0, 0)',
179
- whiteSpace: 'nowrap',
180
- borderWidth: 0,
181
- }
182
- : {};
183
-
184
171
  return (
185
172
  <div
186
173
  ref={ref}
@@ -194,12 +181,11 @@ export function SideNavSection({
194
181
  role="group"
195
182
  aria-labelledby={titleId}
196
183
  data-testid={testId}>
197
- <div
198
- {...mergeProps(stylex.props(styles.header), {
199
- style: shouldHideHeader ? visuallyHiddenStyle : undefined,
200
- })}>
201
- {headerContent}
202
- </div>
184
+ {shouldHideHeader ? (
185
+ <VisuallyHidden as="div">{headerContent}</VisuallyHidden>
186
+ ) : (
187
+ <div {...stylex.props(styles.header)}>{headerContent}</div>
188
+ )}
203
189
  <div {...stylex.props(styles.items)}>{children}</div>
204
190
  </div>
205
191
  );
@@ -29,6 +29,8 @@ export type {SideNavCollapseButtonProps} from './SideNavCollapseButton';
29
29
  export {useSideNavCollapse} from './SideNavCollapseContext';
30
30
  export type {
31
31
  SideNavCollapseState,
32
+ SideNavCollapsibleConfig,
33
+ SideNavControlledCollapsible,
32
34
  SideNavImperativeCollapseHandle,
33
35
  } from './SideNavCollapseContext';
34
36
 
@@ -81,8 +81,8 @@ describe('Slider', () => {
81
81
  });
82
82
 
83
83
  it.each([
84
- {value: 150, expectedValue: 100, expectedPosition: '100%'},
85
- {value: -50, expectedValue: 0, expectedPosition: '0%'},
84
+ {value: 150, expectedValue: 100, expectedPosition: 'calc(100% - 10px)'},
85
+ {value: -50, expectedValue: 0, expectedPosition: 'calc(0% + 10px)'},
86
86
  ])(
87
87
  'clamps a controlled value of $value to $expectedValue',
88
88
  ({value, expectedValue, expectedPosition}) => {
@@ -90,11 +90,47 @@ describe('Slider', () => {
90
90
  const slider = screen.getByRole('slider');
91
91
  expect(slider).toHaveAttribute('aria-valuenow', String(expectedValue));
92
92
  // Thumb positions via the logical `inset-inline-start` so it mirrors
93
- // under RTL (see RTL Phase 4). In LTR this resolves to the left edge.
93
+ // under RTL (see RTL Phase 4), offset by half a thumb so the extremes
94
+ // stay inside the component box.
94
95
  expect(slider).toHaveStyle({insetInlineStart: expectedPosition});
95
96
  },
96
97
  );
97
98
 
99
+ // Regression: #5050 — at min/max the thumb centred on the container edge,
100
+ // so half of it (10px of a 20px thumb) hung outside the component.
101
+ it.each([
102
+ {value: 0, position: 'calc(0% + 10px)'},
103
+ {value: 50, position: 'calc(50% + 0px)'},
104
+ {value: 100, position: 'calc(100% - 10px)'},
105
+ ])(
106
+ 'insets the thumb at value $value so it stays in bounds',
107
+ ({value, position}) => {
108
+ render(<Slider label="Volume" value={value} min={0} max={100} />);
109
+ expect(screen.getByRole('slider')).toHaveStyle({
110
+ insetInlineStart: position,
111
+ });
112
+ },
113
+ );
114
+
115
+ it.each([
116
+ {value: 0, position: 'calc(0% + 10px)'},
117
+ {value: 100, position: 'calc(100% - 10px)'},
118
+ ])(
119
+ 'insets a vertical thumb at value $value so it stays in bounds',
120
+ ({value, position}) => {
121
+ render(
122
+ <Slider
123
+ label="Volume"
124
+ value={value}
125
+ min={0}
126
+ max={100}
127
+ orientation="vertical"
128
+ />,
129
+ );
130
+ expect(screen.getByRole('slider')).toHaveStyle({bottom: position});
131
+ },
132
+ );
133
+
98
134
  it('range mode sets correct aria values on both thumbs', () => {
99
135
  render(
100
136
  <Slider
@@ -580,22 +616,23 @@ describe('Slider', () => {
580
616
  />,
581
617
  );
582
618
  const track = screen.getByRole('slider').parentElement!;
583
- track.getBoundingClientRect = () =>
584
- ({
585
- left: 0,
586
- top: 0,
587
- right: 200,
588
- bottom: 20,
589
- width: 200,
590
- height: 20,
591
- x: 0,
592
- y: 0,
593
- toJSON: () => {},
594
- });
619
+ track.getBoundingClientRect = () => ({
620
+ left: 0,
621
+ top: 0,
622
+ right: 200,
623
+ bottom: 20,
624
+ width: 200,
625
+ height: 20,
626
+ x: 0,
627
+ y: 0,
628
+ toJSON: () => {},
629
+ });
595
630
 
596
- // Click at 25% of the track from the left (x=50 of 200) → value 25 in LTR.
631
+ // Click at x=50 of a 200px track. The thumb's travel is the track minus
632
+ // half a thumb at each end (180px from x=10), so the fraction is
633
+ // (50 - 10) / 180 → value 22.
597
634
  fireEvent.pointerDown(track, {clientX: 50, clientY: 10, pointerId: 1});
598
- expect(handleChange).toHaveBeenLastCalledWith(25);
635
+ expect(handleChange).toHaveBeenLastCalledWith(22);
599
636
  });
600
637
 
601
638
  it('mirrors a track click to the RTL value when the track is rtl', () => {
@@ -611,18 +648,17 @@ describe('Slider', () => {
611
648
  />,
612
649
  );
613
650
  const track = screen.getByRole('slider').parentElement!;
614
- track.getBoundingClientRect = () =>
615
- ({
616
- left: 0,
617
- top: 0,
618
- right: 200,
619
- bottom: 20,
620
- width: 200,
621
- height: 20,
622
- x: 0,
623
- y: 0,
624
- toJSON: () => {},
625
- });
651
+ track.getBoundingClientRect = () => ({
652
+ left: 0,
653
+ top: 0,
654
+ right: 200,
655
+ bottom: 20,
656
+ width: 200,
657
+ height: 20,
658
+ x: 0,
659
+ y: 0,
660
+ toJSON: () => {},
661
+ });
626
662
 
627
663
  // Force the track's computed direction to rtl (isRtlElement reads this).
628
664
  const realGetComputedStyle = window.getComputedStyle;
@@ -635,11 +671,11 @@ describe('Slider', () => {
635
671
  return realGetComputedStyle(el, pseudo ?? undefined);
636
672
  });
637
673
 
638
- // Same physical click at 25% from the left (x=50). Under RTL the inline
639
- // start is the right edge, so fraction = (right - x)/width = 150/200 = 0.75
640
- // → value 75 (the mirror of the LTR value 25).
674
+ // Same physical click at x=50. Under RTL the inline start is the right
675
+ // edge, so the fraction is (right - 10 - x) / 180 = 140/180 → value 78
676
+ // (the mirror of the LTR value 22).
641
677
  fireEvent.pointerDown(track, {clientX: 50, clientY: 10, pointerId: 1});
642
- expect(handleChange).toHaveBeenLastCalledWith(75);
678
+ expect(handleChange).toHaveBeenLastCalledWith(78);
643
679
 
644
680
  gcsSpy.mockRestore();
645
681
  });