@astryxdesign/core 0.4.0 → 0.4.1-canary.f4868b2

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 (236) 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 +2 -2
  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/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
  64. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  65. package/dist/NavItem/navItemStyles.stylex.js +11 -5
  66. package/dist/Popover/usePopover.d.ts +15 -0
  67. package/dist/Popover/usePopover.d.ts.map +1 -1
  68. package/dist/Popover/usePopover.js +14 -2
  69. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  70. package/dist/ProgressBar/ProgressBar.js +2 -3
  71. package/dist/Section/Section.d.ts.map +1 -1
  72. package/dist/Section/Section.js +1 -2
  73. package/dist/Section/index.d.ts.map +1 -1
  74. package/dist/Section/index.js +1 -2
  75. package/dist/Selector/Selector.d.ts +10 -0
  76. package/dist/Selector/Selector.d.ts.map +1 -1
  77. package/dist/Selector/Selector.js +135 -95
  78. package/dist/SideNav/SideNav.d.ts +7 -9
  79. package/dist/SideNav/SideNav.d.ts.map +1 -1
  80. package/dist/SideNav/SideNav.js +32 -5
  81. package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
  82. package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
  83. package/dist/SideNav/SideNavCollapseButton.js +39 -15
  84. package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
  85. package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
  86. package/dist/SideNav/SideNavCollapseContext.js +16 -2
  87. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  88. package/dist/SideNav/SideNavHeading.js +70 -24
  89. package/dist/SideNav/SideNavItem.d.ts +7 -2
  90. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  91. package/dist/SideNav/SideNavItem.js +114 -75
  92. package/dist/SideNav/SideNavSection.d.ts.map +1 -1
  93. package/dist/SideNav/SideNavSection.js +7 -16
  94. package/dist/SideNav/index.d.ts +1 -1
  95. package/dist/SideNav/index.d.ts.map +1 -1
  96. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  97. package/dist/Skeleton/Skeleton.js +1 -2
  98. package/dist/Skeleton/index.d.ts.map +1 -1
  99. package/dist/Skeleton/index.js +1 -2
  100. package/dist/Slider/Slider.d.ts.map +1 -1
  101. package/dist/Slider/Slider.js +2 -6
  102. package/dist/Switch/Switch.d.ts.map +1 -1
  103. package/dist/Switch/Switch.js +3 -3
  104. package/dist/Table/TableRow.d.ts +1 -1
  105. package/dist/Table/TableRow.d.ts.map +1 -1
  106. package/dist/Table/TableRow.js +4 -2
  107. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  108. package/dist/TimeInput/TimeInput.js +7 -2
  109. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  110. package/dist/Timestamp/Timestamp.js +14 -3
  111. package/dist/Timestamp/formatInstant.d.ts +11 -1
  112. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  113. package/dist/Timestamp/formatInstant.js +3 -1
  114. package/dist/TopNav/TopNavMegaMenu.js +1 -1
  115. package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
  116. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  117. package/dist/TopNav/TopNavMenu.js +2 -2
  118. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  119. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  120. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  121. package/dist/VisuallyHidden/index.js +1 -2
  122. package/dist/astryx.css +20 -17
  123. package/dist/astryx.umd.js +48 -48
  124. package/dist/astryx.umd.js.map +4 -4
  125. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  126. package/dist/hooks/useInputStatusIcon.js +17 -4
  127. package/dist/theme/defineTheme.d.ts +2 -2
  128. package/dist/theme/defineTheme.d.ts.map +1 -1
  129. package/dist/theme/defineTheme.js +2 -1
  130. package/dist/theme/index.d.ts +1 -1
  131. package/dist/theme/index.d.ts.map +1 -1
  132. package/dist/theme/index.js +1 -1
  133. package/dist/theme/tokens.stylex.d.ts +12 -0
  134. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  135. package/dist/theme/tokens.stylex.js +23 -0
  136. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  137. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  138. package/dist/utils/focusOutline.stylex.js +46 -33
  139. package/dist/utils/index.d.ts +1 -0
  140. package/dist/utils/index.d.ts.map +1 -1
  141. package/dist/utils/index.js +5 -1
  142. package/dist/utils/interactionModality.d.ts +34 -0
  143. package/dist/utils/interactionModality.d.ts.map +1 -0
  144. package/dist/utils/interactionModality.js +70 -0
  145. package/locales/en.json +44 -0
  146. package/locales/pseudo.json +33 -0
  147. package/package.json +3 -3
  148. package/src/AppShell/AppShell.doc.mjs +3 -0
  149. package/src/AspectRatio/AspectRatio.tsx +0 -2
  150. package/src/AspectRatio/index.ts +0 -2
  151. package/src/Badge/Badge.tsx +0 -2
  152. package/src/Badge/index.ts +0 -2
  153. package/src/Banner/Banner.test.tsx +58 -1
  154. package/src/Banner/Banner.tsx +24 -5
  155. package/src/Blockquote/Blockquote.tsx +0 -2
  156. package/src/Blockquote/index.ts +0 -2
  157. package/src/Button/Button.doc.mjs +2 -2
  158. package/src/Button/Button.tsx +5 -4
  159. package/src/Card/Card.tsx +0 -2
  160. package/src/Card/index.ts +0 -2
  161. package/src/Center/Center.tsx +0 -2
  162. package/src/Center/index.ts +0 -2
  163. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  164. package/src/Code/Code.tsx +0 -2
  165. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  166. package/src/CommandPalette/CommandPalette.tsx +5 -0
  167. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  168. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  169. package/src/DateInput/DateInput.test.tsx +19 -0
  170. package/src/DateInput/DateInput.tsx +3 -6
  171. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  172. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  173. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  174. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  175. package/src/Field/PanelSearchInput.tsx +271 -0
  176. package/src/FileInput/FileInput.tsx +21 -8
  177. package/src/Grid/Grid.tsx +0 -2
  178. package/src/Grid/index.ts +0 -2
  179. package/src/Icon/globalIconRegistry.tsx +5 -4
  180. package/src/Indicator/Indicator.doc.mjs +1 -1
  181. package/src/Indicator/index.ts +1 -0
  182. package/src/Indicator/types.ts +11 -0
  183. package/src/Lightbox/Lightbox.tsx +2 -11
  184. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  185. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  186. package/src/MultiSelector/MultiSelector.tsx +170 -127
  187. package/src/NavItem/navItemStyles.stylex.ts +31 -5
  188. package/src/Popover/Popover.doc.mjs +2 -0
  189. package/src/Popover/usePopover.tsx +37 -4
  190. package/src/ProgressBar/ProgressBar.tsx +2 -8
  191. package/src/Section/Section.tsx +0 -2
  192. package/src/Section/index.ts +0 -2
  193. package/src/Selector/Selector.doc.mjs +10 -0
  194. package/src/Selector/Selector.test.tsx +386 -2
  195. package/src/Selector/Selector.tsx +182 -122
  196. package/src/SideNav/SideNav.doc.mjs +13 -4
  197. package/src/SideNav/SideNav.test.tsx +771 -2
  198. package/src/SideNav/SideNav.tsx +37 -16
  199. package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
  200. package/src/SideNav/SideNavCollapseButton.tsx +67 -15
  201. package/src/SideNav/SideNavCollapseContext.ts +29 -9
  202. package/src/SideNav/SideNavHeading.tsx +28 -10
  203. package/src/SideNav/SideNavItem.doc.mjs +13 -0
  204. package/src/SideNav/SideNavItem.tsx +92 -84
  205. package/src/SideNav/SideNavSection.tsx +6 -20
  206. package/src/SideNav/index.ts +2 -0
  207. package/src/Skeleton/Skeleton.tsx +0 -2
  208. package/src/Skeleton/index.ts +0 -2
  209. package/src/Slider/Slider.tsx +2 -11
  210. package/src/Switch/Switch.tsx +10 -2
  211. package/src/Table/Table.test.tsx +31 -0
  212. package/src/Table/TableRow.tsx +6 -0
  213. package/src/TimeInput/TimeInput.test.tsx +54 -0
  214. package/src/TimeInput/TimeInput.tsx +17 -2
  215. package/src/Timestamp/Timestamp.test.tsx +91 -14
  216. package/src/Timestamp/Timestamp.tsx +14 -2
  217. package/src/Timestamp/formatInstant.ts +16 -1
  218. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  219. package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
  220. package/src/TopNav/TopNavMegaMenu.tsx +4 -1
  221. package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
  222. package/src/TopNav/TopNavMenu.test.tsx +41 -0
  223. package/src/TopNav/TopNavMenu.tsx +8 -2
  224. package/src/TreeList/TreeList.test.tsx +3 -2
  225. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  226. package/src/VisuallyHidden/index.ts +0 -2
  227. package/src/hooks/useInputStatusIcon.tsx +8 -8
  228. package/src/serverSafeComponents.test.ts +665 -0
  229. package/src/theme/defineTheme.ts +3 -0
  230. package/src/theme/generateThemeRules.test.ts +17 -0
  231. package/src/theme/index.ts +2 -0
  232. package/src/theme/tokens.stylex.ts +18 -0
  233. package/src/theme/tokens.test.ts +37 -0
  234. package/src/utils/focusOutline.stylex.ts +37 -23
  235. package/src/utils/index.ts +4 -0
  236. package/src/utils/interactionModality.ts +74 -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,17 @@ 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
