@astryxdesign/core 0.5.0 → 0.5.1-canary.f64186a

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 (597) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/dist/AlertDialog/AlertDialog.d.ts +1 -1
  3. package/dist/AlertDialog/AlertDialog.d.ts.map +1 -1
  4. package/dist/AlertDialog/AlertDialog.js +45 -20
  5. package/dist/AspectRatio/AspectRatio.d.ts +6 -6
  6. package/dist/Avatar/Avatar.d.ts +25 -1
  7. package/dist/Avatar/Avatar.d.ts.map +1 -1
  8. package/dist/Avatar/Avatar.js +43 -9
  9. package/dist/Avatar/index.d.ts +2 -2
  10. package/dist/Avatar/index.d.ts.map +1 -1
  11. package/dist/Avatar/index.js +1 -1
  12. package/dist/AvatarGroup/AvatarGroup.d.ts +9 -2
  13. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  14. package/dist/AvatarGroup/AvatarGroup.js +5 -2
  15. package/dist/AvatarGroup/AvatarGroupContext.d.ts +2 -1
  16. package/dist/AvatarGroup/AvatarGroupContext.d.ts.map +1 -1
  17. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  18. package/dist/AvatarGroup/AvatarGroupOverflow.js +11 -7
  19. package/dist/Badge/Badge.d.ts.map +1 -1
  20. package/dist/Badge/Badge.js +26 -2
  21. package/dist/Banner/Banner.d.ts +10 -1
  22. package/dist/Banner/Banner.d.ts.map +1 -1
  23. package/dist/Banner/Banner.js +11 -2
  24. package/dist/Button/Button.d.ts.map +1 -1
  25. package/dist/Button/Button.js +8 -7
  26. package/dist/Calendar/Calendar.d.ts +4 -2
  27. package/dist/Calendar/Calendar.d.ts.map +1 -1
  28. package/dist/Calendar/Calendar.js +15 -21
  29. package/dist/Calendar/hooks/useCalendarDays.js +4 -6
  30. package/dist/Calendar/hooks/useCalendarNavigation.js +4 -6
  31. package/dist/Calendar/styles.d.ts +5 -20
  32. package/dist/Calendar/styles.d.ts.map +1 -1
  33. package/dist/Calendar/styles.js +15 -17
  34. package/dist/Card/Card.d.ts +12 -12
  35. package/dist/Card/Card.d.ts.map +1 -1
  36. package/dist/Card/Card.js +17 -9
  37. package/dist/Card/index.d.ts +35 -1
  38. package/dist/Card/index.d.ts.map +1 -1
  39. package/dist/Card/index.js +21 -1
  40. package/dist/Carousel/Carousel.d.ts.map +1 -1
  41. package/dist/Carousel/Carousel.js +108 -7
  42. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  43. package/dist/CheckboxInput/CheckboxInput.js +7 -1
  44. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  45. package/dist/CodeBlock/CodeBlock.js +15 -4
  46. package/dist/CodeBlock/highlightRanges.js +1 -1
  47. package/dist/CodeBlock/highlightStyles.js +13 -13
  48. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  49. package/dist/CommandPalette/CommandPaletteItem.js +2 -7
  50. package/dist/ComplexSelector/ComplexSelector.d.ts +10 -2
  51. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  52. package/dist/ComplexSelector/ComplexSelector.js +14 -7
  53. package/dist/DateInput/DateInput.d.ts +43 -0
  54. package/dist/DateInput/DateInput.d.ts.map +1 -1
  55. package/dist/DateInput/DateInput.js +22 -5
  56. package/dist/DateInput/MonthScroller.d.ts.map +1 -1
  57. package/dist/DateInput/MonthScroller.js +5 -3
  58. package/dist/DateInput/MonthYearWheels.d.ts.map +1 -1
  59. package/dist/DateInput/MonthYearWheels.js +6 -4
  60. package/dist/DateInput/NativeDateField.d.ts +10 -0
  61. package/dist/DateInput/NativeDateField.d.ts.map +1 -0
  62. package/dist/DateInput/NativeDateField.js +494 -0
  63. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  64. package/dist/DateInput/TouchDateField.js +52 -25
  65. package/dist/DateInput/Wheel.d.ts.map +1 -1
  66. package/dist/DateInput/Wheel.js +1 -0
  67. package/dist/DateInput/index.d.ts +1 -1
  68. package/dist/DateInput/index.d.ts.map +1 -1
  69. package/dist/DateInput/nativeDateSegments.d.ts +22 -0
  70. package/dist/DateInput/nativeDateSegments.d.ts.map +1 -0
  71. package/dist/DateInput/nativeDateSegments.js +111 -0
  72. package/dist/DateRangeInput/DateRangeInput.d.ts +4 -2
  73. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  74. package/dist/DateRangeInput/DateRangeInput.js +3 -5
  75. package/dist/DateTimeInput/DateTimeInput.d.ts +23 -24
  76. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  77. package/dist/DateTimeInput/DateTimeInput.js +35 -10
  78. package/dist/DateTimeInput/TouchDateTimeField.d.ts +6 -0
  79. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -0
  80. package/dist/DateTimeInput/TouchDateTimeField.js +1021 -0
  81. package/dist/Dialog/Dialog.d.ts +16 -2
  82. package/dist/Dialog/Dialog.d.ts.map +1 -1
  83. package/dist/Dialog/Dialog.js +28 -7
  84. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  85. package/dist/DropdownMenu/DropdownMenu.js +4 -13
  86. package/dist/Field/FieldLabel.d.ts.map +1 -1
  87. package/dist/Field/FieldLabel.js +9 -1
  88. package/dist/Field/InputClearButton.d.ts +9 -3
  89. package/dist/Field/InputClearButton.d.ts.map +1 -1
  90. package/dist/Field/InputClearButton.js +14 -3
  91. package/dist/Heading/Heading.d.ts.map +1 -1
  92. package/dist/Heading/Heading.js +0 -1
  93. package/dist/HoverCard/HoverCard.d.ts.map +1 -1
  94. package/dist/HoverCard/HoverCard.js +104 -3
  95. package/dist/HoverCard/useHoverCard.d.ts +14 -2
  96. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  97. package/dist/HoverCard/useHoverCard.js +7 -1
  98. package/dist/Icon/Icon.d.ts +4 -2
  99. package/dist/Icon/Icon.d.ts.map +1 -1
  100. package/dist/Icon/defaultIcons.d.ts +3 -2
  101. package/dist/Icon/defaultIcons.d.ts.map +1 -1
  102. package/dist/Icon/defaultIcons.js +7 -0
  103. package/dist/Icon/globalIconRegistry.d.ts +13 -0
  104. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  105. package/dist/Icon/globalIconRegistry.js +34 -6
  106. package/dist/Icon/index.d.ts +1 -1
  107. package/dist/Icon/index.d.ts.map +1 -1
  108. package/dist/Icon/useIcon.d.ts +5 -2
  109. package/dist/Icon/useIcon.d.ts.map +1 -1
  110. package/dist/Icon/useIcon.js +3 -0
  111. package/dist/Indicator/CheckboxIndicator.d.ts.map +1 -1
  112. package/dist/Indicator/CheckboxIndicator.js +6 -2
  113. package/dist/Item/Item.d.ts.map +1 -1
  114. package/dist/Item/Item.js +2 -2
  115. package/dist/Kbd/Kbd.d.ts.map +1 -1
  116. package/dist/Kbd/Kbd.js +2 -26
  117. package/dist/Layer/gestureCounter.d.ts +8 -0
  118. package/dist/Layer/gestureCounter.d.ts.map +1 -0
  119. package/dist/Layer/gestureCounter.js +53 -0
  120. package/dist/Layer/useLayer.d.ts +23 -13
  121. package/dist/Layer/useLayer.d.ts.map +1 -1
  122. package/dist/Layer/useLayer.js +146 -24
  123. package/dist/List/List.d.ts +1 -1
  124. package/dist/List/List.d.ts.map +1 -1
  125. package/dist/List/List.js +15 -4
  126. package/dist/Markdown/Markdown.d.ts +1 -1
  127. package/dist/Markdown/Markdown.d.ts.map +1 -1
  128. package/dist/Markdown/Markdown.js +20 -7
  129. package/dist/Markdown/parser.d.ts.map +1 -1
  130. package/dist/Markdown/parser.js +5 -3
  131. package/dist/MetadataList/MetadataListItem.d.ts +1 -1
  132. package/dist/MetadataList/MetadataListItem.d.ts.map +1 -1
  133. package/dist/MetadataList/MetadataListItem.js +4 -1
  134. package/dist/MultiSelector/MultiSelector.d.ts +18 -1
  135. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  136. package/dist/MultiSelector/MultiSelector.js +58 -11
  137. package/dist/MultiSelector/hooks.d.ts +7 -1
  138. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  139. package/dist/MultiSelector/hooks.js +3 -2
  140. package/dist/NavIcon/NavIcon.d.ts.map +1 -1
  141. package/dist/NavIcon/NavIcon.js +5 -1
  142. package/dist/NavItem/navItemStyles.stylex.d.ts +0 -8
  143. package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
  144. package/dist/NavItem/navItemStyles.stylex.js +0 -2
  145. package/dist/NavMenu/NavHeadingMenu.d.ts +1 -1
  146. package/dist/NavMenu/NavHeadingMenu.d.ts.map +1 -1
  147. package/dist/NavMenu/NavHeadingMenu.js +7 -4
  148. package/dist/NumberInput/NumberInput.d.ts +6 -1
  149. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  150. package/dist/NumberInput/NumberInput.js +95 -126
  151. package/dist/NumberInput/numberInputCommit.d.ts +34 -0
  152. package/dist/NumberInput/numberInputCommit.d.ts.map +1 -0
  153. package/dist/NumberInput/numberInputCommit.js +71 -0
  154. package/dist/NumberInput/numberParser.d.ts +64 -0
  155. package/dist/NumberInput/numberParser.d.ts.map +1 -0
  156. package/dist/NumberInput/numberParser.js +350 -0
  157. package/dist/Outline/Outline.d.ts.map +1 -1
  158. package/dist/Outline/Outline.js +2 -3
  159. package/dist/OverflowList/OverflowList.d.ts +41 -1
  160. package/dist/OverflowList/OverflowList.d.ts.map +1 -1
  161. package/dist/OverflowList/OverflowList.js +43 -2
  162. package/dist/Popover/Popover.d.ts.map +1 -1
  163. package/dist/Popover/Popover.js +8 -14
  164. package/dist/Popover/usePopover.d.ts +15 -41
  165. package/dist/Popover/usePopover.d.ts.map +1 -1
  166. package/dist/Popover/usePopover.js +26 -7
  167. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  168. package/dist/PowerSearch/PowerSearchValueEditor.js +91 -18
  169. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  170. package/dist/PowerSearch/formatFilterValue.js +4 -2
  171. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  172. package/dist/ProgressBar/ProgressBar.js +18 -5
  173. package/dist/Resizable/useResizable.d.ts +19 -1
  174. package/dist/Resizable/useResizable.d.ts.map +1 -1
  175. package/dist/Resizable/useResizable.js +104 -38
  176. package/dist/SelectableCard/SelectableCard.d.ts.map +1 -1
  177. package/dist/SelectableCard/SelectableCard.js +9 -0
  178. package/dist/Selector/Selector.d.ts +18 -1
  179. package/dist/Selector/Selector.d.ts.map +1 -1
  180. package/dist/Selector/Selector.js +92 -21
  181. package/dist/Selector/hooks.d.ts +7 -1
  182. package/dist/Selector/hooks.d.ts.map +1 -1
  183. package/dist/Selector/hooks.js +3 -2
  184. package/dist/SideNav/SideNav.d.ts +6 -1
  185. package/dist/SideNav/SideNav.d.ts.map +1 -1
  186. package/dist/SideNav/SideNav.js +57 -27
  187. package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
  188. package/dist/SideNav/SideNavHeading.js +5 -4
  189. package/dist/SideNav/SideNavItem.d.ts +29 -4
  190. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  191. package/dist/SideNav/SideNavItem.js +75 -20
  192. package/dist/Slider/Slider.d.ts.map +1 -1
  193. package/dist/Slider/Slider.js +34 -2
  194. package/dist/Spinner/Spinner.d.ts +3 -1
  195. package/dist/Spinner/Spinner.d.ts.map +1 -1
  196. package/dist/Spinner/Spinner.js +199 -20
  197. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  198. package/dist/StatusDot/StatusDot.js +6 -1
  199. package/dist/Stepper/Step.d.ts.map +1 -1
  200. package/dist/Stepper/Step.js +7 -6
  201. package/dist/Switch/Switch.d.ts.map +1 -1
  202. package/dist/Switch/Switch.js +5 -1
  203. package/dist/Table/BaseTable.d.ts.map +1 -1
  204. package/dist/Table/BaseTable.js +6 -1
  205. package/dist/Table/Table.d.ts.map +1 -1
  206. package/dist/Table/Table.js +3 -6
  207. package/dist/Table/plugins/filtering/useTableFiltering.d.ts.map +1 -1
  208. package/dist/Table/plugins/filtering/useTableFiltering.js +73 -4
  209. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts +26 -0
  210. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  211. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +34 -3
  212. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  213. package/dist/Table/plugins/selection/useTableSelection.js +25 -1
  214. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.d.ts.map +1 -1
  215. package/dist/Table/plugins/stickyColumns/useTableStickyColumns.js +3 -4
  216. package/dist/Text/Text.d.ts.map +1 -1
  217. package/dist/Text/Text.js +0 -1
  218. package/dist/TextArea/TextArea.d.ts.map +1 -1
  219. package/dist/TextArea/TextArea.js +13 -8
  220. package/dist/Timestamp/Timestamp.d.ts +1 -1
  221. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  222. package/dist/Timestamp/Timestamp.js +10 -18
  223. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  224. package/dist/Timestamp/formatInstant.js +10 -5
  225. package/dist/Toast/Toast.d.ts +17 -2
  226. package/dist/Toast/Toast.d.ts.map +1 -1
  227. package/dist/Toast/Toast.js +77 -11
  228. package/dist/Toast/ToastViewport.d.ts +1 -0
  229. package/dist/Toast/ToastViewport.d.ts.map +1 -1
  230. package/dist/Toast/ToastViewport.js +76 -21
  231. package/dist/Toast/index.d.ts +1 -1
  232. package/dist/Toast/index.d.ts.map +1 -1
  233. package/dist/Toast/types.d.ts +38 -0
  234. package/dist/Toast/types.d.ts.map +1 -1
  235. package/dist/Toast/useToastGesture.d.ts +26 -0
  236. package/dist/Toast/useToastGesture.d.ts.map +1 -0
  237. package/dist/Toast/useToastGesture.js +223 -0
  238. package/dist/Token/Token.d.ts +1 -1
  239. package/dist/Token/Token.d.ts.map +1 -1
  240. package/dist/Token/Token.js +23 -12
  241. package/dist/Token/TokenLink.d.ts +1 -1
  242. package/dist/Token/TokenLink.d.ts.map +1 -1
  243. package/dist/Token/TokenLink.js +5 -2
  244. package/dist/Tokenizer/Tokenizer.d.ts +12 -1
  245. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  246. package/dist/Tokenizer/Tokenizer.js +31 -21
  247. package/dist/TopNav/TopNavHeading.js +2 -2
  248. package/dist/TopNav/TopNavItem.d.ts.map +1 -1
  249. package/dist/TopNav/TopNavItem.js +3 -3
  250. package/dist/TopNav/TopNavMegaMenu.d.ts +1 -1
  251. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  252. package/dist/TopNav/TopNavMegaMenu.js +30 -9
  253. package/dist/TopNav/TopNavMegaMenuItem.d.ts.map +1 -1
  254. package/dist/TopNav/TopNavMegaMenuItem.js +3 -3
  255. package/dist/TopNav/TopNavMenu.d.ts +1 -1
  256. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  257. package/dist/TopNav/TopNavMenu.js +21 -6
  258. package/dist/TreeList/TreeList.d.ts +1 -1
  259. package/dist/TreeList/TreeList.d.ts.map +1 -1
  260. package/dist/TreeList/TreeList.js +7 -2
  261. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  262. package/dist/TreeList/TreeListItem.js +2 -2
  263. package/dist/Typeahead/BaseTypeahead.d.ts +33 -0
  264. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  265. package/dist/Typeahead/BaseTypeahead.js +51 -8
  266. package/dist/Typeahead/Typeahead.d.ts +8 -1
  267. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  268. package/dist/Typeahead/Typeahead.js +2 -0
  269. package/dist/Typeahead/TypeaheadItem.d.ts +1 -1
  270. package/dist/Typeahead/TypeaheadItem.d.ts.map +1 -1
  271. package/dist/Typeahead/TypeaheadItem.js +7 -2
  272. package/dist/astryx.css +87 -22
  273. package/dist/hooks/useHotkeys.d.ts.map +1 -1
  274. package/dist/hooks/useHotkeys.js +3 -25
  275. package/dist/hooks/useOverflow.d.ts.map +1 -1
  276. package/dist/hooks/useOverflow.js +14 -4
  277. package/dist/index.d.ts +1 -1
  278. package/dist/index.d.ts.map +1 -1
  279. package/dist/theme/Theme.d.ts.map +1 -1
  280. package/dist/theme/Theme.js +46 -10
  281. package/dist/theme/defineTheme.d.ts +9 -3
  282. package/dist/theme/defineTheme.d.ts.map +1 -1
  283. package/dist/theme/defineTheme.js +5 -0
  284. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  285. package/dist/theme/derivedVarRegistry.js +24 -4
  286. package/dist/theme/generateThemeRules.d.ts +21 -0
  287. package/dist/theme/generateThemeRules.d.ts.map +1 -1
  288. package/dist/theme/generateThemeRules.js +26 -0
  289. package/dist/theme/syntax/tokens.d.ts +1 -1
  290. package/dist/theme/syntax/tokens.d.ts.map +1 -1
  291. package/dist/theme/syntax/tokens.js +5 -2
  292. package/dist/theme/useTheme.d.ts.map +1 -1
  293. package/dist/theme/useTheme.js +6 -7
  294. package/dist/utils/dateParser.d.ts +5 -8
  295. package/dist/utils/dateParser.d.ts.map +1 -1
  296. package/dist/utils/dateParser.js +10 -11
  297. package/dist/utils/focusOutline.stylex.d.ts +25 -0
  298. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  299. package/dist/utils/focusOutline.stylex.js +15 -0
  300. package/dist/utils/interactionOverlay.stylex.d.ts +26 -0
  301. package/dist/utils/interactionOverlay.stylex.d.ts.map +1 -0
  302. package/dist/utils/interactionOverlay.stylex.js +42 -0
  303. package/dist/utils/isApplePlatform.d.ts +18 -0
  304. package/dist/utils/isApplePlatform.d.ts.map +1 -0
  305. package/dist/utils/isApplePlatform.js +37 -0
  306. package/dist/utils/plainDate.d.ts +11 -0
  307. package/dist/utils/plainDate.d.ts.map +1 -1
  308. package/dist/utils/plainDate.js +20 -4
  309. package/locales/af-ZA.json +7 -7
  310. package/locales/ar-SA.json +7 -7
  311. package/locales/ca-ES.json +6 -6
  312. package/locales/cs-CZ.json +7 -7
  313. package/locales/da-DK.json +7 -7
  314. package/locales/de-DE.json +7 -7
  315. package/locales/el-GR.json +7 -7
  316. package/locales/en.json +178 -6
  317. package/locales/es-ES.json +6 -6
  318. package/locales/fi-FI.json +7 -7
  319. package/locales/fr-FR.json +7 -7
  320. package/locales/he-IL.json +7 -7
  321. package/locales/hu-HU.json +7 -7
  322. package/locales/it-IT.json +7 -7
  323. package/locales/ja-JP.json +7 -7
  324. package/locales/ko-KR.json +7 -7
  325. package/locales/nl-NL.json +7 -7
  326. package/locales/no-NO.json +7 -7
  327. package/locales/pl-PL.json +7 -7
  328. package/locales/pseudo.json +130 -1
  329. package/locales/pt-BR.json +7 -7
  330. package/locales/pt-PT.json +7 -7
  331. package/locales/ro-RO.json +7 -7
  332. package/locales/ru-RU.json +7 -7
  333. package/locales/sr-SP.json +7 -7
  334. package/locales/sv-SE.json +7 -7
  335. package/locales/tr-TR.json +7 -7
  336. package/locales/uk-UA.json +7 -7
  337. package/locales/vi-VN.json +7 -7
  338. package/locales/zh-CN.json +7 -7
  339. package/locales/zh-TW.json +7 -7
  340. package/package.json +4 -5
  341. package/src/AlertDialog/AlertDialog.doc.mjs +114 -30
  342. package/src/AlertDialog/AlertDialog.test.tsx +211 -2
  343. package/src/AlertDialog/AlertDialog.tsx +62 -20
  344. package/src/AspectRatio/AspectRatio.doc.mjs +3 -3
  345. package/src/AspectRatio/AspectRatio.tsx +6 -6
  346. package/src/Avatar/Avatar.doc.mjs +12 -2
  347. package/src/Avatar/Avatar.test.tsx +80 -0
  348. package/src/Avatar/Avatar.tsx +64 -14
  349. package/src/Avatar/index.ts +2 -2
  350. package/src/AvatarGroup/AvatarGroup.doc.mjs +8 -2
  351. package/src/AvatarGroup/AvatarGroup.test.tsx +24 -0
  352. package/src/AvatarGroup/AvatarGroup.tsx +12 -4
  353. package/src/AvatarGroup/AvatarGroupContext.ts +5 -3
  354. package/src/AvatarGroup/AvatarGroupOverflow.tsx +12 -12
  355. package/src/Badge/Badge.doc.mjs +3 -2
  356. package/src/Badge/Badge.test.tsx +32 -3
  357. package/src/Badge/Badge.tsx +42 -1
  358. package/src/Banner/Banner.doc.mjs +9 -0
  359. package/src/Banner/Banner.test.tsx +76 -11
  360. package/src/Banner/Banner.tsx +21 -2
  361. package/src/Button/Button.doc.mjs +2 -1
  362. package/src/Button/Button.test.tsx +19 -4
  363. package/src/Button/Button.tsx +9 -29
  364. package/src/ButtonGroup/ButtonGroup.doc.mjs +2 -2
  365. package/src/ButtonGroup/ButtonGroup.test.tsx +13 -0
  366. package/src/Calendar/Calendar.doc.mjs +3 -3
  367. package/src/Calendar/Calendar.test.tsx +112 -0
  368. package/src/Calendar/Calendar.tsx +18 -16
  369. package/src/Calendar/hooks/useCalendarDays.ts +4 -4
  370. package/src/Calendar/hooks/useCalendarNavigation.ts +4 -4
  371. package/src/Calendar/styles.ts +48 -42
  372. package/src/Card/Card.doc.mjs +10 -7
  373. package/src/Card/Card.test.tsx +113 -19
  374. package/src/Card/Card.tsx +67 -89
  375. package/src/Card/index.ts +36 -1
  376. package/src/Carousel/Carousel.doc.mjs +3 -0
  377. package/src/Carousel/Carousel.test.tsx +360 -0
  378. package/src/Carousel/Carousel.tsx +116 -15
  379. package/src/CheckboxInput/CheckboxInput.doc.mjs +2 -0
  380. package/src/CheckboxInput/CheckboxInput.test.tsx +15 -0
  381. package/src/CheckboxInput/CheckboxInput.tsx +5 -0
  382. package/src/CodeBlock/CodeBlock.doc.mjs +10 -4
  383. package/src/CodeBlock/CodeBlock.test.tsx +37 -0
  384. package/src/CodeBlock/CodeBlock.tsx +20 -4
  385. package/src/CodeBlock/highlightRanges.ts +1 -1
  386. package/src/CodeBlock/highlightStyles.ts +13 -13
  387. package/src/CommandPalette/CommandPaletteItem.tsx +2 -12
  388. package/src/ComplexSelector/ComplexSelector.doc.mjs +7 -0
  389. package/src/ComplexSelector/ComplexSelector.test.tsx +183 -0
  390. package/src/ComplexSelector/ComplexSelector.tsx +22 -12
  391. package/src/DateInput/DateInput.doc.mjs +16 -0
  392. package/src/DateInput/DateInput.test.tsx +9 -4
  393. package/src/DateInput/DateInput.tsx +54 -3
  394. package/src/DateInput/DateInputTouch.test.tsx +146 -32
  395. package/src/DateInput/MonthScroller.tsx +5 -1
  396. package/src/DateInput/MonthYearWheels.tsx +7 -4
  397. package/src/DateInput/NativeDateField.test.tsx +635 -0
  398. package/src/DateInput/NativeDateField.tsx +641 -0
  399. package/src/DateInput/TouchDateField.tsx +83 -33
  400. package/src/DateInput/Wheel.tsx +1 -0
  401. package/src/DateInput/index.ts +1 -0
  402. package/src/DateInput/nativeDateSegments.ts +127 -0
  403. package/src/DateRangeInput/DateRangeInput.doc.mjs +2 -2
  404. package/src/DateRangeInput/DateRangeInput.test.tsx +46 -0
  405. package/src/DateRangeInput/DateRangeInput.tsx +8 -13
  406. package/src/DateTimeInput/DateTimeInput.doc.mjs +38 -21
  407. package/src/DateTimeInput/DateTimeInput.test.tsx +76 -0
  408. package/src/DateTimeInput/DateTimeInput.tsx +54 -20
  409. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +980 -0
  410. package/src/DateTimeInput/TouchDateTimeField.tsx +1437 -0
  411. package/src/Dialog/Dialog.test.tsx +185 -18
  412. package/src/Dialog/Dialog.tsx +35 -11
  413. package/src/DropdownMenu/DropdownMenu.test.tsx +44 -10
  414. package/src/DropdownMenu/DropdownMenu.tsx +4 -13
  415. package/src/Field/FieldLabel.test.tsx +29 -0
  416. package/src/Field/FieldLabel.tsx +7 -0
  417. package/src/Field/InputClearButton.test.tsx +15 -1
  418. package/src/Field/InputClearButton.tsx +28 -4
  419. package/src/Heading/Heading.tsx +0 -1
  420. package/src/HoverCard/HoverCard.doc.mjs +8 -2
  421. package/src/HoverCard/HoverCard.test.tsx +154 -4
  422. package/src/HoverCard/HoverCard.tsx +137 -3
  423. package/src/HoverCard/useHoverCard.tsx +23 -3
  424. package/src/Icon/Icon.test.tsx +55 -1
  425. package/src/Icon/Icon.tsx +5 -3
  426. package/src/Icon/defaultIcons.tsx +11 -2
  427. package/src/Icon/globalIconRegistry.test.tsx +16 -3
  428. package/src/Icon/globalIconRegistry.tsx +37 -6
  429. package/src/Icon/index.ts +1 -0
  430. package/src/Icon/useIcon.ts +9 -2
  431. package/src/Indicator/CheckboxIndicator.tsx +14 -8
  432. package/src/Indicator/Indicator.doc.mjs +2 -0
  433. package/src/Item/Item.tsx +2 -7
  434. package/src/Kbd/Kbd.test.tsx +0 -17
  435. package/src/Kbd/Kbd.tsx +2 -26
  436. package/src/Layer/gestureCounter.ts +57 -0
  437. package/src/Layer/useLayer.test.tsx +368 -16
  438. package/src/Layer/useLayer.tsx +208 -35
  439. package/src/Layout/__tests__/edgeCompensation.test.tsx +1 -1
  440. package/src/List/List.test.tsx +38 -0
  441. package/src/List/List.tsx +8 -1
  442. package/src/Markdown/Markdown.test.tsx +37 -0
  443. package/src/Markdown/Markdown.tsx +16 -5
  444. package/src/Markdown/parser.test.ts +33 -0
  445. package/src/Markdown/parser.ts +9 -3
  446. package/src/MetadataList/MetadataList.test.tsx +75 -2
  447. package/src/MetadataList/MetadataListItem.tsx +5 -2
  448. package/src/MultiSelector/MultiSelector.doc.mjs +22 -1
  449. package/src/MultiSelector/MultiSelector.test.tsx +205 -0
  450. package/src/MultiSelector/MultiSelector.tsx +103 -16
  451. package/src/MultiSelector/hooks.ts +9 -2
  452. package/src/NavIcon/NavIcon.doc.mjs +8 -2
  453. package/src/NavIcon/NavIcon.test.tsx +9 -0
  454. package/src/NavIcon/NavIcon.tsx +5 -1
  455. package/src/NavItem/navItemStyles.stylex.ts +0 -8
  456. package/src/NavMenu/NavHeadingMenu.test.tsx +30 -0
  457. package/src/NavMenu/NavHeadingMenu.tsx +7 -4
  458. package/src/NumberInput/NumberInput.doc.mjs +26 -7
  459. package/src/NumberInput/NumberInput.test.tsx +563 -8
  460. package/src/NumberInput/NumberInput.tsx +77 -131
  461. package/src/NumberInput/numberInputCommit.test.ts +72 -0
  462. package/src/NumberInput/numberInputCommit.ts +103 -0
  463. package/src/NumberInput/numberParser.docblock.test.ts +234 -0
  464. package/src/NumberInput/numberParser.test.ts +222 -0
  465. package/src/NumberInput/numberParser.ts +403 -0
  466. package/src/Outline/Outline.tsx +2 -4
  467. package/src/OverflowList/OverflowList.doc.mjs +102 -21
  468. package/src/OverflowList/OverflowList.test.tsx +550 -4
  469. package/src/OverflowList/OverflowList.tsx +100 -2
  470. package/src/Pagination/Pagination.test.tsx +3 -2
  471. package/src/Popover/Popover.doc.mjs +2 -2
  472. package/src/Popover/Popover.test.tsx +38 -0
  473. package/src/Popover/Popover.tsx +28 -66
  474. package/src/Popover/usePopover.tsx +44 -5
  475. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +87 -0
  476. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +64 -0
  477. package/src/PowerSearch/PowerSearchValueEditor.tsx +109 -10
  478. package/src/PowerSearch/formatFilterValue.test.ts +16 -1
  479. package/src/PowerSearch/formatFilterValue.ts +2 -0
  480. package/src/ProgressBar/ProgressBar.doc.mjs +42 -6
  481. package/src/ProgressBar/ProgressBar.test.tsx +24 -0
  482. package/src/ProgressBar/ProgressBar.tsx +28 -8
  483. package/src/Resizable/Resizable.doc.mjs +2 -2
  484. package/src/Resizable/useResizable.doc.mjs +21 -2
  485. package/src/Resizable/useResizable.test.ts +421 -0
  486. package/src/Resizable/useResizable.ts +133 -48
  487. package/src/SelectableCard/SelectableCard.doc.mjs +3 -0
  488. package/src/SelectableCard/SelectableCard.tsx +16 -0
  489. package/src/Selector/Selector.doc.mjs +14 -0
  490. package/src/Selector/Selector.source-build.test.mjs +65 -0
  491. package/src/Selector/Selector.test.tsx +301 -0
  492. package/src/Selector/Selector.tsx +145 -32
  493. package/src/Selector/hooks.ts +17 -2
  494. package/src/SideNav/SideNav.doc.mjs +2 -2
  495. package/src/SideNav/SideNav.test.tsx +1069 -6
  496. package/src/SideNav/SideNav.tsx +109 -38
  497. package/src/SideNav/SideNavHeading.tsx +5 -2
  498. package/src/SideNav/SideNavItem.doc.mjs +24 -3
  499. package/src/SideNav/SideNavItem.tsx +134 -44
  500. package/src/Slider/Slider.test.tsx +106 -1
  501. package/src/Slider/Slider.tsx +52 -1
  502. package/src/Spinner/Spinner.doc.mjs +17 -5
  503. package/src/Spinner/Spinner.test.tsx +164 -0
  504. package/src/Spinner/Spinner.tsx +252 -22
  505. package/src/StatusDot/StatusDot.doc.mjs +8 -2
  506. package/src/StatusDot/StatusDot.test.tsx +9 -0
  507. package/src/StatusDot/StatusDot.tsx +7 -1
  508. package/src/Stepper/Step.doc.mjs +1 -1
  509. package/src/Stepper/Step.tsx +7 -14
  510. package/src/Switch/Switch.doc.mjs +2 -0
  511. package/src/Switch/Switch.test.tsx +10 -0
  512. package/src/Switch/Switch.tsx +3 -0
  513. package/src/Table/BaseTable.tsx +6 -1
  514. package/src/Table/Table.doc.mjs +5 -3
  515. package/src/Table/Table.tsx +3 -8
  516. package/src/Table/TableBody.doc.mjs +2 -2
  517. package/src/Table/TableFooter.doc.mjs +1 -1
  518. package/src/Table/TableHeader.doc.mjs +1 -1
  519. package/src/Table/plugins/filtering/useTableFiltering.test.tsx +44 -6
  520. package/src/Table/plugins/filtering/useTableFiltering.tsx +100 -4
  521. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +50 -4
  522. package/src/Table/plugins/selection/useTableSelection.test.tsx +62 -0
  523. package/src/Table/plugins/selection/useTableSelection.tsx +25 -2
  524. package/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx +1 -2
  525. package/src/Table/useTableGroupedRows.doc.mjs +3 -3
  526. package/src/Table/useTablePagination.doc.mjs +1 -1
  527. package/src/Table/useTableSelection.doc.mjs +1 -1
  528. package/src/Text/Text.test.tsx +44 -0
  529. package/src/Text/Text.tsx +0 -1
  530. package/src/TextArea/TextArea.doc.mjs +12 -2
  531. package/src/TextArea/TextArea.test.tsx +11 -0
  532. package/src/TextArea/TextArea.tsx +34 -22
  533. package/src/Timestamp/Timestamp.test.tsx +89 -0
  534. package/src/Timestamp/Timestamp.tsx +8 -8
  535. package/src/Timestamp/formatInstant.ts +5 -0
  536. package/src/Toast/Toast.doc.mjs +213 -44
  537. package/src/Toast/Toast.tsx +118 -23
  538. package/src/Toast/ToastViewport.test.tsx +1434 -8
  539. package/src/Toast/ToastViewport.tsx +85 -14
  540. package/src/Toast/index.ts +2 -0
  541. package/src/Toast/types.ts +43 -5
  542. package/src/Toast/useToastGesture.ts +361 -0
  543. package/src/Token/Token.test.tsx +95 -0
  544. package/src/Token/Token.tsx +19 -17
  545. package/src/Token/TokenLink.tsx +12 -3
  546. package/src/Tokenizer/Tokenizer.doc.mjs +16 -3
  547. package/src/Tokenizer/Tokenizer.test.tsx +161 -0
  548. package/src/Tokenizer/Tokenizer.tsx +49 -24
  549. package/src/TopNav/TopNavHeading.tsx +2 -2
  550. package/src/TopNav/TopNavItem.tsx +3 -7
  551. package/src/TopNav/TopNavMegaMenu.test.tsx +104 -0
  552. package/src/TopNav/TopNavMegaMenu.tsx +47 -4
  553. package/src/TopNav/TopNavMegaMenuItem.tsx +10 -9
  554. package/src/TopNav/TopNavMenu.test.tsx +133 -1
  555. package/src/TopNav/TopNavMenu.tsx +38 -5
  556. package/src/TreeList/TreeList.test.tsx +49 -0
  557. package/src/TreeList/TreeList.tsx +7 -2
  558. package/src/TreeList/TreeListItem.tsx +2 -7
  559. package/src/Typeahead/BaseTypeahead.doc.mjs +13 -0
  560. package/src/Typeahead/BaseTypeahead.tsx +96 -6
  561. package/src/Typeahead/Typeahead.doc.mjs +6 -0
  562. package/src/Typeahead/Typeahead.test.tsx +154 -0
  563. package/src/Typeahead/Typeahead.tsx +9 -0
  564. package/src/Typeahead/TypeaheadItem.test.tsx +43 -0
  565. package/src/Typeahead/TypeaheadItem.tsx +8 -1
  566. package/src/hooks/useAnnounce.doc.mjs +96 -0
  567. package/src/hooks/useDevWarning.doc.mjs +96 -0
  568. package/src/hooks/useHotkeys.test.ts +0 -18
  569. package/src/hooks/useHotkeys.ts +3 -25
  570. package/src/hooks/useIndicatorFocusRing.doc.mjs +104 -0
  571. package/src/hooks/useInputStatusIcon.doc.mjs +142 -0
  572. package/src/hooks/useInteractiveRole.doc.mjs +119 -0
  573. package/src/hooks/useLongPress.doc.mjs +123 -0
  574. package/src/hooks/useOverflow.doc.mjs +58 -17
  575. package/src/hooks/useOverflow.ts +16 -4
  576. package/src/hooks/useTypeahead.doc.mjs +153 -0
  577. package/src/index.ts +2 -0
  578. package/src/raw-import.d.ts +14 -0
  579. package/src/theme/Theme.test.tsx +104 -2
  580. package/src/theme/Theme.tsx +49 -14
  581. package/src/theme/defineTheme.test.ts +23 -0
  582. package/src/theme/defineTheme.ts +12 -3
  583. package/src/theme/derivedVarRegistry.test.ts +87 -3
  584. package/src/theme/derivedVarRegistry.ts +23 -4
  585. package/src/theme/generateThemeRules.test.ts +99 -1
  586. package/src/theme/generateThemeRules.ts +28 -0
  587. package/src/theme/syntax/tokens.ts +5 -2
  588. package/src/theme/useTheme.test.tsx +10 -0
  589. package/src/theme/useTheme.ts +16 -10
  590. package/src/utils/dateParser.test.ts +34 -2
  591. package/src/utils/dateParser.ts +11 -14
  592. package/src/utils/focusOutline.stylex.ts +50 -0
  593. package/src/utils/interactionOverlay.stylex.ts +74 -0
  594. package/src/utils/isApplePlatform.test.ts +61 -0
  595. package/src/utils/isApplePlatform.ts +37 -0
  596. package/src/utils/plainDate.test.ts +46 -0
  597. package/src/utils/plainDate.ts +18 -3
