@astryxdesign/core 0.7.1 → 0.7.2-canary.020e1fe

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 (377) hide show
  1. package/dist/AppShell/AppShell.d.ts.map +1 -1
  2. package/dist/AppShell/AppShell.js +40 -1
  3. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  4. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  5. package/dist/AspectRatio/AspectRatio.js +44 -3
  6. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  7. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  8. package/dist/Button/Button.d.ts.map +1 -1
  9. package/dist/Button/Button.js +38 -14
  10. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  11. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  12. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  13. package/dist/CheckboxList/CheckboxListItem.d.ts +9 -1
  14. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  15. package/dist/CheckboxList/CheckboxListItem.js +4 -2
  16. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  17. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  18. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  19. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  20. package/dist/ContextMenu/ContextMenu.js +5 -2
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +20 -8
  23. package/dist/Drawer/Drawer.d.ts +191 -0
  24. package/dist/Drawer/Drawer.d.ts.map +1 -0
  25. package/dist/Drawer/Drawer.js +382 -0
  26. package/dist/Drawer/DrawerHeader.d.ts +85 -0
  27. package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
  28. package/dist/Drawer/DrawerHeader.js +134 -0
  29. package/dist/Drawer/index.d.ts +11 -0
  30. package/dist/Drawer/index.d.ts.map +1 -0
  31. package/dist/Drawer/index.js +11 -0
  32. package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
  33. package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
  34. package/dist/Drawer/useDrawerDialogPresence.js +236 -0
  35. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -1
  36. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  37. package/dist/DropdownMenu/DropdownMenu.js +83 -11
  38. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  39. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  40. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  41. package/dist/DropdownMenu/DropdownMenuDivider.d.ts +5 -3
  42. package/dist/DropdownMenu/DropdownMenuDivider.d.ts.map +1 -1
  43. package/dist/DropdownMenu/DropdownMenuDivider.js +7 -3
  44. package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
  45. package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
  46. package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
  47. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  48. package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -8
  49. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +24 -0
  50. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -0
  51. package/dist/DropdownMenu/lazyMenuBottomSheet.js +46 -0
  52. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  53. package/dist/EmptyState/EmptyState.js +4 -10
  54. package/dist/Field/Field.d.ts +1 -1
  55. package/dist/Field/Field.d.ts.map +1 -1
  56. package/dist/Field/Field.js +8 -4
  57. package/dist/Grid/GridSpan.d.ts.map +1 -1
  58. package/dist/Grid/GridSpan.js +1 -0
  59. package/dist/Heading/Heading.d.ts.map +1 -1
  60. package/dist/Heading/Heading.js +4 -2
  61. package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
  62. package/dist/HoverCard/useHoverCard.js +10 -2
  63. package/dist/Item/Item.d.ts +6 -2
  64. package/dist/Item/Item.d.ts.map +1 -1
  65. package/dist/Item/Item.js +19 -4
  66. package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
  67. package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
  68. package/dist/Layer/layerViewportInset.stylex.js +6 -2
  69. package/dist/Layer/useLayer.d.ts.map +1 -1
  70. package/dist/Layer/useLayer.js +13 -7
  71. package/dist/Layer/useLayerDismissal.d.ts +1 -1
  72. package/dist/Layer/useLayerDismissal.js +1 -1
  73. package/dist/Layout/container.stylex.d.ts +5 -5
  74. package/dist/Layout/container.stylex.js +4 -4
  75. package/dist/List/List.d.ts +46 -19
  76. package/dist/List/List.d.ts.map +1 -1
  77. package/dist/List/List.js +28 -10
  78. package/dist/List/ListContext.d.ts +8 -3
  79. package/dist/List/ListContext.d.ts.map +1 -1
  80. package/dist/List/ListContext.js +7 -2
  81. package/dist/List/ListItem.d.ts +13 -5
  82. package/dist/List/ListItem.d.ts.map +1 -1
  83. package/dist/List/ListItem.js +21 -8
  84. package/dist/List/index.d.ts +1 -1
  85. package/dist/List/index.d.ts.map +1 -1
  86. package/dist/Markdown/Markdown.d.ts.map +1 -1
  87. package/dist/Markdown/Markdown.js +8 -1
  88. package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
  89. package/dist/Markdown/plugins/frontmatter.js +7 -3
  90. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  91. package/dist/MetadataList/MetadataList.js +5 -1
  92. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  93. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  94. package/dist/MobileNav/MobileNav.d.ts +9 -0
  95. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  96. package/dist/MobileNav/MobileNav.js +30 -3
  97. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  98. package/dist/MultiSelector/MultiSelector.js +22 -8
  99. package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
  100. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  101. package/dist/MultiSelector/hooks.js +3 -1
  102. package/dist/Pagination/Pagination.d.ts +8 -5
  103. package/dist/Pagination/Pagination.d.ts.map +1 -1
  104. package/dist/Pagination/Pagination.js +21 -8
  105. package/dist/Popover/Popover.js +2 -2
  106. package/dist/PowerSearch/PowerSearch.js +1 -1
  107. package/dist/RadioList/RadioList.d.ts +1 -1
  108. package/dist/RadioList/RadioList.d.ts.map +1 -1
  109. package/dist/RadioList/RadioList.js +6 -2
  110. package/dist/RadioList/RadioListItem.d.ts +9 -1
  111. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  112. package/dist/RadioList/RadioListItem.js +2 -0
  113. package/dist/Selector/hooks.d.ts.map +1 -1
  114. package/dist/Selector/hooks.js +6 -2
  115. package/dist/SideNav/SideNavItem.d.ts +8 -1
  116. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  117. package/dist/SideNav/SideNavItem.js +71 -9
  118. package/dist/Switch/Switch.d.ts +1 -1
  119. package/dist/Switch/Switch.d.ts.map +1 -1
  120. package/dist/Switch/Switch.js +13 -1
  121. package/dist/TabList/TabMenu.d.ts +2 -2
  122. package/dist/TabList/TabMenu.d.ts.map +1 -1
  123. package/dist/TabList/TabMenu.js +6 -3
  124. package/dist/Text/Text.d.ts.map +1 -1
  125. package/dist/Text/Text.js +4 -2
  126. package/dist/Text/useTruncation.d.ts +7 -5
  127. package/dist/Text/useTruncation.d.ts.map +1 -1
  128. package/dist/Text/useTruncation.js +16 -5
  129. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  130. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  131. package/dist/ToggleButton/ToggleButton.js +12 -1
  132. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  133. package/dist/Tooltip/useTooltip.js +11 -0
  134. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  135. package/dist/TopNav/TopNavMegaMenu.js +3 -3
  136. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  137. package/dist/TopNav/TopNavMenu.js +3 -3
  138. package/dist/TreeList/TreeList.d.ts.map +1 -1
  139. package/dist/TreeList/TreeList.js +1 -0
  140. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  141. package/dist/TreeList/TreeListBranches.js +2 -2
  142. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  143. package/dist/TreeList/TreeListItem.js +57 -9
  144. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  145. package/dist/Typeahead/BaseTypeahead.js +26 -0
  146. package/dist/astryx.css +47 -13
  147. package/dist/hooks/useEntryAnimation.d.ts +2 -1
  148. package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
  149. package/dist/hooks/useEntryAnimation.js +14 -12
  150. package/dist/hooks/useIsHydrating.d.ts +6 -0
  151. package/dist/hooks/useIsHydrating.d.ts.map +1 -0
  152. package/dist/hooks/useIsHydrating.js +30 -0
  153. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  154. package/dist/hooks/useMenuHover.js +18 -13
  155. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  156. package/dist/i18n/generated-locales/en.generated.js +112 -1
  157. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  158. package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
  159. package/dist/index.d.ts +1 -0
  160. package/dist/index.d.ts.map +1 -1
  161. package/dist/index.js +1 -0
  162. package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
  163. package/dist/theme/derivedVarRegistry.js +0 -3
  164. package/dist/utils/focusOutline.stylex.d.ts +4 -0
  165. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  166. package/dist/utils/focusOutline.stylex.js +4 -0
  167. package/dist/utils/plainDate.d.ts.map +1 -1
  168. package/dist/utils/plainDate.js +15 -9
  169. package/dist/utils/sharedMutationObserver.d.ts +21 -0
  170. package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
  171. package/dist/utils/sharedMutationObserver.js +69 -0
  172. package/locales/en.json +444 -0
  173. package/locales/pseudo.json +333 -0
  174. package/package.json +9 -4
  175. package/scripts/agent-doc-state.mjs +38 -0
  176. package/scripts/postinstall.mjs +1 -1
  177. package/src/AppShell/AppShell.tsx +45 -1
  178. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  179. package/src/AspectRatio/AspectRatio.tsx +38 -18
  180. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  181. package/src/Avatar/Avatar.doc.mjs +5 -3
  182. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  183. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  184. package/src/Breadcrumbs/Breadcrumbs.test.tsx +30 -1
  185. package/src/Button/Button.doc.mjs +2 -2
  186. package/src/Button/Button.test.tsx +155 -9
  187. package/src/Button/Button.tsx +39 -16
  188. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  189. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  190. package/src/Card/Card.doc.mjs +1 -1
  191. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  192. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  193. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  194. package/src/CheckboxList/CheckboxList.test.tsx +22 -0
  195. package/src/CheckboxList/CheckboxListItem.doc.mjs +16 -0
  196. package/src/CheckboxList/CheckboxListItem.tsx +10 -0
  197. package/src/Code/Code.doc.mjs +1 -0
  198. package/src/CommandPalette/CommandPalette.doc.mjs +2 -0
  199. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  200. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  201. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  202. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +292 -0
  203. package/src/ContextMenu/ContextMenu.doc.mjs +2 -3
  204. package/src/ContextMenu/ContextMenu.tsx +4 -1
  205. package/src/ContextMenu/__tests__/ContextMenuAudit.a11y.chromium.spec.ts +438 -0
  206. package/src/Dialog/Dialog.test.tsx +46 -0
  207. package/src/Dialog/Dialog.tsx +20 -7
  208. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  209. package/src/Drawer/Drawer.doc.mjs +473 -0
  210. package/src/Drawer/Drawer.spec.md +357 -0
  211. package/src/Drawer/Drawer.test.tsx +1300 -0
  212. package/src/Drawer/Drawer.tsx +613 -0
  213. package/src/Drawer/DrawerDirection.test.tsx +127 -0
  214. package/src/Drawer/DrawerHeader.doc.mjs +293 -0
  215. package/src/Drawer/DrawerHeader.test.tsx +102 -0
  216. package/src/Drawer/DrawerHeader.tsx +235 -0
  217. package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
  218. package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
  219. package/src/Drawer/index.ts +13 -0
  220. package/src/Drawer/useDrawerDialogPresence.ts +284 -0
  221. package/src/DropdownMenu/DropdownMenu.ssr.test.tsx +57 -0
  222. package/src/DropdownMenu/DropdownMenu.test.tsx +79 -18
  223. package/src/DropdownMenu/DropdownMenu.tsx +83 -12
  224. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  225. package/src/DropdownMenu/DropdownMenuDivider.doc.mjs +2 -2
  226. package/src/DropdownMenu/DropdownMenuDivider.spec.md +192 -0
  227. package/src/DropdownMenu/DropdownMenuDivider.tsx +9 -5
  228. package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
  229. package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
  230. package/src/DropdownMenu/DropdownMenuLazySheet.test.tsx +126 -0
  231. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  232. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +19 -5
  233. package/src/DropdownMenu/__tests__/DropdownMenuDividerAudit.a11y.chromium.spec.ts +280 -0
  234. package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
  235. package/src/DropdownMenu/__tests__/Menu.a11y.chromium.spec.ts +145 -0
  236. package/src/DropdownMenu/__tests__/Menu.a11y.known-failures.ts +26 -0
  237. package/src/DropdownMenu/__tests__/Menu.a11y.renders.tsx +65 -0
  238. package/src/DropdownMenu/__tests__/Menu.a11y.states.ts +138 -0
  239. package/src/DropdownMenu/__tests__/Menu.a11y.test.tsx +136 -0
  240. package/src/DropdownMenu/__tests__/warmMenuBottomSheet.tsx +43 -0
  241. package/src/DropdownMenu/lazyMenuBottomSheet.ts +71 -0
  242. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  243. package/src/EmptyState/EmptyState.spec.md +189 -0
  244. package/src/EmptyState/EmptyState.test.tsx +74 -3
  245. package/src/EmptyState/EmptyState.tsx +4 -8
  246. package/src/Field/Field.doc.mjs +14 -8
  247. package/src/Field/Field.spec.md +217 -0
  248. package/src/Field/Field.test.tsx +60 -2
  249. package/src/Field/Field.tsx +17 -8
  250. package/src/Field/FieldLabel.doc.mjs +133 -26
  251. package/src/Field/FieldLabel.spec.md +172 -0
  252. package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
  253. package/src/Grid/GridSpan.tsx +3 -0
  254. package/src/Heading/Heading.test.tsx +57 -0
  255. package/src/Heading/Heading.tsx +6 -2
  256. package/src/HoverCard/HoverCard.test.tsx +58 -0
  257. package/src/HoverCard/useHoverCard.tsx +10 -2
  258. package/src/Icon/Icon.doc.mjs +36 -0
  259. package/src/IconButton/IconButton.doc.mjs +2 -2
  260. package/src/IconButton/IconButton.test.tsx +3 -1
  261. package/src/Item/Item.doc.mjs +12 -8
  262. package/src/Item/Item.spec.md +226 -0
  263. package/src/Item/Item.test.tsx +61 -0
  264. package/src/Item/Item.tsx +40 -4
  265. package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
  266. package/src/Layer/layerViewportInset.stylex.ts +6 -2
  267. package/src/Layer/layerViewportInset.test.ts +1 -0
  268. package/src/Layer/useLayer.tsx +24 -13
  269. package/src/Layer/useLayerDismissal.ts +1 -1
  270. package/src/Layout/LayoutHeader.doc.mjs +5 -0
  271. package/src/Layout/container.stylex.ts +6 -6
  272. package/src/List/List.doc.mjs +14 -1
  273. package/src/List/List.spec.md +129 -77
  274. package/src/List/List.test.tsx +163 -94
  275. package/src/List/List.tsx +74 -18
  276. package/src/List/ListContext.tsx +9 -3
  277. package/src/List/ListItem.doc.mjs +24 -5
  278. package/src/List/ListItem.tsx +56 -27
  279. package/src/List/index.ts +1 -1
  280. package/src/Markdown/Markdown.tsx +14 -1
  281. package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
  282. package/src/Markdown/plugins/frontmatter.ts +7 -3
  283. package/src/MetadataList/MetadataList.doc.mjs +3 -3
  284. package/src/MetadataList/MetadataList.test.tsx +38 -0
  285. package/src/MetadataList/MetadataList.tsx +8 -1
  286. package/src/MetadataList/MetadataListContext.tsx +1 -1
  287. package/src/MobileNav/MobileNav.tsx +30 -3
  288. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +173 -15
  289. package/src/MoreMenu/MoreMenu.test.tsx +5 -1
  290. package/src/MultiSelector/MultiSelector.test.tsx +159 -0
  291. package/src/MultiSelector/MultiSelector.tsx +40 -7
  292. package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
  293. package/src/MultiSelector/hooks.ts +3 -1
  294. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  295. package/src/Pagination/Pagination.doc.mjs +7 -7
  296. package/src/Pagination/Pagination.spec.md +9 -9
  297. package/src/Pagination/Pagination.test.tsx +242 -0
  298. package/src/Pagination/Pagination.tsx +27 -11
  299. package/src/Popover/Popover.test.tsx +2 -2
  300. package/src/Popover/Popover.tsx +4 -4
  301. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  302. package/src/PowerSearch/PowerSearch.tsx +2 -2
  303. package/src/ProgressBar/ProgressBar.doc.mjs +1 -0
  304. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  305. package/src/RadioList/RadioList.test.tsx +22 -0
  306. package/src/RadioList/RadioList.tsx +4 -1
  307. package/src/RadioList/RadioListItem.doc.mjs +16 -0
  308. package/src/RadioList/RadioListItem.tsx +10 -1
  309. package/src/Resizable/useResizable.doc.mjs +2 -0
  310. package/src/ScrollableArea/ScrollableArea.doc.mjs +1 -1
  311. package/src/Selector/hooks.ts +6 -2
  312. package/src/Selector/useCombobox.doc.mjs +237 -0
  313. package/src/SideNav/SideNav.spec.md +227 -0
  314. package/src/SideNav/SideNav.test.tsx +179 -0
  315. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  316. package/src/SideNav/SideNavItem.tsx +96 -17
  317. package/src/Slider/Slider.spec.md +39 -18
  318. package/src/Spinner/Spinner.doc.mjs +1 -1
  319. package/src/Switch/Switch.test.tsx +49 -0
  320. package/src/Switch/Switch.tsx +11 -0
  321. package/src/TabList/TabList.spec.md +57 -44
  322. package/src/TabList/TabList.test.tsx +67 -0
  323. package/src/TabList/TabMenu.doc.mjs +4 -3
  324. package/src/TabList/TabMenu.tsx +7 -4
  325. package/src/Text/Text.test.tsx +57 -0
  326. package/src/Text/Text.tsx +6 -2
  327. package/src/Text/useTruncation.test.tsx +69 -0
  328. package/src/Text/useTruncation.ts +16 -5
  329. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  330. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  331. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  332. package/src/ToggleButton/ToggleButton.tsx +12 -1
  333. package/src/ToggleButton/ToggleButtonGroup.doc.mjs +4 -0
  334. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  335. package/src/Toolbar/Toolbar.test.tsx +0 -8
  336. package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
  337. package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
  338. package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
  339. package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
  340. package/src/Tooltip/Tooltip.test.tsx +31 -0
  341. package/src/Tooltip/useTooltip.test.tsx +13 -0
  342. package/src/Tooltip/useTooltip.tsx +11 -0
  343. package/src/TopNav/TopNavMegaMenu.test.tsx +22 -0
  344. package/src/TopNav/TopNavMegaMenu.tsx +7 -3
  345. package/src/TopNav/TopNavMenu.test.tsx +19 -0
  346. package/src/TopNav/TopNavMenu.tsx +11 -3
  347. package/src/TreeList/TreeList.doc.mjs +1 -0
  348. package/src/TreeList/TreeList.spec.md +29 -17
  349. package/src/TreeList/TreeList.test.tsx +327 -6
  350. package/src/TreeList/TreeList.tsx +5 -1
  351. package/src/TreeList/TreeListBranches.tsx +6 -0
  352. package/src/TreeList/TreeListItem.tsx +88 -13
  353. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  354. package/src/Typeahead/Typeahead.test.tsx +260 -2
  355. package/src/docPropLiterals.test.ts +36 -10
  356. package/src/hooks/useEntryAnimation.doc.mjs +4 -4
  357. package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
  358. package/src/hooks/useEntryAnimation.ts +15 -16
  359. package/src/hooks/useIsHydrating.test.tsx +70 -0
  360. package/src/hooks/useIsHydrating.ts +31 -0
  361. package/src/hooks/useMenuHover.ts +18 -13
  362. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  363. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  364. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  365. package/src/i18n/generated-locales/en.generated.ts +121 -0
  366. package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
  367. package/src/i18n/useTranslator.test.tsx +37 -0
  368. package/src/index.ts +1 -0
  369. package/src/postinstall.test.mjs +46 -0
  370. package/src/reset.css +0 -29
  371. package/src/theme/derivedVarRegistry.test.ts +7 -3
  372. package/src/theme/derivedVarRegistry.ts +0 -3
  373. package/src/utils/focusOutline.stylex.ts +4 -0
  374. package/src/utils/plainDate.test.ts +37 -1
  375. package/src/utils/plainDate.ts +15 -5
  376. package/src/utils/sharedMutationObserver.test.ts +53 -0
  377. package/src/utils/sharedMutationObserver.ts +78 -0