+ });
473
472
 
474
473
  // In collapsed mode: hide items without icons
475
474
  if (isCollapsed && !icon) {
@@ -484,16 +483,21 @@ export function SideNavItem({
484
483
  displayIcon &&
485
484
  renderIconSlot(displayIcon, {
486
485
  size: 'sm',
487
- color: isSelected ? 'primary' : isDisabled ? 'disabled' : 'secondary',
486
+ // `inherit` so a selected row's icon follows the row to HighlightText
487
+ // under forced colors. Identical to `primary` otherwise: both token
488
+ // families are emitted from one expression.
489
+ color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary',
488
490
  });
489
491
 
490
- // Shared collapsed item styles — used by trigger, link, and button
492
+ // Shared collapsed item styles — used by trigger, link, and button.
493
+ // All three render a focusable element, so each draws the shared ring.
491
494
  const collapsedItemStyles = mergeProps(
492
495
  themeProps('side-nav-item', {
493
496
  size,
494
497
  selected: isSelected ? 'selected' : null,
498
+ disabled: isDisabled ? 'disabled' : null,
495
499
  }),
496
- stylex.props(
500
+ focusOutlineProps.focusVisible(
497
501
  navItemStyles.item,
498
502
  navItemStyles[size],
499
503
  styles.itemCollapsed,
@@ -511,9 +515,8 @@ export function SideNavItem({
511
515
  <button
512
516
  ref={mergeRefs(ref, popover.triggerRef)}
513
517
  type="button"
514
- onClick={popover.toggle}
515
- onMouseEnter={handlePopoverMouseEnter}
516
- onMouseLeave={handlePopoverMouseLeave}
518
+ {...rest}
519
+ {...hoverTriggerProps}
517
520
  aria-label={label}
518
521
  data-testid={testId}
519
522
  {...popover.triggerProps}
@@ -523,15 +526,15 @@ export function SideNavItem({
523
526
  {popover.render(
524
527
  <div
525
528
  {...stylex.props(styles.popoverSurface)}
526
- onMouseEnter={handlePopoverMouseEnter}
527
- onMouseLeave={handlePopoverMouseLeave}
529
+ onMouseEnter={hoverContentProps.onMouseEnter}
530
+ onMouseLeave={hoverContentProps.onMouseLeave}
528
531
  onClick={() => popover.hide()}>
529
532
  <div {...stylex.props(styles.popoverHeader)}>{label}</div>
530
533
  <SideNavCollapseContext value={EXPANDED_COLLAPSE_STATE}>
531
534
  {children}
532
535
  </SideNavCollapseContext>
533
536
  </div>,
534
- {placement: 'end', alignment: 'start'},
537
+ {placement: 'end', alignment: 'start', xstyle: styles.popoverGap},
535
538
  )}
536
539
  </div>
537
540
  );
@@ -552,6 +555,7 @@ export function SideNavItem({
552
555
  as={as}
553
556
  isDisabled={isDisabled}
554
557
  onClick={handleClick}
558
+ {...rest}
555
559
  {...collapsedAriaProps}
556
560
  {...collapsedItemStyles}>
557
561
  {collapsedIcon}
@@ -571,7 +575,8 @@ export function SideNavItem({
571
575
  {displayIcon &&
572
576
  renderIconSlot(displayIcon, {
573
577
  size: 'sm',
574
- color: isSelected ? 'primary' : isDisabled ? 'disabled' : 'secondary',
578
+ // `inherit` — see the collapsed path above.
579
+ color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary',
575
580
  })}
576
581
  {!isCollapsed && <span {...stylex.props(styles.label)}>{label}</span>}
577
582
  {!isCollapsed && endContent && (
@@ -591,45 +596,47 @@ export function SideNavItem({
591
596
  </>
592
597
  );
593
598
 
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.
599
+ const itemThemeProps = themeProps('side-nav-item', {
600
+ size,
601
+ selected: isSelected ? 'selected' : null,
602
+ disabled: isDisabled ? 'disabled' : null,
603
+ });
613
604
 
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>).
605
+ const itemStyleArgs = [
606
+ navItemStyles.item,
607
+ navItemStyles[size],
608
+ isSelected && navItemStyles.selected,
609
+ isDisabled && navItemStyles.disabled,
610
+ ] as const;
611
+
612
+ // Two shapes of the same row appearance:
613
+ // - `rowProps` for the split-action path, where the row is a plain <div>
614
+ // container and its children take focus (so the ring belongs on them);
615
+ // - `focusableRowProps` for every other path, where the row element is
616
+ // itself the focusable control.
617
+ const rowProps = mergeProps(itemThemeProps, stylex.props(...itemStyleArgs));
618
+ const focusableRowProps = mergeProps(
619
+ itemThemeProps,
620
+ focusOutlineProps.focusVisible(...itemStyleArgs),
621
+ );
619
622
 
623
+ // Split-action path: collapsible plus a primary action renders the primary
624
+ // element and the chevron toggle as siblings in a <div> row, since a
625
+ // <button> cannot nest inside an <a>.
620
626
  let itemElement;
621
627
 
622
628
  if (hasIndependentToggle) {
623
629
  itemElement = (
624
- <div data-testid={testId} {...navItemStyleProps}>
630
+ <div data-testid={testId} {...rowProps}>
625
631
  <NavItemElement
626
632
  ref={ref}
627
633
  href={href}
628
634
  as={as}
629
635
  isDisabled={isDisabled}
630
636
  onClick={handleClick}
637
+ {...rest}
631
638
  aria-current={isSelected ? ('page' as const) : undefined}
632
- {...stylex.props(styles.splitAction)}>
639
+ {...focusOutlineProps.focusVisible(styles.splitAction)}>
633
640
  {itemContent}
634
641
  </NavItemElement>
635
642
  <button
@@ -642,7 +649,7 @@ export function SideNavItem({
642
649
  }
643
650
  aria-expanded={!isItemCollapsed}
644
651
  aria-controls={`${id}-children`}
645
- {...stylex.props(styles.expandToggle)}>
652
+ {...focusOutlineProps.focusVisible(styles.expandToggle)}>
646
653
  <Icon
647
654
  icon="chevronDown"
648
655
  size="lg"
@@ -671,8 +678,9 @@ export function SideNavItem({
671
678
  as={as}
672
679
  isDisabled={isDisabled}
673
680
  onClick={handleClick}
681
+ {...rest}
674
682
  {...ariaProps}
675
- {...navItemStyleProps}>
683
+ {...focusableRowProps}>
676
684
  {itemContent}
677
685
  </NavItemElement>
678
686
  );
@@ -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
 
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Skeleton.tsx
7
5
  * @input Uses React, StyleX keyframes and tokens
@@ -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 Skeleton component files
@@ -41,6 +41,7 @@ import {useTooltip} from '../Tooltip';
41
41
  import {VisuallyHidden} from '../VisuallyHidden';
42
42
  import type {InputStatus} from '../Field/types';
43
43
  import {mergeProps, mergeRefs, rtlStyles} from '../utils';
44
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
44
45
  import {isRtlElement} from '../hooks/isRtlElement';
45
46
  import type {BaseProps} from '../BaseProps';
46
47
  import type {SizeValue} from '../utils/types';
@@ -253,16 +254,6 @@ const styles = stylex.create({
253
254
  },
254
255
  },
255
256
  },
256
- thumbFocusVisible: {
257
- outline: {
258
- default: 'none',
259
- ':focus-visible': `2px solid ${colorVars['--color-accent']}`,
260
- },
261
- outlineOffset: {
262
- default: '0',
263
- ':focus-visible': '2px',
264
- },
265
- },
266
257
  thumbDisabled: {
267
258
  backgroundColor: colorVars['--color-background-muted'],
268
259
  cursor: 'not-allowed',
@@ -792,7 +783,7 @@ export function Slider({ref, ...props}: SliderProps) {
792
783
  ? styles.thumbHorizontal
793
784
  : rtlStyles.centerInline('50%'),
794
785
  !isDisabled && styles.thumbHover,
795
- !isDisabled && styles.thumbFocusVisible,
786
+ !isDisabled && focusOutlineStyles.focusVisible,
796
787
  isDisabled && styles.thumbDisabled,
797
788
  ),
798
789
  undefined,
@@ -33,6 +33,7 @@ import {
33
33
  easeVars,
34
34
  typographyVars,
35
35
  typeScaleVars,
36
+ focusVars,
36
37
  } from '../theme/tokens.stylex';
37
38
  import {FieldLabel} from '../Field/FieldLabel';
38
39
  import {FieldStatus} from '../FieldStatus/FieldStatus';
@@ -189,15 +190,22 @@ const styles = stylex.create({
189
190
  '@media (forced-colors: active)': 'CanvasText',
190
191
  },
191
192
  },
193
+ // The one ring in the system not drawn by focusOutlineStyles. The focusable
194
+ // input is a sibling of the track, so the condition has to reach the shared
195
+ // scope marker — and a marker cannot be shared across components without
196
+ // leaking focus state from an outer one, so it cannot live in the utility.
197
+ // StyleX also cannot inline a constant imported from another module, so the
198
+ // values are read from the tokens the utility reads.
192
199
  trackFocus: {
193
200
  outline: {
194
201
  default: 'none',
195
202
  [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
196
- `2px solid ${colorVars['--color-accent']}`,
203
+ `${focusVars['--focus-outline-width']} ${focusVars['--focus-outline-style']} ${focusVars['--focus-outline-color']}`,
197
204
  },
198
205
  outlineOffset: {
199
206
  default: null,
200
- [stylex.when.ancestor(':has(:focus-visible)', switchScope)]: '2px',
207
+ [stylex.when.ancestor(':has(:focus-visible)', switchScope)]:
208
+ focusVars['--focus-outline-offset'],
201
209
  },
202
210
  },
203
211
  // State-dependent colors with ancestor hover behavior
@@ -496,6 +496,37 @@ describe('BaseTable', () => {
496
496
  });
497
497
  });
498
498
 
499
+ describe('TableRow styling props', () => {
500
+ it('applies className and style to the row inside a Table', () => {
501
+ render(
502
+ <Table>
503
+ <tbody>
504
+ <TableRow className="custom-row" style={{opacity: 0.9}}>
505
+ <TableCell>Cell</TableCell>
506
+ </TableRow>
507
+ </tbody>
508
+ </Table>,
509
+ );
510
+ const row = screen.getByRole('row');
511
+ expect(row.className).toContain('custom-row');
512
+ expect(row.className).toContain('astryx-table-row');
513
+ expect(row.style.opacity).toBe('0.9');
514
+ });
515
+
516
+ it('applies className to a standalone row (no table context)', () => {
517
+ render(
518
+ <table>
519
+ <tbody>
520
+ <TableRow className="custom-row">
521
+ <td>Cell</td>
522
+ </TableRow>
523
+ </tbody>
524
+ </table>,
525
+ );
526
+ expect(screen.getByRole('row').className).toContain('custom-row');
527
+ });
528
+ });
529
+
499
530
  describe('plugin pipeline', () => {
500
531
  it('applies transformTable plugin', () => {
501
532
  const plugin: TablePlugin<User> = {
@@ -120,6 +120,8 @@ export function TableRow({
120
120
  xstyle,
121
121
  ref,
122
122
  isHeaderRow = false,
123
+ className: incomingClassName,
124
+ style: incomingStyle,
123
125
  ...props
124
126
  }: TableRowProps) {
125
127
  const ctx = use(TableContext);
@@ -132,6 +134,8 @@ export function TableRow({
132
134
  {...mergeProps(
133
135
  themeProps('table-row'),
134
136
  stylex.props(tableRowMarker, xstyle),
137
+ incomingClassName,
138
+ incomingStyle,
135
139
  )}>
136
140
  {children}
137
141
  </tr>
@@ -169,6 +173,8 @@ export function TableRow({
169
173
  {...mergeProps(
170
174
  themeProps('table-row'),
171
175
  stylex.props(tableRowMarker, ...rowStyles),
176
+ incomingClassName,
177
+ incomingStyle,
172
178
  )}>
173
179
  {children}
174
180
  </tr>
@@ -14,6 +14,7 @@ import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {TimeInput} from './TimeInput';
16
16
  import {InputGroup, InputGroupText} from '../InputGroup';
17
+ import {InternationalizationProvider} from '../i18n';
17
18
  import type {ISOTimeString} from '../utils';
18
19
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
19
20
 
@@ -209,6 +210,59 @@ describe('TimeInput', () => {
209
210
  expect(screen.queryByText('Invalid time')).not.toBeInTheDocument();
210
211
  });
211
212
 
213
+ it('resolves the invalid-time announcement from the i18n catalog', () => {
214
+ render(
215
+ <InternationalizationProvider
216
+ locale="en"
217
+ overrides={{en: {'@astryx.timeInput.invalidTime': 'Ungültige Zeit'}}}>
218
+ <TimeInput label="Time" onChange={() => {}} />
219
+ </InternationalizationProvider>,
220
+ );
221
+
222
+ fireEvent.change(screen.getByRole('textbox'), {target: {value: '25:99'}});
223
+
224
+ expect(screen.getByRole('alert')).toHaveTextContent('Ungültige Zeit');
225
+ });
226
+
227
+ // Arrow-key stepping mutates a plain textbox programmatically, and screen
228
+ // readers do not announce programmatic textbox changes — the new value must
229
+ // be announced through the polite live region (WCAG 4.1.2).
230
+ it('politely announces the new time after ArrowUp stepping', async () => {
231
+ const onChange = vi.fn();
232
+ render(
233
+ <TimeInput
234
+ label="Time"
235
+ value={'14:30' as ISOTimeString}
236
+ onChange={onChange}
237
+ />,
238
+ );
239
+
240
+ fireEvent.keyDown(screen.getByRole('textbox'), {key: 'ArrowUp'});
241
+
242
+ expect(onChange).toHaveBeenCalledWith('14:31');
243
+ await waitFor(() => {
244
+ expect(politeRegion()).toHaveTextContent('2:31 PM');
245
+ });
246
+ });
247
+
248
+ it('politely announces the new time after ArrowDown stepping', async () => {
249
+ const onChange = vi.fn();
250
+ render(
251
+ <TimeInput
252
+ label="Time"
253
+ value={'14:30' as ISOTimeString}
254
+ onChange={onChange}
255
+ />,
256
+ );
257
+
258
+ fireEvent.keyDown(screen.getByRole('textbox'), {key: 'ArrowDown'});
259
+
260
+ expect(onChange).toHaveBeenCalledWith('14:29');
261
+ await waitFor(() => {
262
+ expect(politeRegion()).toHaveTextContent('2:29 PM');
263
+ });
264
+ });
265
+
212
266
  it('calls onChange on blur when input is valid', async () => {
213
267
  const user = userEvent.setup();
214
268
  const onChange = vi.fn();
@@ -558,10 +558,25 @@ export function TimeInput({
558
558
  // Check if within range
559
559
  if (isTimeInRange(newTime, min, max)) {
560
560
  fireChange(newTime);
561
+ // Stepping programmatically rewrites a plain textbox's value, and
562
+ // screen readers do not announce programmatic textbox changes — the
563
+ // new value must be spoken explicitly or stepping is silent
564
+ // (WCAG 4.1.2).
565
+ announce(formatDisplayTime(newTime, hasSeconds));
561
566
  }
562
567
  }
563
568
  },
564
- [value, hasSeconds, increment, min, max, fireChange, isDisabled],
569
+ [
570
+ value,
571
+ hasSeconds,
572
+ increment,
573
+ min,
574
+ max,
575
+ fireChange,
576
+ isDisabled,
577
+ announce,
578
+ formatDisplayTime,
579
+ ],
565
580
  );
566
581
 
567
582
  // Handle clear button click
@@ -659,7 +674,7 @@ export function TimeInput({
659
674
  user would get no feedback that their entry was rejected (WCAG 3.3.1).
660
675
  */}
661
676
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
662
- {!isInputValid ? 'Invalid time' : ''}
677
+ {!isInputValid ? t('@astryx.timeInput.invalidTime') : ''}
663
678
  </VisuallyHidden>
664
679
  {isBusy && <Spinner size="sm" />}
665
680
  {hasClear && value && !isDisabled && (