@@ -10,8 +10,15 @@
10
10
  */
11
11
 
12
12
  import React from 'react';
13
- import {describe, it, expect, vi, afterEach} from 'vitest';
14
- import {render, screen, act, fireEvent, waitFor} from '@testing-library/react';
13
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
14
+ import {
15
+ render,
16
+ screen,
17
+ act,
18
+ fireEvent,
19
+ waitFor,
20
+ within,
21
+ } from '@testing-library/react';
15
22
  import userEvent from '@testing-library/user-event';
16
23
  import {useRef, useState, type ReactNode} from 'react';
17
24
  import * as stylex from '@stylexjs/stylex';
@@ -1546,6 +1553,318 @@ describe('SideNavItem — collapsible + href', () => {
1546
1553
  });
1547
1554
  });
1548
1555
 
1556
+ // =============================================================================
1557
+ // SideNavItem — actions slot (row-level secondary controls, #4988)
1558
+ // =============================================================================
1559
+
1560
+ describe('SideNavItem — actions slot', () => {
1561
+ const rowAction = (onClick?: (e: React.MouseEvent) => void) => (
1562
+ <button type="button" data-testid="row-action" onClick={onClick}>
1563
+ ⋯
1564
+ </button>
1565
+ );
1566
+
1567
+ it('renders actions outside the primary interactive element', () => {
1568
+ render(
1569
+ <SideNavItem label="Project" href="/project" actions={rowAction()} />,
1570
+ );
1571
+ const action = screen.getByTestId('row-action');
1572
+ const link = screen.getByRole('link', {name: 'Project'});
1573
+ // Sibling, not nested: the action's closest interactive element is itself.
1574
+ expect(within(link).queryByTestId('row-action')).toBeNull();
1575
+ expect(action.closest('a, button')).toBe(action);
1576
+ });
1577
+
1578
+ it('places actions after the primary element and before nested children', () => {
1579
+ render(
1580
+ <SideNavItem
1581
+ label="Project"
1582
+ href="/project"
1583
+ collapsible
1584
+ actions={rowAction()}>
1585
+ <SideNavItem label="Session" href="/project/session" />
1586
+ </SideNavItem>,
1587
+ );
1588
+ const primary = screen.getByRole('link', {name: 'Project'});
1589
+ const action = screen.getByTestId('row-action');
1590
+ const group = screen.getByRole('group');
1591
+ // Compare real node positions, not serialized markup.
1592
+ expect(
1593
+ primary.compareDocumentPosition(action) &
1594
+ Node.DOCUMENT_POSITION_FOLLOWING,
1595
+ ).toBeTruthy();
1596
+ expect(
1597
+ action.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING,
1598
+ ).toBeTruthy();
1599
+ });
1600
+
1601
+ it('reaches every row-level control before nested items when tabbing', async () => {
1602
+ const user = userEvent.setup();
1603
+ render(
1604
+ <SideNavItem
1605
+ label="Project"
1606
+ href="/project"
1607
+ collapsible
1608
+ actions={rowAction()}>
1609
+ <SideNavItem label="Session" href="/project/session" />
1610
+ </SideNavItem>,
1611
+ );
1612
+ await user.tab();
1613
+ expect(screen.getByRole('link', {name: 'Project'})).toHaveFocus();
1614
+ await user.tab();
1615
+ expect(
1616
+ screen.getByRole('button', {name: /collapse project/i}),
1617
+ ).toHaveFocus();
1618
+ await user.tab();
1619
+ expect(screen.getByTestId('row-action')).toHaveFocus();
1620
+ await user.tab();
1621
+ expect(screen.getByRole('link', {name: 'Session'})).toHaveFocus();
1622
+ });
1623
+
1624
+ it('clicking an action does not activate the item', async () => {
1625
+ const user = userEvent.setup();
1626
+ const onItemClick = vi.fn();
1627
+ const onActionClick = vi.fn();
1628
+ render(
1629
+ <SideNavItem
1630
+ label="Project"
1631
+ onClick={onItemClick}
1632
+ actions={rowAction(onActionClick)}
1633
+ />,
1634
+ );
1635
+ await user.click(screen.getByTestId('row-action'));
1636
+ expect(onActionClick).toHaveBeenCalledTimes(1);
1637
+ expect(onItemClick).not.toHaveBeenCalled();
1638
+ });
1639
+
1640
+ it('clicking an action does not toggle collapse of a whole-row toggle item', async () => {
1641
+ const user = userEvent.setup();
1642
+ render(
1643
+ <SideNavItem label="Project" collapsible actions={rowAction()}>
1644
+ <SideNavItem label="Session" />
1645
+ </SideNavItem>,
1646
+ );
1647
+ const rowToggle = screen.getByRole('button', {name: 'Project'});
1648
+ expect(rowToggle).toHaveAttribute('aria-expanded', 'true');
1649
+ await user.click(screen.getByTestId('row-action'));
1650
+ expect(rowToggle).toHaveAttribute('aria-expanded', 'true');
1651
+ });
1652
+
1653
+ it('whole-row toggle still collapses when actions are present', async () => {
1654
+ const user = userEvent.setup();
1655
+ render(
1656
+ <SideNavItem label="Project" collapsible actions={rowAction()}>
1657
+ <SideNavItem label="Session" />
1658
+ </SideNavItem>,
1659
+ );
1660
+ expect(screen.getByTestId('row-action')).toBeInTheDocument();
1661
+ const rowToggle = screen.getByRole('button', {name: 'Project'});
1662
+ await user.click(rowToggle);
1663
+ expect(rowToggle).toHaveAttribute('aria-expanded', 'false');
1664
+ });
1665
+
1666
+ it('adds a row wrapper only when actions are present', () => {
1667
+ const {unmount} = render(<SideNavItem label="Project" href="/project" />);
1668
+ // Without actions, the link itself is the styled row element.
1669
+ expect(screen.getByRole('link', {name: 'Project'})).toHaveClass(
1670
+ 'astryx-side-nav-item',
1671
+ );
1672
+ unmount();
1673
+
1674
+ render(
1675
+ <SideNavItem
1676
+ label="Project"
1677
+ href="/project"
1678
+ data-testid="row"
1679
+ actions={rowAction()}
1680
+ />,
1681
+ );
1682
+ // With actions, a wrapper div is the styled row; the link sits inside it.
1683
+ const link = screen.getByRole('link', {name: 'Project'});
1684
+ expect(link).not.toHaveClass('astryx-side-nav-item');
1685
+ const row = screen.getByTestId('row');
1686
+ expect(row).toHaveClass('astryx-side-nav-item');
1687
+ expect(row).toContainElement(link);
1688
+ expect(row).toContainElement(screen.getByTestId('row-action'));
1689
+ });
1690
+
1691
+ it('adds no row wrapper for a falsy actions value', () => {
1692
+ // `actions={canEdit ? <Menu /> : null}` is the ordinary consumer shape.
1693
+ // A wrapper there would change markup, focus order and the ring's
1694
+ // owner for a row that has no row controls at all.
1695
+ const {rerender} = render(
1696
+ <SideNavItem label="Project" href="/project" actions={null} />,
1697
+ );
1698
+ expect(screen.getByRole('link', {name: 'Project'})).toHaveClass(
1699
+ 'astryx-side-nav-item',
1700
+ );
1701
+
1702
+ rerender(<SideNavItem label="Project" href="/project" actions={false} />);
1703
+ expect(screen.getByRole('link', {name: 'Project'})).toHaveClass(
1704
+ 'astryx-side-nav-item',
1705
+ );
1706
+ });
1707
+
1708
+ it('keeps aria wiring on the toggle when actions are present', () => {
1709
+ render(
1710
+ <SideNavItem
1711
+ label="Project"
1712
+ href="/project"
1713
+ collapsible
1714
+ actions={rowAction()}>
1715
+ <SideNavItem label="Session" />
1716
+ </SideNavItem>,
1717
+ );
1718
+ expect(screen.getByTestId('row-action')).toBeInTheDocument();
1719
+ const toggle = screen.getByRole('button', {name: /collapse project/i});
1720
+ const group = screen.getByRole('group');
1721
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
1722
+ expect(toggle).toHaveAttribute('aria-controls', group.id);
1723
+ expect(screen.getByRole('link', {name: 'Project'})).not.toHaveAttribute(
1724
+ 'aria-expanded',
1725
+ );
1726
+ });
1727
+
1728
+ it('keeps endContent inside the primary element when actions are present', () => {
1729
+ render(
1730
+ <SideNavItem
1731
+ label="Project"
1732
+ href="/project"
1733
+ endContent={<span data-testid="badge">3</span>}
1734
+ actions={rowAction()}
1735
+ />,
1736
+ );
1737
+ const link = screen.getByRole('link', {name: /Project/});
1738
+ expect(within(link).getByTestId('badge')).toBeInTheDocument();
1739
+ expect(within(link).queryByTestId('row-action')).toBeNull();
1740
+ expect(screen.getByTestId('row-action')).toBeInTheDocument();
1741
+ });
1742
+
1743
+ it('keeps collapsed children inert while the action stays in the row', () => {
1744
+ render(
1745
+ <SideNavItem
1746
+ label="Project"
1747
+ collapsible={{defaultIsCollapsed: true}}
1748
+ actions={rowAction()}>
1749
+ <SideNavItem label="Session" href="/project/session" />
1750
+ </SideNavItem>,
1751
+ );
1752
+ const rowToggle = screen.getByRole('button', {name: 'Project'});
1753
+ const group = document.getElementById(
1754
+ rowToggle.getAttribute('aria-controls')!,
1755
+ );
1756
+ expect(group).toHaveAttribute('inert');
1757
+ expect(screen.getByTestId('row-action')).toBeInTheDocument();
1758
+ });
1759
+
1760
+ it('does not render actions in the collapsed rail', () => {
1761
+ const {unmount} = renderCollapsed(
1762
+ <SideNavItem
1763
+ label="Project"
1764
+ icon={StubIcon}
1765
+ href="/project"
1766
+ actions={rowAction()}
1767
+ />,
1768
+ );
1769
+ expect(screen.getByLabelText('Project')).toBeInTheDocument();
1770
+ expect(screen.queryByTestId('row-action')).toBeNull();
1771
+ unmount();
1772
+
1773
+ renderCollapsed(
1774
+ <SideNavItem label="Project" icon={StubIcon} actions={rowAction()}>
1775
+ <SideNavItem label="Session" href="/project/session" />
1776
+ </SideNavItem>,
1777
+ );
1778
+ expect(screen.getByLabelText('Project')).toBeInTheDocument();
1779
+ expect(screen.queryByTestId('row-action')).toBeNull();
1780
+ });
1781
+
1782
+ it('keeps actions clickable on a disabled item', async () => {
1783
+ const user = userEvent.setup();
1784
+ const onActionClick = vi.fn();
1785
+ render(
1786
+ <SideNavItem
1787
+ label="Project"
1788
+ href="/project"
1789
+ isDisabled
1790
+ actions={rowAction(onActionClick)}
1791
+ />,
1792
+ );
1793
+ // The row's disabled styling must not swallow the sibling action:
1794
+ // the action control owns its own disabled state.
1795
+ await user.click(screen.getByTestId('row-action'));
1796
+ expect(onActionClick).toHaveBeenCalledTimes(1);
1797
+ });
1798
+
1799
+ it('places actions after the toggle when endContent is also present', () => {
1800
+ render(
1801
+ <SideNavItem
1802
+ label="Project"
1803
+ href="/project"
1804
+ collapsible
1805
+ endContent={<span data-testid="badge">3</span>}
1806
+ actions={rowAction()}>
1807
+ <SideNavItem label="Session" href="/project/session" />
1808
+ </SideNavItem>,
1809
+ );
1810
+ const link = screen.getByRole('link', {name: /Project/});
1811
+ const toggle = screen.getByRole('button', {name: /collapse project/i});
1812
+ const action = screen.getByTestId('row-action');
1813
+ expect(within(link).getByTestId('badge')).toBeInTheDocument();
1814
+ expect(
1815
+ link.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING,
1816
+ ).toBeTruthy();
1817
+ expect(
1818
+ toggle.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING,
1819
+ ).toBeTruthy();
1820
+ });
1821
+
1822
+ it('reaches the action after the whole-row toggle and before nested items', async () => {
1823
+ const user = userEvent.setup();
1824
+ render(
1825
+ <SideNavItem label="Project" collapsible actions={rowAction()}>
1826
+ <SideNavItem label="Session" href="/project/session" />
1827
+ </SideNavItem>,
1828
+ );
1829
+ await user.tab();
1830
+ expect(screen.getByRole('button', {name: 'Project'})).toHaveFocus();
1831
+ await user.tab();
1832
+ expect(screen.getByTestId('row-action')).toHaveFocus();
1833
+ await user.tab();
1834
+ expect(screen.getByRole('link', {name: 'Session'})).toHaveFocus();
1835
+ });
1836
+
1837
+ it('renders the custom link component inside the row wrapper', () => {
1838
+ render(
1839
+ <LinkProvider component={CustomLink}>
1840
+ <SideNavItem label="Project" href="/project" actions={rowAction()} />
1841
+ </LinkProvider>,
1842
+ );
1843
+ const link = screen.getByRole('link', {name: 'Project'});
1844
+ expect(link).toHaveAttribute('data-custom-link');
1845
+ expect(link.parentElement).toHaveClass('astryx-side-nav-item');
1846
+ });
1847
+
1848
+ it('controlled collapse reports toggle intent without flipping until the prop changes', async () => {
1849
+ const user = userEvent.setup();
1850
+ const onCollapsedChange = vi.fn();
1851
+ render(
1852
+ <SideNavItem
1853
+ label="Project"
1854
+ href="/project"
1855
+ collapsible={{isCollapsed: false, onCollapsedChange}}
1856
+ actions={rowAction()}>
1857
+ <SideNavItem label="Session" href="/project/session" />
1858
+ </SideNavItem>,
1859
+ );
1860
+ const toggle = screen.getByRole('button', {name: /collapse project/i});
1861
+ await user.click(toggle);
1862
+ expect(onCollapsedChange).toHaveBeenCalledWith(true);
1863
+ // Controlled mode: stays expanded until the consumer flips the prop.
1864
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
1865
+ });
1866
+ });
1867
+
1549
1868
  import {SideNavRenderContext} from './SideNavRenderContext';
