@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
@@ -36,6 +36,7 @@ import {mergeProps, mergeRefs} from '../utils';
36
36
  import {
37
37
  SideNavCollapseContext,
38
38
  type SideNavCollapseState,
39
+ type SideNavCollapsibleConfig,
39
40
  type SideNavImperativeCollapseHandle,
40
41
  } from './SideNavCollapseContext';
41
42
  import {SideNavCollapseButton} from './SideNavCollapseButton';
@@ -45,6 +46,7 @@ import {useResizable} from '../Resizable/useResizable';
45
46
  import type {ResizableConfig} from '../Resizable/useResizable';
46
47
  import {ResizeHandle} from '../Resizable/ResizeHandle';
47
48
  import {themeProps} from '../utils/themeProps';
49
+ import {SizeProvider} from '../SizeContext/SizeContext';
48
50
  import {useTranslator} from '../i18n';
49
51
 
50
52
  // =============================================================================
@@ -185,6 +187,13 @@ const styles = stylex.create({
185
187
  },
186
188
  });
187
189
 
190
+ /**
191
+ * Cascaded to the icon rows through `SizeContext` so the built-in collapse
192
+ * button and the consumer's `footerIcons` come out one height. An explicit
193
+ * `size` on a child still wins.
194
+ */
195
+ const FOOTER_ICON_SIZE = 'sm';
196
+
188
197
  // =============================================================================
189
198
  // Types
190
199
  // =============================================================================