@@ -9,7 +9,6 @@
9
9
  * SYNC: When modified, update these files to stay in sync:
10
10
  * - /packages/core/src/AspectRatio/AspectRatio.doc.mjs
11
11
  * - /packages/core/src/AspectRatio/AspectRatio.test.tsx
12
- * - /packages/core/src/reset.css (AspectRatio fit baseline rules)
13
12
  * - /apps/storybook/stories/AspectRatio.stories.tsx
14
13
  * - /packages/cli/assets/templates/blocks/components/AspectRatio/ (showcase blocks)
15
14
  */
@@ -91,13 +90,12 @@ export interface AspectRatioProps extends BaseProps<HTMLDivElement> {
91
90
  * (`object-fit: contain`).
92
91
  * - `center`: child keeps its natural size, centered in the box.
93
92
  *
94
- * `cover`/`contain` child sizing ships as zero-specificity baseline rules
95
- * in `reset.css`, keyed on the `data-astryx-aspect-ratio-override`
96
- * attribute the component sets on the child's direct parent. Any styles
97
- * the child sets itself still win, so children that already size
98
- * themselves keep their behavior. When omitted, the child is left
99
- * unstyled (the pre-existing contract). `fit` is structural, not visual,
100
- * so it is not exposed on the theming surface.
93
+ * `cover`/`contain` size the child from the component's own styles, in
94
+ * the `astryx-base` cascade layer. Styles in a later layer (Tailwind
95
+ * utilities, `astryx-theme`), unlayered CSS, and inline styles on the
96
+ * child still win. When omitted, the child is left unstyled (the
97
+ * pre-existing contract). `fit` is structural, not visual, so it is not
98
+ * exposed on the theming surface.
101
99
  *
102
100
  * @example
103
101
  * ```
@@ -140,15 +138,33 @@ const styles = stylex.create({
140
138
  height: '100%',
141
139
  },
142
140
  // fit="center" centers the child at its natural size from the wrapper —
143
- // no styles on the child itself. The `cover`/`contain` child sizing can't
144
- // live here (StyleX has no descendant selectors); it ships as baseline
145
- // rules in reset.css keyed on the `data-astryx-aspect-ratio-override`
146
- // attribute the wrapper carries (see below).
141
+ // no styles on the child itself.
147
142
  childCenter: {
148
143
  display: 'flex',
149
144
  alignItems: 'center',
150
145
  justifyContent: 'center',
151
146
  },
147
+ // `cover`/`contain` must style the child itself (`object-fit` only applies
148
+ // on the replaced element), and StyleX has no child selector. The
149
+ // `:not([stylex-hack])` pseudo-class always matches, so StyleX accepts the
150
+ // key and emits `.x:not([stylex-hack]) > *`, a direct-child rule in the
151
+ // `astryx-base` layer like every other component style.
152
+ childFill: {
153
+ ':not([stylex-hack]) > *': {
154
+ width: '100%',
155
+ height: '100%',
156
+ },
157
+ },
158
+ childCover: {
159
+ ':not([stylex-hack]) > :is(img, video)': {
160
+ objectFit: 'cover',
161
+ },
162
+ },
163
+ childContain: {
164
+ ':not([stylex-hack]) > :is(img, video)': {
165
+ objectFit: 'contain',
166
+ },
167
+ },
152
168
  });
153
169
 
154
170
  // The ratio compiles to a CSS variable + a class-level declaration
@@ -234,14 +250,18 @@ export function AspectRatio({
234
250
  style,
235
251
  )}
236
252
  {...props}>
237
- {/* The marker attribute carries the fit value so the reset.css child
238
- sizing can use direct-child selectors on this wrapper — the child's
239
- actual parent — without depending on AspectRatio's internal
240
- structure or on the theming surface (fit is structural, not
241
- themeable). The name is namespaced to avoid collisions. */}
253
+ {/* The marker attribute carries the fit value. No Astryx style keys on
254
+ it any more, but earlier releases documented it, so product CSS may
255
+ still target it. */}
242
256
  <div
243
257
  data-astryx-aspect-ratio-override={fit}
244
- {...stylex.props(styles.child, fit === 'center' && styles.childCenter)}>
258
+ {...stylex.props(
259
+ styles.child,
260
+ fit === 'center' && styles.childCenter,
261
+ (fit === 'cover' || fit === 'contain') && styles.childFill,
262
+ fit === 'cover' && styles.childCover,
263
+ fit === 'contain' && styles.childContain,
264
+ )}>
245
265
  {children}
246
266
  </div>
247
267
  </div>
@@ -0,0 +1,157 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file AspectRatioFitLayers.a11y.browser.spec.ts
5
+ * @input The built FitModes story (its rendered markup and compiled CSS) and
6
+ * Tailwind's preflight.css
7
+ * @output Browser evidence that `fit` fills the box on the documented setups
8
+ * @position AspectRatio integration proof. Which rule sizes the child is a
9
+ * cascade-layer question, and only a shipping engine resolves the cascade.
10
+ */
11
+
12
+ import * as fs from 'node:fs';
13
+ import {fileURLToPath} from 'node:url';
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {
16
+ DEFAULT_STORYBOOK_DIR,
17
+ serveStorybook,
18
+ type StaticServer,
19
+ } from '@astryxdesign/a11y-spec/storybook';
20
+
21
+ const PREFLIGHT_CSS = fs.readFileSync(
22
+ fileURLToPath(import.meta.resolve('tailwindcss/preflight.css')),
23
+ 'utf8',
24
+ );
25
+
26
+ // The layer order each setup guide declares, and the layer preflight goes in.
27
+ const TAILWIND_SETUPS = [
28
+ {
29
+ name: 'Tailwind v4',
30
+ order:
31
+ '@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;',
32
+ preflightLayer: 'base',
33
+ },
34
+ {
35
+ name: 'Tailwind v3',
36
+ order: '@layer reset, tw-preflight, astryx-base, astryx-theme;',
37
+ preflightLayer: 'tw-preflight',
38
+ },
39
+ ];
40
+
41
+ // A 100×100 image in a 320px-wide 16:9 box: filling the box makes it 320×180.
42
+ const IMAGE =
43
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'/%3E";
44
+
45
+ type Fit = 'cover' | 'contain';
46
+
47
+ let storybook: StaticServer;
48
+ test.beforeAll(async () => {
49
+ storybook = await serveStorybook(
50
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
51
+ );
52
+ });
53
+ test.afterAll(async () => {
54
+ await storybook?.close();
55
+ });
56
+
57
+ /**
58
+ * The markup AspectRatio renders for `fit`, and every CSS rule the story
59
+ * page loaded, taken from the built story so the classes and rules are the
60
+ * component's real compiled output. The child image gets a fixed id, a
61
+ * local source and any extra class.
62
+ */
63
+ async function captureStory(page: Page, fit: Fit, mediaClass: string) {
64
+ await page.goto(
65
+ `${storybook.origin}/iframe.html?id=core-aspectratio--fit-modes&viewMode=story`,
66
+ );
67
+ const wrapper = page.locator(`[data-astryx-aspect-ratio-override="${fit}"]`);
68
+ await expect(wrapper).toHaveCount(1);
69
+ return wrapper.evaluate(
70
+ (element, {image, extraClass}) => {
71
+ const root = element.parentElement?.cloneNode(true);
72
+ const media =
73
+ root instanceof HTMLElement ? root.querySelector('img') : null;
74
+ if (!(root instanceof HTMLElement) || media == null) {
75
+ throw new Error('FitModes no longer renders an img inside AspectRatio');
76
+ }
77
+ media.id = 'media';
78
+ media.src = image;
79
+ if (extraClass) {
80
+ media.classList.add(extraClass);
81
+ }
82
+ const css = [...document.styleSheets]
83
+ .flatMap(sheet => [...sheet.cssRules].map(rule => rule.cssText))
84
+ .join('\n');
85
+ return {html: root.outerHTML, css};
86
+ },
87
+ {image: IMAGE, extraClass: mediaClass},
88
+ );
89
+ }
90
+
91
+ /** Re-host the captured story with `before` CSS ahead of it and `after` CSS behind it. */
92
+ async function measureMedia(
93
+ page: Page,
94
+ fit: Fit,
95
+ {
96
+ before = [],
97
+ after = [],
98
+ mediaClass = '',
99
+ }: {
100
+ before?: string[];
101
+ after?: string[];
102
+ mediaClass?: string;
103
+ },
104
+ ) {
105
+ const {html, css} = await captureStory(page, fit, mediaClass);
106
+ await page.setContent(
107
+ `<!doctype html><div style="width: 320px">${html}</div>`,
108
+ );
109
+ for (const content of [...before, css, ...after]) {
110
+ await page.addStyleTag({content});
111
+ }
112
+ await page.waitForFunction(
113
+ () => (document.getElementById('media') as HTMLImageElement).complete,
114
+ );
115
+ return page.evaluate(() => {
116
+ const media = document.getElementById('media');
117
+ if (!media) {
118
+ throw new Error('missing media');
119
+ }
120
+ const box = media.getBoundingClientRect();
121
+ return {
122
+ width: box.width,
123
+ height: box.height,
124
+ objectFit: getComputedStyle(media).objectFit,
125
+ };
126
+ });
127
+ }
128
+
129
+ for (const setup of TAILWIND_SETUPS) {
130
+ for (const fit of ['cover', 'contain'] as const) {
131
+ test(`fit="${fit}" fills the box on the ${setup.name} setup`, async ({
132
+ page,
133
+ }) => {
134
+ const media = await measureMedia(page, fit, {
135
+ before: [
136
+ setup.order,
137
+ `@layer ${setup.preflightLayer} { ${PREFLIGHT_CSS} }`,
138
+ ],
139
+ });
140
+ expect(media).toEqual({width: 320, height: 180, objectFit: fit});
141
+ });
142
+ }
143
+ }
144
+
145
+ test('fit fills the box with only Astryx CSS loaded', async ({page}) => {
146
+ const media = await measureMedia(page, 'cover', {});
147
+ expect(media).toEqual({width: 320, height: 180, objectFit: 'cover'});
148
+ });
149
+
150
+ test('a utility class on the child still wins over fit', async ({page}) => {
151
+ const media = await measureMedia(page, 'cover', {
152
+ before: [TAILWIND_SETUPS[0].order, `@layer base { ${PREFLIGHT_CSS} }`],
153
+ after: ['@layer utilities { .h-10 { height: 40px; } }'],
154
+ mediaClass: 'h-10',
155
+ });
156
+ expect(media).toEqual({width: 320, height: 40, objectFit: 'cover'});
157
+ });
@@ -67,8 +67,10 @@ export const docs = {
67
67
  },
68
68
  {
69
69
  name: 'size',
70
- type: "'xsm' | 'sm' | 'md' | 'lg' | 'xl' | number",
71
- description: "Avatar size. Use a named size ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or a numeric pixel value. Avatar shares Icon's abbreviated scale, but its tiers are larger because avatars align with media rather than glyphs. Inside an AvatarGroup the group's size wins and this prop is ignored.",
70
+ type:
71
+ "'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
72
+ description:
73
+ "Avatar size: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. Inside an AvatarGroup, the group's size always overrides this prop, including when the group uses its default size.",
72
74
  default: "'md'",
73
75
  },
74
76
  {
@@ -169,7 +171,7 @@ export const docsDense = {
169
171
  fallbackSrc: 'fallback image when primary fails',
170
172
  name: 'user name for initials and alt text',
171
173
  alt: 'alt text; falls back to name',
172
- size: "avatar size. Named ('xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px) or numeric px. An AvatarGroup's size overrides it.",
174
+ size: "named sizes: 'xsm' 20px, 'sm' 24px, 'md' 36px, 'lg' 48px, 'xl' 128px; numeric px restricted to values listed in the type. AvatarGroup's size always wins, default included.",
173
175
  status:
174
176
  'corner content for status indicators; AvatarStatusDot reports its `label`, composed into the avatar accessible name ("Jane Doe, Online"), at any nesting depth',
175
177
  tooltip:
@@ -48,9 +48,10 @@ export const docs = {
48
48
  },
49
49
  {
50
50
  name: 'size',
51
- type: 'AvatarSize',
51
+ type:
52
+ "'xsm' | 'sm' | 'md' | 'lg' | 'xl' | 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180",
52
53
  description:
53
- "Size applied to all avatars via context. This wins over each child Avatar's own size prop, including when it is left at the default, so set the size here rather than on the children.",
54
+ "Size applied to all avatars: named sizes are 'xsm' (20px), 'sm' (24px), 'md' (36px), 'lg' (48px), and 'xl' (128px); numeric sizes must be one of the pixel values listed in the type. The group's size always overrides each child Avatar's size, including when the group uses its default size.",
54
55
  default: "'md'",
55
56
  },
56
57
  {
@@ -56,6 +56,7 @@ import {layerAnimations} from '../Layer/layerAnimations.stylex';
56
56
  import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
57
57
  import {
58
58
  DropdownMenuContext,
59
+ DropdownMenuRootOpenContext,
59
60
  type DropdownMenuContextValue,
60
61
  type DropdownMenuSize,
61
62
  } from '../DropdownMenu/DropdownMenuContext';
@@ -673,7 +674,9 @@ function BreadcrumbMenuTrigger({
673
674
  stylex.props(menuStyles.menu),
674
675
  )}>
675
676
  <DropdownMenuContext value={contextValue}>
676
- {menuContent}
677
+ <DropdownMenuRootOpenContext value={popover.isOpen}>
678
+ {menuContent}
679
+ </DropdownMenuRootOpenContext>
677
680
  </DropdownMenuContext>
678
681
  </div>,
679
682
  {
@@ -7,12 +7,13 @@ import {Breadcrumbs} from './Breadcrumbs';
7
7
  import {BreadcrumbItem} from './BreadcrumbItem';
8
8
  import {
9
9
  BreadcrumbMenuItem,
10
+ BreadcrumbMenuDivider,
10
11
  BreadcrumbMenuGroup,
11
12
  BreadcrumbMenuCheckboxItem,
12
13
  BreadcrumbMenuRadioGroup,
13
14
  BreadcrumbMenuRadioItem,
14
15
  } from './index';
15
- import type {BreadcrumbMenuOption} from './index';
16
+ import type {BreadcrumbMenuDividerProps, BreadcrumbMenuOption} from './index';
16
17
  import {LinkProvider} from '../Link/LinkProvider';
17
18
 
18
19
  function CustomLink({
@@ -771,6 +772,34 @@ describe('BreadcrumbItem menu', () => {
771
772
  expect(separators).toHaveLength(3);
772
773
  });
773
774
 
775
+ it('renders the separator and props types through the BreadcrumbMenuDivider aliases', async () => {
776
+ const user = userEvent.setup();
777
+ const dividerProps: BreadcrumbMenuDividerProps = {
778
+ 'data-testid': 'breadcrumb-menu-divider',
779
+ };
780
+ render(
781
+ <Breadcrumbs>
782
+ <BreadcrumbItem
783
+ menu={
784
+ <>
785
+ <BreadcrumbMenuItem label="Overview" />
786
+ <BreadcrumbMenuDivider {...dividerProps} />
787
+ <BreadcrumbMenuItem label="Settings" />
788
+ </>
789
+ }>
790
+ Project
791
+ </BreadcrumbItem>
792
+ <BreadcrumbItem isCurrent>Current</BreadcrumbItem>
793
+ </Breadcrumbs>,
794
+ );
795
+ await user.click(screen.getByRole('button', {name: 'Project'}));
796
+
797
+ expect(screen.getByTestId('breadcrumb-menu-divider')).toHaveAttribute(
798
+ 'role',
799
+ 'separator',
800
+ );
801
+ });
802
+
774
803
  it('carries the group semantics through the BreadcrumbMenuGroup alias', async () => {
775
804
  // The alias must bring the semantics, not just the component: a group
776
805
  // that renders but is not named leaves a screen reader with a run of
@@ -138,7 +138,7 @@ export const docs = {
138
138
  {
139
139
  name: 'isLoading',
140
140
  type: 'boolean',
141
- description: 'Shows a loading spinner and disables interaction. Announces "Loading" via a live region.',
141
+ description: 'Shows a loading spinner and blocks re-activation while staying focusable (aria-busy + aria-disabled, not native disabled, so keyboard focus is never dropped mid-action). Announces "Loading" via a live region.',
142
142
  default: 'false',
143
143
  },
144
144
  {
@@ -419,7 +419,7 @@ export const docsDense = {
419
419
  displayName: 'HTML name for form submission',
420
420
  value: 'HTML value for form submission',
421
421
  form: 'associates button with form element by ID',
422
- isLoading: 'shows spinner+disables interaction; announces via live region',
422
+ isLoading: 'shows spinner, blocks re-activation but stays focusable (aria-disabled, not native disabled); announces via live region',
423
423
  icon: 'icon element rendered before label text; unsized Astryx Icon defaults to sm for sm/md buttons and md for lg',
424
424
  isIconOnly: 'when true, renders square icon-only button; label becomes aria-label',
425
425
  width: "Width of button. Numbers=pixels, strings=as-is (e.g. '100%' for full-width). Default: content width, capped at the container; label truncates w/ ellipsis in a narrow row; icon-only stays square.",
@@ -11,7 +11,13 @@
11
11
 
12
12
  import {readFileSync} from 'node:fs';
13
13
  import {describe, it, expect, vi} from 'vitest';
14
- import {render, screen, fireEvent, act} from '@testing-library/react';
14
+ import {
15
+ render,
16
+ screen,
17
+ fireEvent,
18
+ createEvent,
19
+ act,
20
+ } from '@testing-library/react';
15
21
  import userEvent from '@testing-library/user-event';
16
22
  import {Button} from './Button';
17
23
  import * as stylex from '@stylexjs/stylex';
@@ -127,8 +133,9 @@ describe('Button', () => {
127
133
  it('shows isLoading state with spinner', () => {
128
134
  render(<Button label="Submit" isLoading />);
129
135
  const button = screen.getByRole('button');
130
- // Button should be disabled when loading
131
- expect(button).toBeDisabled();
136
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
137
+ expect(button).not.toBeDisabled();
138
+ expect(button).toHaveAttribute('aria-disabled', 'true');
132
139
  expect(button.className).toContain('styles.inactive');
133
140
  expect(button.className).not.toContain('styles.disabled');
134
141
  });
@@ -143,7 +150,7 @@ describe('Button', () => {
143
150
 
144
151
  it('sets aria-busy synchronously while clickAction is pending', async () => {
145
152
  // The spinner reveal is visually delayed (CSS animation-delay), but the
146
- // loading DOM state — aria-busy and disabled — must not be delayed.
153
+ // loading DOM state — aria-busy and aria-disabled — must not be delayed.
147
154
  const user = userEvent.setup();
148
155
  let resolveAction: (() => void) | undefined;
149
156
  const clickAction = vi.fn(
@@ -157,7 +164,9 @@ describe('Button', () => {
157
164
 
158
165
  await user.click(button);
159
166
  expect(button).toHaveAttribute('aria-busy', 'true');
160
- expect(button).toBeDisabled();
167
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
168
+ expect(button).not.toBeDisabled();
169
+ expect(button).toHaveAttribute('aria-disabled', 'true');
161
170
 
162
171
  await act(async () => {
163
172
  resolveAction?.();
@@ -165,6 +174,7 @@ describe('Button', () => {
165
174
  });
166
175
  expect(button).not.toHaveAttribute('aria-busy', 'true');
167
176
  expect(button).not.toBeDisabled();
177
+ expect(button).not.toHaveAttribute('aria-disabled');
168
178
  });
169
179
 
170
180
  it('renders the loading spinner with the inherit shade for every variant (#2717)', () => {
@@ -310,9 +320,10 @@ describe('Button', () => {
310
320
  );
311
321
  // endContent should still be in the DOM
312
322
  expect(screen.getByTestId('end')).toBeInTheDocument();
313
- // Button should be disabled and have aria-busy
323
+ // Busy is aria-disabled (focusable), not native disabled — see #4871.
314
324
  const button = screen.getByRole('button');
315
- expect(button).toBeDisabled();
325
+ expect(button).not.toBeDisabled();
326
+ expect(button).toHaveAttribute('aria-disabled', 'true');
316
327
  expect(button).toHaveAttribute('aria-busy', 'true');
317
328
  });
318
329
 
@@ -516,6 +527,111 @@ describe('Button', () => {
516
527
  expect(handleKeyDown).toHaveBeenCalledTimes(1);
517
528
  });
518
529
 
530
+ it('uses aria-disabled instead of disabled while isLoading, so the button stays focusable (#4871)', () => {
531
+ render(<Button label="Save" isLoading />);
532
+ const button = screen.getByRole('button');
533
+ // Native disabled would move focus to <body> the moment the action
534
+ // starts, losing the keyboard user's place — busy must stay
535
+ // aria-disabled + focusable per the "Disabled vs Busy" convention.
536
+ expect(button).not.toHaveAttribute('disabled');
537
+ expect(button).toHaveAttribute('aria-disabled', 'true');
538
+ expect(button).toHaveAttribute('aria-busy', 'true');
539
+ button.focus();
540
+ expect(button).toHaveFocus();
541
+ });
542
+
543
+ it('does not fire onClick while aria-disabled via isLoading', async () => {
544
+ const user = userEvent.setup();
545
+ const handleClick = vi.fn();
546
+ render(<Button label="Save" isLoading onClick={handleClick} />);
547
+ await user.click(screen.getByRole('button'));
548
+ expect(handleClick).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('does not let a click on a busy button reach a wrapping ancestor handler', async () => {
552
+ // A native `disabled` element never dispatches a click, so it never
553
+ // reaches an ancestor either. aria-disabled (busy-only) is a real,
554
+ // interactive element that still dispatches one — preventDefault alone
555
+ // only cancels the button's own default action, not propagation, so
556
+ // an unrelated wrapper (a clickable row/card/backdrop) would otherwise
557
+ // still fire.
558
+ const user = userEvent.setup();
559
+ const ancestorClick = vi.fn();
560
+ render(
561
+ <div onClick={ancestorClick}>
562
+ <Button label="Save" isLoading />
563
+ </div>,
564
+ );
565
+ await user.click(screen.getByRole('button'));
566
+ expect(ancestorClick).not.toHaveBeenCalled();
567
+ });
568
+
569
+ it('does not let a click on a busy-only anchor Button reach a wrapping ancestor handler', async () => {
570
+ const user = userEvent.setup();
571
+ const ancestorClick = vi.fn();
572
+ render(
573
+ <div onClick={ancestorClick}>
574
+ <Button label="Docs" href="https://example.com" isLoading />
575
+ </div>,
576
+ );
577
+ await user.click(screen.getByRole('link'));
578
+ expect(ancestorClick).not.toHaveBeenCalled();
579
+ });
580
+
581
+ it('suppresses activation keys but passes other keys while aria-disabled via isLoading', async () => {
582
+ const user = userEvent.setup();
583
+ const handleKeyDown = vi.fn();
584
+ render(<Button label="Save" isLoading onKeyDown={handleKeyDown} />);
585
+ const button = screen.getByRole('button');
586
+ button.focus();
587
+ await user.keyboard('{Enter}');
588
+ expect(handleKeyDown).not.toHaveBeenCalled();
589
+
590
+ await user.keyboard('{Escape}');
591
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
592
+ });
593
+
594
+ it('suppresses activation keys but passes other keys on a busy-only anchor Button', async () => {
595
+ // The button-rendered path suppresses Enter/Space while aria-disabled;
596
+ // the anchor-rendered path (href set, busy-only) shares the same
597
+ // handleKeyDown but only wires onClick, so a consumer onKeyDown still
598
+ // received Enter/Space on a busy link.
599
+ const user = userEvent.setup();
600
+ const handleKeyDown = vi.fn();
601
+ render(
602
+ <Button
603
+ label="Docs"
604
+ href="https://example.com"
605
+ isLoading
606
+ onKeyDown={handleKeyDown}
607
+ />,
608
+ );
609
+ const link = screen.getByRole('link');
610
+ link.focus();
611
+ await user.keyboard('{Enter}');
612
+ expect(handleKeyDown).not.toHaveBeenCalled();
613
+
614
+ await user.keyboard('{Escape}');
615
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
616
+ });
617
+
618
+ it('keeps native disabled (not aria-disabled) when isDisabled and not busy, even without a tooltip', () => {
619
+ render(<Button label="Save" isDisabled />);
620
+ const button = screen.getByRole('button');
621
+ expect(button).toHaveAttribute('disabled');
622
+ expect(button).not.toHaveAttribute('aria-disabled');
623
+ });
624
+
625
+ it('keeps native disabled when isDisabled and isLoading are both true (isDisabled wins)', () => {
626
+ render(<Button label="Save" isDisabled isLoading />);
627
+ const button = screen.getByRole('button');
628
+ // "isDisabled behavior unchanged: that stays native disabled" — busy-only
629
+ // aria-disabled is only for the case where busy is the SOLE reason the
630
+ // button can't be clicked, not when it's also genuinely isDisabled.
631
+ expect(button).toHaveAttribute('disabled');
632
+ expect(button).not.toHaveAttribute('aria-disabled');
633
+ });
634
+
519
635
  it('has a live region that announces loading state', () => {
520
636
  const {rerender} = render(<Button label="Submit" />);
521
637
  const button = screen.getByRole('button');
@@ -663,8 +779,6 @@ describe('Button', () => {
663
779
  });
664
780
 
665
781
  it('exposes aria-busy on the link-rendered button while loading', () => {
666
- // Non-interruptible loading disables the button, which falls back to
667
- // <button> rendering — so an anchor only shows loading when interruptible.
668
782
  render(
669
783
  <Button
670
784
  label="Docs"
@@ -682,6 +796,38 @@ describe('Button', () => {
682
796
  const link = screen.getByRole('link');
683
797
  expect(link).not.toHaveAttribute('aria-busy');
684
798
  });
799
+
800
+ it('stays an anchor while busy-only, not a disabled <button> fallback (#4871)', () => {
801
+ // Non-interruptible busy is exactly the isBusyOnlyDisabled case: not
802
+ // isDisabled, blocked only because a fire-once action is in flight.
803
+ // Swapping the element to <button disabled> here would drop focus the
804
+ // same way the native disabled attribute did (credit @AKnassa, #4879).
805
+ render(<Button label="Docs" href="https://example.com" isLoading />);
806
+ const link = screen.getByRole('link', {name: 'Docs'});
807
+ expect(link.tagName).toBe('A');
808
+ expect(link).toHaveAttribute('href', 'https://example.com');
809
+ expect(link).toHaveAttribute('aria-busy', 'true');
810
+ expect(link).toHaveAttribute('aria-disabled', 'true');
811
+ expect(link).not.toHaveAttribute('disabled');
812
+ });
813
+
814
+ it('keeps a busy-only anchor focusable and guards its navigation', () => {
815
+ render(<Button label="Docs" href="https://example.com" isLoading />);
816
+ const link = screen.getByRole('link', {name: 'Docs'});
817
+ link.focus();
818
+ expect(link).toHaveFocus();
819
+
820
+ const event = createEvent.click(link);
821
+ fireEvent(link, event);
822
+ expect(event.defaultPrevented).toBe(true);
823
+ });
824
+
825
+ it('falls back to <button disabled> for a truly disabled href Button', () => {
826
+ render(<Button label="Docs" href="https://example.com" isDisabled />);
827
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
828
+ const button = screen.getByRole('button', {name: 'Docs'});
829
+ expect(button).toBeDisabled();
830
+ });
685
831
  });
686
832
 
687
833
  describe('Button pressed state (touch)', () => {