1550
1869
  import {AppShellMobileContext} from '../AppShell/AppShellMobileContext';
1551
1870
 
@@ -1625,6 +1944,28 @@ describe('SideNavItem — mobile drawer close-on-activate', () => {
1625
1944
  await user.click(screen.getByTestId('item'));
1626
1945
  expect(closeMobileNav).not.toHaveBeenCalled();
1627
1946
  });
1947
+
1948
+ it('does not close the mobile nav when a row action is clicked', async () => {
1949
+ const user = userEvent.setup();
1950
+ const onActionClick = vi.fn();
1951
+ const {closeMobileNav} = renderInDrawer(
1952
+ <SideNavItem
1953
+ label="Home"
1954
+ href="/"
1955
+ actions={
1956
+ <button
1957
+ type="button"
1958
+ data-testid="drawer-row-action"
1959
+ onClick={onActionClick}>
1960
+ ⋯
1961
+ </button>
1962
+ }
1963
+ />,
1964
+ );
1965
+ await user.click(screen.getByTestId('drawer-row-action'));
1966
+ expect(onActionClick).toHaveBeenCalledTimes(1);
1967
+ expect(closeMobileNav).not.toHaveBeenCalled();
1968
+ });
1628
1969
  });
1629
1970
 
1630
1971
  // =============================================================================