@@ -197,6 +206,9 @@ export interface SideNavProps extends BaseProps<HTMLElement> {
197
206
  * Imperative collapse handle for SideNavCollapseButton instances rendered
198
207
  * outside this SideNav. This intentionally stays separate from `ref`, which
199
208
  * continues to expose the root HTMLElement.
209
+ *
210
+ * @deprecated Hand the same controlled `collapsible` config to SideNav and
211
+ * to the outside button instead.
200
212
  */
201
213
  handleRef?: React.Ref<SideNavImperativeCollapseHandle>;
202
214
 
@@ -270,21 +282,14 @@ export interface SideNavProps extends BaseProps<HTMLElement> {
270
282
  * - `true` — enables collapse with default toggle button and uncontrolled state
271
283
  * - Object — enables collapse with advanced configuration:
272
284
  * - `defaultIsCollapsed` — start collapsed (uncontrolled)
273
- * - `isCollapsed` + `onCollapsedChange` — controlled mode
285
+ * - `isCollapsed` + `onCollapsedChange` — controlled mode. Pass the same
286
+ * object to a `SideNavCollapseButton` rendered outside this SideNav
274
287
  * - `hasButton` — render built-in collapse button (default: true)
275
288
  * - `buttonLabel` — accessibility label for the collapse button
276
289
  *
277
290
  * @default false
278
291
  */
279
- collapsible?:
280
- | boolean
281
- | {
282
- defaultIsCollapsed?: boolean;
283
- isCollapsed?: boolean;
284
- onCollapsedChange?: (isCollapsed: boolean) => void;
285
- hasButton?: boolean;
286
- buttonLabel?: string;
287
- };
292
+ collapsible?: boolean | SideNavCollapsibleConfig;
288
293
  }
289
294
 
290
295
  // =============================================================================
@@ -375,6 +380,8 @@ export function SideNav({
375
380
  const toggle = useCallback(() => {
376
381
  const next = !collapsed;
377
382
 
383
+ // Deprecated `handleRef` path only: an out-of-tree button reads this
384
+ // snapshot while rendering, which can happen before SideNav re-renders.
378
385
  collapseStateRef.current = {
379
386
  ...collapseStateRef.current,
380
387
  isCollapsed: next,
@@ -429,7 +436,9 @@ export function SideNav({
429
436
  style,
430
437
  )}>
431
438
  {header}
432
- <div {...stylex.props(styles.topbarIcons)}>{footerIcons}</div>
439
+ <div {...stylex.props(styles.topbarIcons)}>
440
+ <SizeProvider value={FOOTER_ICON_SIZE}>{footerIcons}</SizeProvider>
441
+ </div>
433
442
  </div>
434
443
  );
435
444
  }
@@ -441,7 +450,13 @@ export function SideNav({
441
450
 
442
451
  if (renderMode === 'drawer') {
443
452
  return (
444
- <MobileNav header={header} data-testid={testId}>
453
+ <MobileNav
454
+ header={header}
455
+ data-testid={testId}
456
+ xstyle={xstyle}
457
+ className={className}
458
+ style={style}
459
+ {...props}>
445
460
  {topContent}
446
461
  {children}
447
462
  {hasDrawerFooter && (
@@ -449,7 +464,9 @@ export function SideNav({
449
464
  {footer}
450
465
  {footerIcons && (
451
466
  <div {...stylex.props(styles.drawerFooterIcons)}>
452
- {footerIcons}
467
+ <SizeProvider value={FOOTER_ICON_SIZE}>
468
+ {footerIcons}
469
+ </SizeProvider>
453
470
  </div>
454
471
  )}
455
472
  </div>
@@ -472,7 +489,9 @@ export function SideNav({
472
489
  {footer}
473
490
  {footerIcons && (
474
491
  <div {...stylex.props(styles.drawerFooterIcons)}>
475
- {footerIcons}
492
+ <SizeProvider value={FOOTER_ICON_SIZE}>
493
+ {footerIcons}
494
+ </SizeProvider>
476
495
  </div>
477
496
  )}
478
497
  </div>
@@ -544,8 +563,10 @@ export function SideNav({
544
563
  styles.footerRow,
545
564
  collapsed && styles.footerRowCollapsed,
546
565
  )}>
547
- {showCollapseButton && <SideNavCollapseButton />}
548
- {footerIcons}
566
+ <SizeProvider value={FOOTER_ICON_SIZE}>
567
+ {showCollapseButton && <SideNavCollapseButton />}
568
+ {footerIcons}
569
+ </SizeProvider>
549
570
  </div>
550
571
  </div>
551
572
  )}
@@ -7,18 +7,28 @@ export const docs = {
7
7
  subComponentOf: 'SideNav',
8
8
  displayName: 'Side Nav Collapse Button',
9
9
  isHiddenFromOverview: true,
10
- description: 'Toggle button for sidenav collapse. Place inside SideNav (reads context automatically) or outside (pass handleRef). Renders as an icon-only ghost button by default.',
10
+ description: 'Toggle button for sidenav collapse. Place inside SideNav (reads context automatically) or outside it (hand the same controlled collapsible config to both). Renders as an icon-only ghost button by default.',
11
11
  props: [
12
+ {
13
+ name: 'collapsible',
14
+ type: '{isCollapsed: boolean, onCollapsedChange: (isCollapsed: boolean) => void}',
15
+ description: 'The same controlled collapsible config passed to SideNav. Only needed when the button is rendered outside the sidenav, where collapse context cannot reach it.',
16
+ },
12
17
  {
13
18
  name: 'handleRef',
14
19
  type: 'RefObject<SideNavImperativeCollapseHandle | null>',
15
- description: 'Imperative collapse handle from SideNav. Only needed when the button is rendered outside the sidenav.',
20
+ description: 'Deprecated. Imperative collapse handle from SideNav; pass collapsible instead.',
16
21
  },
17
22
  {
18
23
  name: 'label',
19
24
  type: 'string',
20
25
  description: 'Custom button label. When provided, renders as a text button with chevron. When omitted, renders icon-only.',
21
26
  },
27
+ {
28
+ name: 'size',
29
+ type: "'sm' | 'md' | 'lg'",
30
+ description: "Button size. Defaults to the size its container cascades ('sm' inside a SideNav footer) and to 'md' with no container. Set it when the button sits outside a sized container and has to match its neighbours.",
31
+ },
22
32
  {
23
33
  name: 'children',
24
34
  type: 'ReactNode',
@@ -31,18 +41,28 @@ export const docsZh = {
31
41
  name: 'SideNavCollapseButton',
32
42
  isHiddenFromOverview: true,
33
43
  displayName: 'Side Nav Collapse Button',
34
- description: '侧边栏折叠切换按钮。放置在 SideNav 内部(自动读取上下文)或外部(传入 handleRef)。默认渲染为仅图标的 ghost 按钮。',
44
+ description: '侧边栏折叠切换按钮。放置在 SideNav 内部(自动读取上下文)或外部(向两者传入同一个受控 collapsible 配置)。默认渲染为仅图标的 ghost 按钮。',
35
45
  props: [
46
+ {
47
+ name: 'collapsible',
48
+ type: '{isCollapsed: boolean, onCollapsedChange: (isCollapsed: boolean) => void}',
49
+ description: '与 SideNav 相同的受控 collapsible 配置。仅在按钮渲染在侧边栏外部、无法读取折叠上下文时需要。',
50
+ },
36
51
  {
37
52
  name: 'handleRef',
38
53
  type: 'RefObject<SideNavImperativeCollapseHandle | null>',
39
- description: '来自 SideNav 的命令式折叠句柄。仅在按钮渲染在侧边栏外部时需要。',
54
+ description: '已废弃。来自 SideNav 的命令式折叠句柄;请改用 collapsible。',
40
55
  },
41
56
  {
42
57
  name: 'label',
43
58
  type: 'string',
44
59
  description: '自定义按钮标签。提供时渲染为带箭头的文本按钮。省略时渲染为仅图标按钮。',
45
60
  },
61
+ {
62
+ name: 'size',
63
+ type: "'sm' | 'md' | 'lg'",
64
+ description: "按钮尺寸。默认使用容器级联的尺寸(SideNav 页脚内为 'sm'),无容器时为 'md'。当按钮位于已设定尺寸的容器之外且需要与相邻元素保持一致时显式设置。",
65
+ },
46
66
  {
47
67
  name: 'children',
48
68
  type: 'ReactNode',
@@ -55,10 +75,12 @@ export const docsDense = {
55
75
  name: 'SideNavCollapseButton',
56
76
  isHiddenFromOverview: true,
57
77
  displayName: 'Side Nav Collapse Button',
58
- description: 'Toggle button for sidenav collapse. Place inside SideNav (reads context) or outside (pass handleRef). Icon-only ghost button by default.',
78
+ description: 'Toggle button for sidenav collapse. Place inside SideNav (reads context) or outside (pass the same controlled collapsible config both get). Icon-only ghost button by default.',
59
79
  propDescriptions: {
60
- handleRef: 'Imperative collapse handle from SideNav. Only needed when button rendered outside sidenav.',
80
+ collapsible: 'Controlled collapsible config, same object SideNav gets. Only needed when button rendered outside sidenav.',
81
+ handleRef: 'Deprecated. Pass collapsible instead.',
61
82
  label: 'Custom label. Text button w/ chevron when provided, icon-only when omitted.',
83
+ size: "Button size. Inherits from the container ('sm' in a SideNav footer), else 'md'.",
62
84
  children: 'Custom content. Overrides default chevron icon + label.',
63
85
  },
64
86
  };
@@ -9,7 +9,7 @@
9
9
  * @position Composable toggle button for sidenav collapse
10
10
  *
11
11
  * Place inside SideNav (reads context automatically) or outside
12
- * (pass handleRef to connect). Customizable via label/children.
12
+ * (pass the same controlled `collapsible` config both get).
13
13
  *
14
14
  * SYNC: When modified, update:
15
15
  * - /packages/core/src/SideNav/SideNav.doc.mjs
@@ -17,7 +17,7 @@
17
17
  * - /packages/cli/assets/templates/blocks/components/SideNav/ (showcase blocks)
18
18
  */
19
19
 
20
- import React, {type ReactNode} from 'react';
20
+ import React, {useCallback, type ReactNode} from 'react';
21
21
  import * as stylex from '@stylexjs/stylex';
22
22
  import {durationVars, easeVars} from '../theme/tokens.stylex';
23
23
  import {Icon} from '../Icon';
@@ -27,21 +27,34 @@ import {composeEventHandlers, rtlStyles} from '../utils';
27
27
  import {
28
28
  useSideNavCollapse,
29
29
  type SideNavCollapseState,
30
+ type SideNavControlledCollapsible,
30
31
  type SideNavImperativeCollapseHandle,
31
32
  } from './SideNavCollapseContext';
32
33
  import {useAppShellMobile} from '../AppShell/AppShellMobileContext';
33
34
  import {useTranslator} from '../i18n';
35
+ import type {ElementSize} from '../SizeContext/SizeContext';
34
36
 
35
37
  // =============================================================================
36
38
  // Styles
37
39
  // =============================================================================
38
40
 
39
41
  const styles = stylex.create({
42
+ // A flex container, so the glyph is a flex item. Left to blockify as a flex
43
+ // item of Button's icon slot, this span gets a line box and seats the
44
+ // chevron on its text baseline — 2.42px above the button's centre.
45
+ chevronMirror: {
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ },
40
50
  chevron: {
41
51
  display: 'inline-flex',
42
52
  alignItems: 'center',
43
53
  transitionProperty: 'transform',
44
- transitionDuration: durationVars['--duration-fast'],
54
+ transitionDuration: {
55
+ default: durationVars['--duration-fast'],
56
+ '@media (prefers-reduced-motion: reduce)': '0s',
57
+ },
45
58
  transitionTimingFunction: easeVars['--ease-standard'],
46
59
  },
47
60
  chevronCollapsed: {
@@ -56,8 +69,16 @@ const styles = stylex.create({
56
69
  export interface SideNavCollapseButtonProps extends BaseProps<HTMLButtonElement> {
57
70
  ref?: React.Ref<HTMLButtonElement>;
58
71
  /**
59
- * Imperative handle from SideNav. Only needed when the button is rendered
60
- * outside the sidenav, where collapse context is unavailable.
72
+ * The same controlled `collapsible` config given to SideNav
73
+ * (`{isCollapsed, onCollapsedChange}`). Needed only when the button is
74
+ * rendered outside the sidenav, where collapse context cannot reach it.
75
+ */
76
+ collapsible?: SideNavControlledCollapsible;
77
+
78
+ /**
79
+ * Imperative handle from SideNav.
80
+ *
81
+ * @deprecated Pass `collapsible` instead.
61
82
  */
62
83
  handleRef?: React.RefObject<SideNavImperativeCollapseHandle | null>;
63
84
 
@@ -67,6 +88,13 @@ export interface SideNavCollapseButtonProps extends BaseProps<HTMLButtonElement>
67
88
  */
68
89
  label?: string;
69
90
 
91
+ /**
92
+ * Button size. Defaults to the size its container cascades — `sm` in a
93
+ * SideNav footer — or `md` outside one. Set it for placements with no row
94
+ * to inherit from, e.g. a button placed in a `TopNav`.
95
+ */
96
+ size?: ElementSize;
97
+
70
98
  /**
71
99
  * Custom button content. Overrides the default chevron icon and label.
72
100
  */
@@ -82,7 +110,8 @@ export interface SideNavCollapseButtonProps extends BaseProps<HTMLButtonElement>
82
110
  *
83
111
  * Place anywhere inside SideNav (header, topContent, footer, footerIcons)
84
112
  * and it reads collapse state from context automatically. For placement
85
- * outside the sidenav (e.g. in TopNav or content area), pass handleRef.
113
+ * outside the sidenav (e.g. in TopNav or content area), hold the state and
114
+ * hand the same `collapsible` config to both.
86
115
  *
87
116
  * @example
88
117
  * ```
@@ -93,22 +122,27 @@ export interface SideNavCollapseButtonProps extends BaseProps<HTMLButtonElement>
93
122
  *
94
123
  * @example
95
124
  * ```
96
- * const ref = useRef(null);
97
- * <TopNav endContent={<SideNavCollapseButton handleRef={ref} />} />
98
- * <SideNav handleRef={ref} collapsible>...</SideNav>
125
+ * const [isCollapsed, setIsCollapsed] = useState(false);
126
+ * const collapsible = {isCollapsed, onCollapsedChange: setIsCollapsed};
127
+ * <TopNav endContent={<SideNavCollapseButton collapsible={collapsible} />} />
128
+ * <SideNav collapsible={{...collapsible, hasButton: false}}>...</SideNav>
99
129
  * ```
100
130
  */
101
131
  export function SideNavCollapseButton({
102
132
  ref,
133
+ collapsible,
103
134
  handleRef,
104
135
  label,
136
+ size,
105
137
  children,
106
138
  onClick: onClickProp,
107
139
  ...props
108
140
  }: SideNavCollapseButtonProps) {
109
141
  const t = useTranslator();
110
- const {isCollapsed, toggle, isCollapsible} =
111
- useSideNavCollapseState(handleRef);
142
+ const {isCollapsed, toggle, isCollapsible} = useSideNavCollapseState(
143
+ collapsible,
144
+ handleRef,
145
+ );
112
146
  const {isMobile} = useAppShellMobile();
113
147
 
114
148
  // Hide when not collapsible, or when in mobile mode (sidenav is in
@@ -127,6 +161,7 @@ export function SideNavCollapseButton({
127
161
  : t('@astryx.sideNavCollapseButton.collapseSidebar'))
128
162
  }
129
163
  variant="ghost"
164
+ size={size}
130
165
  {...props}
131
166
  onClick={composeEventHandlers(onClickProp, toggle)}
132
167
  icon={
@@ -135,7 +170,7 @@ export function SideNavCollapseButton({
135
170
  // into) the state rotation: both are `transform`, so on a single
136
171
  // element one would overwrite the other and the chevron would stop
137
172
  // mirroring under RTL. See utils/rtlStyles.ts.
138
- <span {...stylex.props(rtlStyles.mirror)}>
173
+ <span {...stylex.props(styles.chevronMirror, rtlStyles.mirror)}>
139
174
  {/* `sm` (1rem) matches what this glyph already renders at: Button's
140
175
  icon slot pins its wrapper to 16px, and the registry SVG is
141
176
  1em, so the chevron is 16px today. */}
@@ -156,11 +191,30 @@ export function SideNavCollapseButton({
156
191
  SideNavCollapseButton.displayName = 'SideNavCollapseButton';
157
192
 
158
193
  function useSideNavCollapseState(
194
+ collapsible: SideNavControlledCollapsible | undefined,
159
195
  handleRef:
160
196
  React.RefObject<SideNavImperativeCollapseHandle | null> | undefined,
161
197
  ): SideNavCollapseState {
162
198
  const contextCollapseState = useSideNavCollapse();
163
199
 
200
+ const {isCollapsed, onCollapsedChange} = collapsible ?? {};
201
+ const toggleCollapsible = useCallback(
202
+ () => onCollapsedChange?.(!isCollapsed),
203
+ [isCollapsed, onCollapsedChange],
204
+ );
205
+
206
+ const toggleHandle = useCallback(() => {
207
+ handleRef?.current?.getCollapseState()?.toggle();
208
+ }, [handleRef]);
209
+
210
+ if (collapsible != null) {
211
+ return {
212
+ isCollapsed: isCollapsed ?? false,
213
+ toggle: toggleCollapsible,
214
+ isCollapsible: true,
215
+ };
216
+ }
217
+
164
218
  if (handleRef == null) {
165
219
  return contextCollapseState;
166
220
  }
@@ -169,9 +223,7 @@ function useSideNavCollapseState(
169
223
 
170
224
  return {
171
225
  isCollapsed: externalCollapseState?.isCollapsed ?? false,
172
- toggle: () => {
173
- handleRef.current?.getCollapseState()?.toggle();
174
- },
226
+ toggle: toggleHandle,
175
227
  isCollapsible: externalCollapseState?.isCollapsible ?? true,
176
228
  };
177
229
  }
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * Provides collapse state to SideNavCollapseButton and other
12
12
  * sidenav children. Set by SideNav when isCollapsible is true.
13
- * Also provides a small imperative handle for ref-based collapse buttons
14
- * rendered outside the SideNav tree.
13
+ * A button rendered outside the SideNav tree is out of context's reach and
14
+ * takes the controlled `collapsible` config as a prop instead.
15
15
  */
16
16
 
17
17
  import {createContext, use} from 'react';
@@ -25,18 +25,38 @@ export interface SideNavCollapseState {
25
25
  isCollapsible: boolean;
26
26
  }
27
27
 
28
+ /** Object form of SideNav's `collapsible` prop. */
29
+ export interface SideNavCollapsibleConfig {
30
+ defaultIsCollapsed?: boolean;
31
+ isCollapsed?: boolean;
32
+ onCollapsedChange?: (isCollapsed: boolean) => void;
33
+ hasButton?: boolean;
34
+ buttonLabel?: string;
35
+ }
36
+
37
+ /**
38
+ * The controlled form: the consumer holds the state, so it can be handed to
39
+ * both SideNav and a SideNavCollapseButton rendered outside it.
40
+ */
41
+ export interface SideNavControlledCollapsible extends SideNavCollapsibleConfig {
42
+ isCollapsed: boolean;
43
+ onCollapsedChange: (isCollapsed: boolean) => void;
44
+ }
28
45
 
46
+ /**
47
+ * @deprecated Pass the same controlled `collapsible` config to SideNav and to
48
+ * the out-of-tree SideNavCollapseButton instead. The state then reaches the
49
+ * button through props rather than through a ref.
50
+ */
29
51
  export interface SideNavImperativeCollapseHandle {
30
52
  getCollapseState: () => SideNavCollapseState | null;
31
53
  }
32
54
 
33
- export const SideNavCollapseContext = createContext<SideNavCollapseState>(
34
- {
35
- isCollapsed: false,
36
- toggle: () => {},
37
- isCollapsible: false,
38
- },
39
- );
55
+ export const SideNavCollapseContext = createContext<SideNavCollapseState>({
56
+ isCollapsed: false,
57
+ toggle: () => {},
58
+ isCollapsible: false,
59
+ });
40
60
  SideNavCollapseContext.displayName = 'SideNavCollapseContext';
41
61
 
42
62
  /**
@@ -36,6 +36,7 @@ import {Link} from '../Link';
36
36
  import {Icon} from '../Icon';
37
37
  import {Tooltip} from '../Tooltip';
38
38
  import {navItemStyles} from '../NavItem/navItemStyles.stylex';
39
+ import {focusOutlineProps} from '../utils/focusOutline.stylex';
39
40
  import {useSideNavCollapse} from './SideNavCollapseContext';
40
41
  import {useLinkComponent} from '../Link/useLinkComponent';
41
42
  import type {LinkComponentType} from '../Link/types';
@@ -370,23 +371,27 @@ export function SideNavHeading({
370
371
  hasCloseButton: false,
371
372
  });
372
373
 
373
- const closeMenuCtx = useMemo(
374
- () => ({closeMenu: popover.hide}),
375
- [popover.hide],
376
- );
374
+ const {
375
+ triggerProps,
376
+ contentProps,
377
+ menuRef,
378
+ setTriggerEl,
379
+ close: closeMenu,
380
+ } = useMenuHover<HTMLDivElement>({
381
+ show: popover.show,
382
+ hide: popover.hide,
383
+ isOpen: popover.isOpen,
384
+ isEnabled: !!menu,
385
+ showDelay: 0,
386
+ });
377
387
 
378
- const {triggerProps, contentProps, menuRef, setTriggerEl} =
379
- useMenuHover<HTMLDivElement>({
380
- show: popover.show,
381
- hide: popover.hide,
382
- isOpen: popover.isOpen,
383
- isEnabled: !!menu,
384
- showDelay: 0,
385
- });
388
+ const closeMenuCtx = useMemo(() => ({closeMenu}), [closeMenu]);
386
389
 
390
+ // setTriggerEl belongs on the chevron button, not this root: it is the
391
+ // focus-restore target and a <div> cannot take focus. triggerRef stays here
392
+ // because the panel anchors to the whole heading.
387
393
  const setRef = mergeRefs<HTMLDivElement>(
388
394
  rootRef,
389
- setTriggerEl,
390
395
  ref,
391
396
  menu ? popover.triggerRef : undefined,
392
397
  );
@@ -401,7 +406,9 @@ export function SideNavHeading({
401
406
  const collapsedSetRef = mergeRefs<HTMLElement>(
402
407
  collapsedItemRef,
403
408
  ref,
409
+ // Collapsed, this button is the trigger, so it takes both roles.
404
410
  menu ? popover.triggerRef : undefined,
411
+ menu ? setTriggerEl : undefined,
405
412
  );
406
413
 
407
414
  let collapsedElement: ReactNode;
@@ -415,7 +422,11 @@ export function SideNavHeading({
415
422
  data-testid={testId}
416
423
  {...mergeProps(
417
424
  themeProps('side-nav-heading'),
418
- stylex.props(navItemStyles.item, styles.rootCollapsed, xstyle),
425
+ focusOutlineProps.focusVisible(
426
+ navItemStyles.item,
427
+ styles.rootCollapsed,
428
+ xstyle,
429
+ ),
419
430
  className,
420
431
  style,
421
432
  )}>
@@ -434,7 +445,7 @@ export function SideNavHeading({
434
445
  {...triggerProps}
435
446
  {...mergeProps(
436
447
  themeProps('side-nav-heading'),
437
- stylex.props(
448
+ focusOutlineProps.focusVisible(
438
449
  navItemStyles.item,
439
450
  styles.rootCollapsed,
440
451
  styles.menuTrigger,
@@ -452,8 +463,9 @@ export function SideNavHeading({
452
463
  {...contentProps}>
453
464
  <button
454
465
  type="button"
455
- {...stylex.props(styles.popoverHeading)}
456
- onClick={triggerProps.onClick}>
466
+ {...focusOutlineProps.focusVisible(styles.popoverHeading)}
467
+ // A close affordance, not the trigger: dismiss only.
468
+ onClick={closeMenu}>
457
469
  {icon && <span {...stylex.props(styles.icon)}>{icon}</span>}
458
470
  <span {...stylex.props(styles.textContainer)}>
459
471
  {superheading && (
@@ -549,7 +561,10 @@ export function SideNavHeading({
549
561
  {hasAnyHref && headingHref && menu ? (
550
562
  <LinkComponent
551
563
  href={headingHref}
552
- {...stylex.props(styles.heading, styles.headingLink)}>
564
+ {...focusOutlineProps.focusVisible(
565
+ styles.heading,
566
+ styles.headingLink,
567
+ )}>
553
568
  {heading}
554
569
  </LinkComponent>
555
570
  ) : (
@@ -586,8 +601,9 @@ export function SideNavHeading({
586
601
  const popoverHeadingContent = (
587
602
  <button
588
603
  type="button"
589
- {...stylex.props(styles.popoverHeading)}
590
- onClick={triggerProps.onClick}>
604
+ {...focusOutlineProps.focusVisible(styles.popoverHeading)}
605
+ // A close affordance, not the trigger: dismiss only.
606
+ onClick={closeMenu}>
591
607
  {icon && <span {...stylex.props(styles.icon)}>{icon}</span>}
592
608
  {renderTextContent(
593
609
  <Icon
@@ -609,7 +625,11 @@ export function SideNavHeading({
609
625
  data-testid={testId}
610
626
  {...mergeProps(
611
627
  themeProps('side-nav-heading'),
612
- stylex.props(styles.root, styles.menuTrigger, xstyle),
628
+ focusOutlineProps.focusVisible(
629
+ styles.root,
630
+ styles.menuTrigger,
631
+ xstyle,
632
+ ),
613
633
  className,
614
634
  style,
615
635
  )}
@@ -639,6 +659,7 @@ export function SideNavHeading({
639
659
  {icon && <span {...stylex.props(styles.icon)}>{icon}</span>}
640
660
  {renderTextContent(
641
661
  <button
662
+ ref={setTriggerEl}
642
663
  type="button"
643
664
  aria-label={t('@astryx.sideNav.heading.openMenu')}
644
665
  onClick={e => {
@@ -646,7 +667,10 @@ export function SideNavHeading({
646
667
  triggerProps.onClick();
647
668
  }}
648
669
  {...popover.triggerProps}
649
- {...stylex.props(styles.chevron, styles.interactive)}>
670
+ {...focusOutlineProps.focusVisible(
671
+ styles.chevron,
672
+ styles.interactive,
673
+ )}>
650
674
  <Icon
651
675
  icon="chevronDown"
652
676
  size="sm"
@@ -701,7 +725,7 @@ export function SideNavHeading({
701
725
  <LinkComponent
702
726
  href={headingHref}
703
727
  aria-label={heading}
704
- {...stylex.props(styles.icon)}>
728
+ {...focusOutlineProps.focusVisible(styles.icon)}>
705
729
  {icon}
706
730
  </LinkComponent>
707
731
  ) : (
@@ -710,6 +734,7 @@ export function SideNavHeading({
710
734
  {renderTextContent(
711
735
  showChevron ? (
712
736
  <button
737
+ ref={setTriggerEl}
713
738
  type="button"
714
739
  aria-label={t('@astryx.sideNav.heading.openMenu')}
715
740
  onClick={e => {
@@ -717,7 +742,10 @@ export function SideNavHeading({
717
742
  triggerProps.onClick();
718
743
  }}
719
744
  {...popover.triggerProps}
720
- {...stylex.props(styles.chevron, styles.interactive)}>
745
+ {...focusOutlineProps.focusVisible(
746
+ styles.chevron,
747
+ styles.interactive,
748
+ )}>
721
749
  <Icon
722
750
  icon="chevronDown"
723
751
  size="sm"
@@ -770,7 +798,7 @@ export function SideNavHeading({
770
798
  <LinkComponent
771
799
  href={headingHref}
772
800
  aria-label={heading}
773
- {...stylex.props(styles.icon)}>
801
+ {...focusOutlineProps.focusVisible(styles.icon)}>
774
802
  {icon}
775
803
  </LinkComponent>
776
804
  ) : (
@@ -91,6 +91,12 @@ export const docs = {
91
91
  description: 'Enables collapse behavior for items with children. Pass true for uncontrolled (starts expanded), or an object for controlled mode.',
92
92
  default: 'false',
93
93
  },
94
+ {
95
+ name: 'size',
96
+ type: "'sm' | 'md' | 'lg'",
97
+ description: 'Size variant for the nav item row.',
98
+ default: "'md'",
99
+ },
94
100
  ],
95
101
  };
96
102
 
@@ -159,6 +165,12 @@ export const docsZh = {
159
165
  description: '启用带子项的折叠行为。传 true 为非受控模式(默认展开),或传对象用于受控模式。',
160
166
  default: 'false',
161
167
  },
168
+ {
169
+ name: 'size',
170
+ type: "'sm' | 'md' | 'lg'",
171
+ description: '导航项的尺寸变体。',
172
+ default: "'md'",
173
+ },
162
174
  ],
163
175
  };
164
176
 
@@ -179,5 +191,6 @@ export const docsDense = {
179
191
  endContent: 'Right-side content such as badges or counts.',
180
192
  children: 'Sub-items for nesting.',
181
193
  collapsible: 'Enables collapse for items w/ children. true=uncontrolled, object=controlled mode.',
194
+ size: "Row size variant: 'sm' | 'md' | 'lg'.",
182
195
  },
183
196
  };