@@ -1899,10 +2240,18 @@ describe('SideNav audit regressions', () => {
1899
2240
  // =============================================================================
1900
2241
  // A15 — the shared focus ring
1901
2242
  //
1902
- // jsdom does not derive `:focus-visible` from `.focus()` or from Tab, so the
1903
- // painted ring cannot be read back here; the geometry is measured in a real
1904
- // browser instead. These pin the composition — that the focusable element
1905
- // carries the classes `focusOutlineStyles.focusVisible` compiles to.
2243
+ // jsdom does not derive `:focus-visible` from `.focus()` for an element
2244
+ // matched directly, so the ring a tab stop draws on itself cannot be read
2245
+ // back here; the geometry is measured in a real browser instead. These pin
2246
+ // the composition — that the focusable element carries the classes
2247
+ // `focusOutlineStyles.focusVisible` compiles to.
2248
+ //
2249
+ // Composition is only proof for a tab stop. A row wrapper is not one, so
2250
+ // `:focus-visible` atoms sitting on it can never match, and asserting them
2251
+ // there proves nothing: the actions-row tests below passed with the
2252
+ // wrapper's ring deleted outright. A wrapper's ring is guarded by `:has()`,
2253
+ // which jsdom *does* re-evaluate against live focus, so those rules are read
2254
+ // out of the stylesheet and matched against the DOM instead.
1906
2255
  // =============================================================================
1907
2256
 
1908
2257
  const sharedFocusRingClasses = stylex
@@ -1923,6 +2272,120 @@ function expectNoSharedFocusRing(el: Element) {
1923
2272
  }
1924
2273
  }
1925
2274
 
2275
+ /** Every style rule in the live sheet, flattened out of any grouping rule. */
2276
+ function allStyleRules(): CSSStyleRule[] {
2277
+ const out: CSSStyleRule[] = [];
2278
+ const walk = (rules: CSSRuleList) => {
2279
+ for (const rule of Array.from(rules)) {
2280
+ const nested = (rule as CSSGroupingRule).cssRules;
2281
+ if (nested) {
2282
+ walk(nested);
2283
+ }
2284
+ if ((rule as CSSStyleRule).selectorText) {
2285
+ out.push(rule as CSSStyleRule);
2286
+ }
2287
+ }
2288
+ };
2289
+ for (const sheet of Array.from(document.styleSheets)) {
2290
+ try {
2291
+ walk(sheet.cssRules);
2292
+ } catch {
2293
+ // A sheet we cannot read cannot be one StyleX injected.
2294
+ }
2295
+ }
2296
+ return out;
2297
+ }
2298
+
2299
+ /**
2300
+ * A rule's declared value for `prop`, read out of its text.
2301
+ *
2302
+ * Not `rule.style`: these values are `var()`s, and jsdom's CSS object model
2303
+ * drops longhands it cannot parse, so they read back as an empty string there.
2304
+ */
2305
+ function declaredValue(rule: CSSStyleRule, prop: string): string | undefined {
2306
+ const body = /\{([^}]*)\}/.exec(rule.cssText)?.[1] ?? '';
2307
+ return new RegExp(`(?:^|;)\\s*${prop}:\\s*([^;]+)`).exec(body)?.[1].trim();
2308
+ }
2309
+
2310
+ /** A rule's `outline-style` — the discriminator for a painting focus ring. */
2311
+ function outlineStyleOf(rule: CSSStyleRule): string | undefined {
2312
+ return declaredValue(rule, 'outline-style');
2313
+ }
2314
+
2315
+ /**
2316
+ * What the live sheet unconditionally declares for `prop` on `el`, gathered
2317
+ * from every bare-class rule the element carries. StyleX emits one atomic
2318
+ * class per property, so a well-formed element yields exactly one value —
2319
+ * comparing those values compares real CSS without pinning a generated hash.
2320
+ */
2321
+ function declaredOn(el: Element, prop: string): string[] {
2322
+ const classes = new Set(el.className.split(' '));
2323
+ return allStyleRules()
2324
+ .filter(rule => {
2325
+ const bare = rule.selectorText.replace(/:not\(#\\?#\)/g, '');
2326
+ const owner = /^\.([^:\s]+)$/.exec(bare)?.[1];
2327
+ return owner != null && classes.has(owner);
2328
+ })
2329
+ .map(rule => declaredValue(rule, prop))
2330
+ .filter(value => value != null);
2331
+ }
2332
+
2333
+ /**
2334
+ * The selectors that actually paint the shared ring on `el`, read out of the
2335
+ * live stylesheet with StyleX's `:not(#\#)` specificity padding stripped so
2336
+ * jsdom can evaluate them.
2337
+ *
2338
+ * `outline-style` is the discriminator: StyleX emits one rule per property,
2339
+ * and no outline paints while that one resolves to `none`.
2340
+ */
2341
+ function ringSelectorsFor(el: Element): string[] {
2342
+ const classes = new Set(el.className.split(' '));
2343
+ return allStyleRules()
2344
+ .filter(rule => {
2345
+ const painted = outlineStyleOf(rule);
2346
+ if (!painted || painted === 'none') {
2347
+ return false;
2348
+ }
2349
+ const owner = /^\.([^:\s]+)/.exec(rule.selectorText)?.[1];
2350
+ return owner != null && classes.has(owner);
2351
+ })
2352
+ .map(rule => rule.selectorText.replace(/:not\(#\\?#\)/g, ''));
2353
+ }
2354
+
2355
+ /**
2356
+ * Whether `el`'s ring is painting for whatever holds focus right now.
2357
+ *
2358
+ * `:focus-visible` is substituted with `:focus` before matching. jsdom
2359
+ * derives `:focus-visible` from a heuristic over the last interaction, so it
2360
+ * answers differently depending on which tests ran before — the same
2361
+ * assertion passes alone and fails in the file. `:focus` is unambiguous, and
2362
+ * the ring's modality is pinned separately, on the selector text, by the
2363
+ * keyboard-only test below.
2364
+ */
2365
+ function ringIsPainting(el: Element): boolean {
2366
+ return ringSelectorsFor(el).some(selector =>
2367
+ el.matches(selector.replace(/:focus-visible/g, ':focus')),
2368
+ );
2369
+ }
2370
+
2371
+ /**
2372
+ * Whether `el` unconditionally declares `outline-style: none`, which is what
2373
+ * keeps the UA's own focus ring off an element whose ring an ancestor paints.
2374
+ * Computed style cannot answer this: `none` is also the CSS initial value.
2375
+ */
2376
+ function suppressesOwnOutline(el: Element): boolean {
2377
+ const classes = new Set(el.className.split(' '));
2378
+ return allStyleRules().some(rule => {
2379
+ if (outlineStyleOf(rule) !== 'none') {
2380
+ return false;
2381
+ }
2382
+ // Unconditional only — a bare class once the padding is stripped.
2383
+ const bare = rule.selectorText.replace(/:not\(#\\?#\)/g, '');
2384
+ const owner = /^\.([^:\s]+)$/.exec(bare)?.[1];
2385
+ return owner != null && classes.has(owner);
2386
+ });
2387
+ }
2388
+
1926
2389
  describe('SideNav focus ring (A15)', () => {
1927
2390
  it('draws the shared ring on a link item', () => {
1928
2391
  render(<SideNavItem label="Dashboard" href="/dashboard" />);
@@ -1970,6 +2433,173 @@ describe('SideNav focus ring (A15)', () => {
1970
2433
  expectNoSharedFocusRing(row);
1971
2434
  });
1972
2435
 
2436
+ it('rings the row wrapper of an actions row, not the truncated primary', async () => {
2437
+ const user = userEvent.setup();
2438
+ render(
2439
+ <SideNavItem
2440
+ label="Project"
2441
+ href="/project"
2442
+ actions={
2443
+ <button type="button" data-testid="row-action">
2444
+ More
2445
+ </button>
2446
+ }
2447
+ />,
2448
+ );
2449
+
2450
+ const link = screen.getByRole('link', {name: 'Project'});
2451
+ const row = link.parentElement!;
2452
+ expect(row.tagName).toBe('DIV');
2453
+ expect(row).toContainElement(screen.getByTestId('row-action'));
2454
+
2455
+ // Same pill as an ordinary row: the ring lives on the wrapper, whose box
2456
+ // includes the actions. Putting it on the primary would clip to the
2457
+ // truncated split-action element (square, not full-row).
2458
+ expect(ringIsPainting(row)).toBe(false);
2459
+ await user.tab();
2460
+ expect(link).toHaveFocus();
2461
+ expect(ringIsPainting(row)).toBe(true);
2462
+ // And exactly one ring: nothing paints a second one on the primary.
2463
+ expect(ringSelectorsFor(link)).toEqual([]);
2464
+ });
2465
+
2466
+ it('leaves the wrapper ring to the primary, not the chevron toggle', async () => {
2467
+ const user = userEvent.setup();
2468
+ render(
2469
+ <SideNavItem
2470
+ label="Settings"
2471
+ href="/settings"
2472
+ collapsible
2473
+ actions={
2474
+ <button type="button" data-testid="row-action">
2475
+ More
2476
+ </button>
2477
+ }>
2478
+ <SideNavItem label="General" href="/settings/general" />
2479
+ </SideNavItem>,
2480
+ );
2481
+
2482
+ const link = screen.getByRole('link', {name: 'Settings'});
2483
+ const toggle = screen.getByRole('button', {name: 'Collapse Settings'});
2484
+ const row = link.parentElement!;
2485
+ expect(row).toContainElement(screen.getByTestId('row-action'));
2486
+
2487
+ await user.tab();
2488
+ expect(link).toHaveFocus();
2489
+ expect(ringIsPainting(row)).toBe(true);
2490
+
2491
+ // The toggle owns its own ring, as it does in a split-action row with no
2492
+ // actions. Keeping the wrapper lit too would stack a full-row outline
2493
+ // around the chevron's own — two rings for one tab stop.
2494
+ await user.tab();
2495
+ expect(toggle).toHaveFocus();
2496
+ expectSharedFocusRing(toggle);
2497
+ expect(ringIsPainting(row)).toBe(false);
2498
+ });
2499
+
2500
+ it('does not ring the row wrapper when a supplied action takes focus', async () => {
2501
+ const user = userEvent.setup();
2502
+ render(
2503
+ <SideNavItem
2504
+ label="Project"
2505
+ href="/project"
2506
+ actions={
2507
+ <button type="button" data-testid="row-action">
2508
+ More
2509
+ </button>
2510
+ }
2511
+ />,
2512
+ );
2513
+
2514
+ const row = screen.getByRole('link', {name: 'Project'}).parentElement!;
2515
+ await user.tab();
2516
+ expect(ringIsPainting(row)).toBe(true);
2517
+
2518
+ // An action draws whatever ring its own component draws. Focus is not on
2519
+ // the row, so the row must not light up around it.
2520
+ await user.tab();
2521
+ expect(screen.getByTestId('row-action')).toHaveFocus();
2522
+ expect(ringIsPainting(row)).toBe(false);
2523
+ });
2524
+
2525
+ it('rings the wrapper of a whole-row toggle carrying actions', async () => {
2526
+ const user = userEvent.setup();
2527
+ render(
2528
+ <SideNavItem
2529
+ label="Project"
2530
+ collapsible
2531
+ actions={
2532
+ <button type="button" data-testid="row-action">
2533
+ More
2534
+ </button>
2535
+ }>
2536
+ <SideNavItem label="Session" href="/project/session" />
2537
+ </SideNavItem>,
2538
+ );
2539
+
2540
+ // No href and no onClick, so the primary *is* the collapse toggle and
2541
+ // keeps the chevron inside it — a different row shape from the split.
2542
+ const primary = screen.getByRole('button', {name: 'Project'});
2543
+ const row = primary.parentElement!;
2544
+
2545
+ await user.tab();
2546
+ expect(primary).toHaveFocus();
2547
+ expect(ringIsPainting(row)).toBe(true);
2548
+
2549
+ await user.tab();
2550
+ expect(screen.getByTestId('row-action')).toHaveFocus();
2551
+ expect(ringIsPainting(row)).toBe(false);
2552
+ });
2553
+
2554
+ it('keeps the UA focus ring off the primary the wrapper rings for', () => {
2555
+ render(
2556
+ <SideNavItem
2557
+ label="Project"
2558
+ href="/project"
2559
+ actions={
2560
+ <button type="button" data-testid="row-action">
2561
+ More
2562
+ </button>
2563
+ }
2564
+ />,
2565
+ );
2566
+
2567
+ const link = screen.getByRole('link', {name: 'Project'});
2568
+ // Dropping the shared ring off the primary also drops the
2569
+ // `outline-style: none` default that came with it, and a bare <a> keeps
2570
+ // the browser's own focus ring — which would paint inside the wrapper's.
2571
+ expect(suppressesOwnOutline(link)).toBe(true);
2572
+ });
2573
+
2574
+ it('paints the actions-row ring for keyboard focus only', () => {
2575
+ render(
2576
+ <SideNavItem
2577
+ label="Project"
2578
+ href="/project"
2579
+ actions={
2580
+ <button type="button" data-testid="row-action">
2581
+ More
2582
+ </button>
2583
+ }
2584
+ />,
2585
+ );
2586
+
2587
+ const link = screen.getByRole('link', {name: 'Project'});
2588
+ const row = link.parentElement!;
2589
+ // The wrapper is not a tab stop, so its ring has to be guarded by a
2590
+ // relational selector. `:focus-within` or `:focus` would light the row
2591
+ // for mouse users too; only `:focus-visible` keeps it to the keyboard.
2592
+ const selectors = ringSelectorsFor(row);
2593
+ expect(selectors.length).toBeGreaterThan(0);
2594
+ for (const selector of selectors) {
2595
+ expect(selector).toContain(':focus-visible');
2596
+ }
2597
+
2598
+ // The guard is scoped to the wrapper's first child. Reordering the row
2599
+ // would hand the ring to the toggle or the actions instead.
2600
+ expect(row.firstElementChild).toBe(link);
2601
+ });
2602
+
1973
2603
  it('draws the shared ring on a heading rendered as one link', () => {
1974
2604
  render(<SideNavHeading heading="My App" headingHref="/" />);
1975
2605
  expectSharedFocusRing(screen.getByRole('link', {name: /My App/}));
@@ -2165,6 +2795,76 @@ describe('SideNav footer icon row sizing', () => {
2165
2795
  });
2166
2796
  });
2167
2797
 
2798
+ describe('SideNav row control sizing', () => {
2799
+ /**
2800
+ * The height an icon-only `Button` of this size declares — the box every
2801
+ * control sharing a nav row is measured against.
2802
+ */
2803
+ function iconButtonHeight(size: 'sm' | 'md' | 'lg'): string[] {
2804
+ const {unmount} = render(
2805
+ <Button
2806
+ label="sized reference"
2807
+ size={size}
2808
+ isIconOnly
2809
+ icon={<span />}
2810
+ data-testid="sized-ref"
2811
+ />,
2812
+ );
2813
+ const height = declaredOn(screen.getByTestId('sized-ref'), 'height');
2814
+ unmount();
2815
+ // Guard against a vacuous pass: nothing below means anything if the
2816
+ // reference itself declares no height.
2817
+ expect(height).toHaveLength(1);
2818
+ return height;
2819
+ }
2820
+
2821
+ function collapsibleRow(actions: ReactNode) {
2822
+ return (
2823
+ <SideNavItem
2824
+ label="Project"
2825
+ href="/project"
2826
+ collapsible
2827
+ actions={actions}>
2828
+ <SideNavItem label="Session" href="/project/session" />
2829
+ </SideNavItem>
2830
+ );
2831
+ }
2832
+
2833
+ const iconAction = (props?: {size?: 'sm' | 'md' | 'lg'}) => (
2834
+ <Button
2835
+ label="Project actions"
2836
+ isIconOnly
2837
+ icon={<span />}
2838
+ data-testid="row-action"
2839
+ {...props}
2840
+ />
2841
+ );
2842
+
2843
+ it('sizes the expand toggle like the icon buttons it sits beside', () => {
2844
+ const sm = iconButtonHeight('sm');
2845
+ render(collapsibleRow(iconAction()));
2846
+ const toggle = screen.getByRole('button', {name: /collapse project/i});
2847
+ expect(declaredOn(toggle, 'height')).toEqual(sm);
2848
+ // Square, so its hover pill reads as the same box as the action's.
2849
+ expect(declaredOn(toggle, 'width')).toEqual(sm);
2850
+ });
2851
+
2852
+ it('cascades that size to an action the consumer did not size', () => {
2853
+ const sm = iconButtonHeight('sm');
2854
+ // Without the cascade an unsized Button falls back to `md`; if the two
2855
+ // compiled to one height the assertion below could not fail.
2856
+ expect(sm).not.toEqual(iconButtonHeight('md'));
2857
+ render(collapsibleRow(iconAction()));
2858
+ expect(declaredOn(screen.getByTestId('row-action'), 'height')).toEqual(sm);
2859
+ });
2860
+
2861
+ it('lets an explicit size on an action win over the cascade', () => {
2862
+ const lg = iconButtonHeight('lg');
2863
+ render(collapsibleRow(iconAction({size: 'lg'})));
2864
+ expect(declaredOn(screen.getByTestId('row-action'), 'height')).toEqual(lg);
2865
+ });
2866
+ });
2867
+
2168
2868
  describe('SideNavHeading hover/click guard', () => {
2169
2869
  // tabIndex={-1} matches real menu items; a bare role=menuitem div is not
2170
2870
  // focusable.
@@ -2249,3 +2949,366 @@ describe('SideNavHeading hover/click guard', () => {
2249
2949
  expect(trigger).toHaveFocus();
2250
2950
  });
2251
2951
  });
2952
+
2953
+ // =============================================================================
2954
+ // Collapse ownership — collapsible + resizable (#4790, #5073)
2955
+ // =============================================================================
2956
+
2957
+ const AUTO_SAVE_ID = 'sidenav-collapse-owner';
2958
+ const STORAGE_KEY = `astryx-resizable:${AUTO_SAVE_ID}`;
2959
+
2960
+ function expectCollapsed(isCollapsed: boolean) {
2961
+ const label = isCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
2962
+ expect(screen.getByRole('button', {name: label})).toBeInTheDocument();
2963
+ }
2964
+
2965
+ describe('SideNav collapse ownership', () => {
2966
+ beforeEach(() => {
2967
+ localStorage.clear();
2968
+ });
2969
+
2970
+ // Every row is a configuration that works on origin/main today, with the
2971
+ // collapse state main resolves for it. Normalizing the two props into one
2972
+ // owner must not move any of these — the one case whose outcome does change
2973
+ // is the divergence itself, pinned separately below.
2974
+ const preservedCases: {
2975
+ name: string;
2976
+ persisted?: string;
2977
+ element: React.ReactElement;
2978
+ isCollapsed: boolean;
2979
+ }[] = [
2980
+ {
2981
+ name: 'collapsible alone',
2982
+ element: <SideNav collapsible>Content</SideNav>,
2983
+ isCollapsed: false,
2984
+ },
2985
+ {
2986
+ name: 'collapsible with defaultIsCollapsed',
2987
+ element: (
2988
+ <SideNav collapsible={{defaultIsCollapsed: true}}>Content</SideNav>
2989
+ ),
2990
+ isCollapsed: true,
2991
+ },
2992
+ {
2993
+ name: 'controlled collapsible (collapsed)',
2994
+ element: (
2995
+ <SideNav collapsible={{isCollapsed: true, onCollapsedChange: () => {}}}>
2996
+ Content
2997
+ </SideNav>
2998
+ ),
2999
+ isCollapsed: true,
3000
+ },
3001
+ {
3002
+ name: 'controlled collapsible (expanded)',
3003
+ element: (
3004
+ <SideNav
3005
+ collapsible={{isCollapsed: false, onCollapsedChange: () => {}}}>
3006
+ Content
3007
+ </SideNav>
3008
+ ),
3009
+ isCollapsed: false,
3010
+ },
3011
+ {
3012
+ name: 'collapsible + resizable',
3013
+ element: (
3014
+ <SideNav collapsible resizable>
3015
+ Content
3016
+ </SideNav>
3017
+ ),
3018
+ isCollapsed: false,
3019
+ },
3020
+ {
3021
+ name: 'defaultIsCollapsed + resizable',
3022
+ element: (
3023
+ <SideNav collapsible={{defaultIsCollapsed: true}} resizable>
3024
+ Content
3025
+ </SideNav>
3026
+ ),
3027
+ isCollapsed: true,
3028
+ },
3029
+ {
3030
+ name: 'controlled collapsible + resizable',
3031
+ element: (
3032
+ <SideNav
3033
+ collapsible={{isCollapsed: true, onCollapsedChange: () => {}}}
3034
+ resizable>
3035
+ Content
3036
+ </SideNav>
3037
+ ),
3038
+ isCollapsed: true,
3039
+ },
3040
+ {
3041
+ name: 'defaultIsCollapsed + a persisted legacy width',
3042
+ persisted: '300',
3043
+ element: (
3044
+ <SideNav
3045
+ collapsible={{defaultIsCollapsed: true}}
3046
+ resizable={{autoSaveId: AUTO_SAVE_ID}}>
3047
+ Content
3048
+ </SideNav>
3049
+ ),
3050
+ isCollapsed: true,
3051
+ },
3052
+ {
3053
+ name: 'collapsible + a persisted legacy width',
3054
+ persisted: '300',
3055
+ element: (
3056
+ <SideNav collapsible resizable={{autoSaveId: AUTO_SAVE_ID}}>
3057
+ Content
3058
+ </SideNav>
3059
+ ),
3060
+ isCollapsed: false,
3061
+ },
3062
+ ];
3063
+
3064
+ it.each(preservedCases)(
3065
+ 'resolves $name the way main does',
3066
+ ({persisted, element, isCollapsed}) => {
3067
+ if (persisted != null) {
3068
+ localStorage.setItem(STORAGE_KEY, persisted);
3069
+ }
3070
+ render(element);
3071
+ expectCollapsed(isCollapsed);
3072
+ },
3073
+ );
3074
+
3075
+ it('keeps resizable alone free of collapse', () => {
3076
+ render(<SideNav resizable>Content</SideNav>);
3077
+ expect(
3078
+ screen.queryByRole('button', {name: /sidebar/i}),
3079
+ ).not.toBeInTheDocument();
3080
+ expect(screen.getByRole('navigation').style.width).toBe('260px');
3081
+ });
3082
+
3083
+ it('restores the collapsed rail from a legacy persisted 0 (#4790)', () => {
3084
+ // main renders the expanded layout here while the resize hook restores
3085
+ // collapsed, so the nav paints at width 0 with no way back.
3086
+ localStorage.setItem(STORAGE_KEY, '0');
3087
+ render(
3088
+ <SideNav collapsible resizable={{autoSaveId: AUTO_SAVE_ID}}>
3089
+ Content
3090
+ </SideNav>,
3091
+ );
3092
+
3093
+ expectCollapsed(true);
3094
+ expect(screen.getByRole('navigation').style.width).not.toBe('0px');
3095
+ });
3096
+
3097
+ it('restores the collapsed rail and the pre-collapse width across a reload', async () => {
3098
+ const user = userEvent.setup();
3099
+ const nav = (
3100
+ <SideNav
3101
+ collapsible
3102
+ resizable={{autoSaveId: AUTO_SAVE_ID, defaultWidth: 260}}>
3103
+ Content
3104
+ </SideNav>
3105
+ );
3106
+
3107
+ const first = render(nav);
3108
+ await user.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
3109
+ expectCollapsed(true);
3110
+ first.unmount();
3111
+
3112
+ render(nav);
3113
+ expectCollapsed(true);
3114
+
3115
+ await user.click(screen.getByRole('button', {name: 'Expand sidebar'}));
3116
+ expect(screen.getByRole('navigation').style.width).toBe('260px');
3117
+ });
3118
+
3119
+ it('expands to the persisted pre-collapse width, not the default', async () => {
3120
+ const user = userEvent.setup();
3121
+ localStorage.setItem(
3122
+ STORAGE_KEY,
3123
+ JSON.stringify({size: 320, isCollapsed: true}),
3124
+ );
3125
+ render(
3126
+ <SideNav
3127
+ collapsible
3128
+ resizable={{autoSaveId: AUTO_SAVE_ID, defaultWidth: 260}}>
3129
+ Content
3130
+ </SideNav>,
3131
+ );
3132
+
3133
+ await user.click(screen.getByRole('button', {name: 'Expand sidebar'}));
3134
+ expect(screen.getByRole('navigation').style.width).toBe('320px');
3135
+ });
3136
+
3137
+ it('reports one collapse change per toggle', async () => {
3138
+ const user = userEvent.setup();
3139
+ const onCollapsedChange = vi.fn();
3140
+ render(
3141
+ <SideNav collapsible={{onCollapsedChange}} resizable>
3142
+ Content
3143
+ </SideNav>,
3144
+ );
3145
+
3146
+ await user.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
3147
+ expect(onCollapsedChange).toHaveBeenCalledExactlyOnceWith(true);
3148
+ });
3149
+
3150
+ it('lets a controlled collapsible drive a resizable nav from outside', async () => {
3151
+ const user = userEvent.setup();
3152
+
3153
+ function Harness() {
3154
+ const [isCollapsed, setIsCollapsed] = useState(false);
3155
+ const collapsible = {isCollapsed, onCollapsedChange: setIsCollapsed};
3156
+ return (
3157
+ <>
3158
+ <SideNavCollapseButton collapsible={collapsible} label="Outside" />
3159
+ <SideNav
3160
+ collapsible={{...collapsible, hasButton: false}}
3161
+ resizable={{autoSaveId: AUTO_SAVE_ID}}>
3162
+ Content
3163
+ </SideNav>
3164
+ </>
3165
+ );
3166
+ }
3167
+
3168
+ render(<Harness />);
3169
+ expect(screen.getByRole('navigation').style.width).toBe('260px');
3170
+
3171
+ await user.click(screen.getByRole('button', {name: 'Outside'}));
3172
+ expect(screen.getByRole('navigation').style.width).toBe('');
3173
+ expect(
3174
+ screen.queryByTestId('astryx-sidenav-resize-handle'),
3175
+ ).not.toBeInTheDocument();
3176
+ });
3177
+
3178
+ it('lets resizable own collapse on its own', async () => {
3179
+ const user = userEvent.setup();
3180
+ const onCollapseChange = vi.fn();
3181
+ render(
3182
+ <SideNav resizable={{defaultIsCollapsed: true, onCollapseChange}}>
3183
+ Content
3184
+ </SideNav>,
3185
+ );
3186
+
3187
+ expectCollapsed(true);
3188
+ await user.click(screen.getByRole('button', {name: 'Expand sidebar'}));
3189
+ expect(onCollapseChange).toHaveBeenCalledExactlyOnceWith(false);
3190
+ });
3191
+
3192
+ it('keeps collapse state when resize is toggled off and on', async () => {
3193
+ const user = userEvent.setup();
3194
+ const {rerender} = render(
3195
+ <SideNav collapsible resizable>
3196
+ Content
3197
+ </SideNav>,
3198
+ );
3199
+
3200
+ await user.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
3201
+ expectCollapsed(true);
3202
+
3203
+ rerender(<SideNav collapsible>Content</SideNav>);
3204
+ expectCollapsed(true);
3205
+
3206
+ rerender(
3207
+ <SideNav collapsible resizable>
3208
+ Content
3209
+ </SideNav>,
3210
+ );
3211
+ expectCollapsed(true);
3212
+ });
3213
+
3214
+ it('gives resizable the winning value when both set defaultIsCollapsed', () => {
3215
+ render(
3216
+ <SideNav
3217
+ collapsible={{defaultIsCollapsed: false}}
3218
+ resizable={{defaultIsCollapsed: true}}>
3219
+ Content
3220
+ </SideNav>,
3221
+ );
3222
+ expectCollapsed(true);
3223
+ });
3224
+ });
3225
+
3226
+ describe('SideNav collapse conflict warning', () => {
3227
+ let warn: ReturnType<typeof vi.spyOn>;
3228
+
3229
+ beforeEach(() => {
3230
+ localStorage.clear();
3231
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
3232
+ });
3233
+
3234
+ afterEach(() => {
3235
+ warn.mockRestore();
3236
+ });
3237
+
3238
+ it('warns when both props carry defaultIsCollapsed', () => {
3239
+ render(
3240
+ <SideNav
3241
+ collapsible={{defaultIsCollapsed: false}}
3242
+ resizable={{defaultIsCollapsed: true}}>
3243
+ Content
3244
+ </SideNav>,
3245
+ );
3246
+
3247
+ expect(warn).toHaveBeenCalledExactlyOnceWith(
3248
+ expect.stringContaining('collapsible.defaultIsCollapsed'),
3249
+ );
3250
+ expect(warn.mock.calls[0][0]).toContain('resizable.defaultIsCollapsed');
3251
+ expect(warn.mock.calls[0][0]).toContain('resizable wins');
3252
+ });
3253
+
3254
+ it('warns when a controlled collapsible does not drive collapse', () => {
3255
+ render(
3256
+ <SideNav
3257
+ collapsible={{isCollapsed: false, onCollapsedChange: () => {}}}
3258
+ resizable={{isCollapsed: true, onCollapseChange: () => {}}}>
3259
+ Content
3260
+ </SideNav>,
3261
+ );
3262
+
3263
+ const message = String(warn.mock.calls[0][0]);
3264
+ expect(message).toContain('collapsible.isCollapsed');
3265
+ expect(message).toContain('collapsible.onCollapsedChange');
3266
+ expect(message).toContain('resizable.isCollapsed');
3267
+ expect(message).toContain('resizable wins');
3268
+ // The winner is the state that actually renders.
3269
+ expectCollapsed(true);
3270
+ });
3271
+
3272
+ it('does not split state and callback ownership across props', async () => {
3273
+ const user = userEvent.setup();
3274
+ const collapsibleChange = vi.fn();
3275
+ const resizableChange = vi.fn();
3276
+ render(
3277
+ <SideNav
3278
+ collapsible={{
3279
+ isCollapsed: true,
3280
+ onCollapsedChange: collapsibleChange,
3281
+ }}
3282
+ resizable={{onCollapseChange: resizableChange}}>
3283
+ Content
3284
+ </SideNav>,
3285
+ );
3286
+
3287
+ expectCollapsed(false);
3288
+ await user.click(screen.getByRole('button', {name: 'Collapse sidebar'}));
3289
+ expect(resizableChange).toHaveBeenCalledExactlyOnceWith(true);
3290
+ expect(collapsibleChange).not.toHaveBeenCalled();
3291
+ });
3292
+
3293
+ it('stays silent for collapsible alongside a resize config', () => {
3294
+ render(
3295
+ <SideNav
3296
+ collapsible
3297
+ resizable={{autoSaveId: AUTO_SAVE_ID, defaultWidth: 300}}>
3298
+ Content
3299
+ </SideNav>,
3300
+ );
3301
+ expect(warn).not.toHaveBeenCalled();
3302
+ });
3303
+
3304
+ it('stays silent for a controlled collapsible with a plain resize config', () => {
3305
+ render(
3306
+ <SideNav
3307
+ collapsible={{isCollapsed: true, onCollapsedChange: () => {}}}
3308
+ resizable={{defaultWidth: 300}}>
3309
+ Content
3310
+ </SideNav>,
3311
+ );
3312
+ expect(warn).not.toHaveBeenCalled();
3313
+ });
3314
+ });