@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
@@ -0,0 +1,1300 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Drawer.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Drawer component
6
+ * @output Unit tests for Drawer component behavior
7
+ * @position Core testing; validates Drawer.tsx implementation
8
+ *
9
+ * SYNC: When Drawer.tsx changes, update tests to match new behavior
10
+ */
11
+
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
+ import {render, screen, fireEvent, act} from '@testing-library/react';
14
+ import {createContext, createRef, use, useState} from 'react';
15
+ import * as stylex from '@stylexjs/stylex';
16
+ import {Drawer} from './Drawer';
17
+ import {DrawerHeader} from './DrawerHeader';
18
+ import {Button} from '../Button';
19
+ import {ButtonGroup} from '../ButtonGroup';
20
+ import {layerTextReset} from '../Layer/layerTextReset.stylex';
21
+ import {layerStructureReset} from '../Layer/layerStructureReset.stylex';
22
+
23
+ // Mock dialog methods since they're not fully implemented in jsdom
24
+ beforeEach(() => {
25
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
26
+ this: HTMLDialogElement,
27
+ ) {
28
+ this.setAttribute('open', '');
29
+ });
30
+ HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
31
+ this.setAttribute('open', '');
32
+ });
33
+ HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
34
+ this.setAttribute('open', '');
35
+ });
36
+ HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) {
37
+ this.removeAttribute('open');
38
+ });
39
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
40
+ this.removeAttribute('open');
41
+ });
42
+
43
+ vi.stubGlobal(
44
+ 'matchMedia',
45
+ vi.fn().mockReturnValue({
46
+ matches: false,
47
+ media: '',
48
+ onchange: null,
49
+ addEventListener: vi.fn(),
50
+ removeEventListener: vi.fn(),
51
+ addListener: vi.fn(),
52
+ removeListener: vi.fn(),
53
+ dispatchEvent: vi.fn(),
54
+ }),
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.unstubAllGlobals();
60
+ });
61
+
62
+ describe('Drawer', () => {
63
+ it('renders children when open', () => {
64
+ render(
65
+ <Drawer isOpen onOpenChange={() => {}} label="Host details">
66
+ Drawer content
67
+ </Drawer>,
68
+ );
69
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
70
+ expect(screen.getByText('Drawer content')).toBeInTheDocument();
71
+ });
72
+
73
+ it('does not show when isOpen is false', () => {
74
+ render(
75
+ <Drawer isOpen={false} onOpenChange={() => {}} label="Host details">
76
+ Hidden content
77
+ </Drawer>,
78
+ );
79
+ const dialog = screen.getByRole('dialog', {hidden: true});
80
+ expect(dialog).not.toHaveAttribute('open');
81
+ expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('applies the accessible label', () => {
85
+ render(
86
+ <Drawer isOpen onOpenChange={() => {}} label="Host details">
87
+ Content
88
+ </Drawer>,
89
+ );
90
+ expect(screen.getByRole('dialog')).toHaveAccessibleName('Host details');
91
+ });
92
+
93
+ describe('modal vs non-modal', () => {
94
+ it('opens with showModal() and aria-modal by default (hasScrim)', () => {
95
+ render(
96
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
97
+ Content
98
+ </Drawer>,
99
+ );
100
+ expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
101
+ expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled();
102
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
103
+ });
104
+
105
+ it('opens with showPopover() and no aria-modal when hasScrim is false', () => {
106
+ render(
107
+ <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}>
108
+ Content
109
+ </Drawer>,
110
+ );
111
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
112
+ expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled();
113
+ expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
114
+ expect(screen.getByRole('dialog')).toHaveAttribute('popover', 'manual');
115
+ expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal');
116
+ });
117
+
118
+ it('leaves the manual popover and preserves close listeners after exit', () => {
119
+ const {rerender} = render(
120
+ <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}>
121
+ Content
122
+ </Drawer>,
123
+ );
124
+ const dialog = screen.getByRole('dialog', {hidden: true});
125
+ const handleClose = vi.fn();
126
+ dialog.addEventListener('close', handleClose);
127
+
128
+ rerender(
129
+ <Drawer
130
+ isOpen={false}
131
+ onOpenChange={() => {}}
132
+ label="Details"
133
+ hasScrim={false}>
134
+ Content
135
+ </Drawer>,
136
+ );
137
+ act(() => {
138
+ fireEvent.transitionEnd(dialog, {propertyName: 'transform'});
139
+ });
140
+
141
+ expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled();
142
+ expect(handleClose).toHaveBeenCalledTimes(1);
143
+ expect(dialog).not.toHaveAttribute('open');
144
+ });
145
+
146
+ it('dispatches the synthetic close after focus restore so a close listener owns final focus', () => {
147
+ // Master-detail row switching: the consumer's close listener retargets
148
+ // focus to the latest selected row. Native dialog.close() queues its
149
+ // close event as a task, so such a listener always ran after the
150
+ // drawer's own focus restore — the synthetic popover close must give
151
+ // the consumer the same last word.
152
+ function Fixture({isOpen}: {isOpen: boolean}) {
153
+ return (
154
+ <>
155
+ <button type="button">Open row</button>
156
+ <button type="button">Latest row</button>
157
+ <Drawer
158
+ isOpen={isOpen}
159
+ onOpenChange={() => {}}
160
+ label="Details"
161
+ hasScrim={false}>
162
+ Content
163
+ </Drawer>
164
+ </>
165
+ );
166
+ }
167
+ const {rerender} = render(<Fixture isOpen={false} />);
168
+ const openTrigger = screen.getByRole('button', {name: 'Open row'});
169
+ const latestRow = screen.getByRole('button', {name: 'Latest row'});
170
+ openTrigger.focus();
171
+ rerender(<Fixture isOpen />);
172
+
173
+ const dialog = screen.getByRole('dialog', {hidden: true});
174
+ const handleClose = vi.fn(() => latestRow.focus());
175
+ dialog.addEventListener('close', handleClose);
176
+
177
+ rerender(<Fixture isOpen={false} />);
178
+ act(() => {
179
+ fireEvent.transitionEnd(dialog, {propertyName: 'transform'});
180
+ });
181
+
182
+ expect(handleClose).toHaveBeenCalledTimes(1);
183
+ expect(latestRow).toHaveFocus();
184
+ });
185
+
186
+ it('dispatches exactly one close when an open non-modal drawer unmounts', () => {
187
+ const {unmount} = render(
188
+ <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}>
189
+ Content
190
+ </Drawer>,
191
+ );
192
+ const dialog = screen.getByRole('dialog', {hidden: true});
193
+ const handleClose = vi.fn();
194
+ dialog.addEventListener('close', handleClose);
195
+
196
+ unmount();
197
+
198
+ expect(HTMLElement.prototype.hidePopover).toHaveBeenCalledTimes(1);
199
+ expect(handleClose).toHaveBeenCalledTimes(1);
200
+ });
201
+ });
202
+
203
+ describe('Escape key', () => {
204
+ it('calls onOpenChange(false) on Escape keydown', () => {
205
+ const handleOpenChange = vi.fn();
206
+ render(
207
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
208
+ Content
209
+ </Drawer>,
210
+ );
211
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
212
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
213
+ });
214
+
215
+ it('calls onOpenChange(false) on Escape in non-modal mode (no native cancel)', () => {
216
+ const handleOpenChange = vi.fn();
217
+ render(
218
+ <Drawer
219
+ isOpen
220
+ onOpenChange={handleOpenChange}
221
+ label="Details"
222
+ hasScrim={false}>
223
+ Content
224
+ </Drawer>,
225
+ );
226
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
227
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
228
+ });
229
+
230
+ it('prevents the native cancel event and routes through onOpenChange(false)', () => {
231
+ const handleOpenChange = vi.fn();
232
+ render(
233
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
234
+ Content
235
+ </Drawer>,
236
+ );
237
+ const cancelEvent = new Event('cancel', {cancelable: true});
238
+ fireEvent(screen.getByRole('dialog'), cancelEvent);
239
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
240
+ expect(cancelEvent.defaultPrevented).toBe(true);
241
+ });
242
+
243
+ it('ignores other keys', () => {
244
+ const handleOpenChange = vi.fn();
245
+ render(
246
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
247
+ Content
248
+ </Drawer>,
249
+ );
250
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Enter'});
251
+ expect(handleOpenChange).not.toHaveBeenCalled();
252
+ });
253
+ });
254
+
255
+ describe('consumer event handlers', () => {
256
+ it('composes a consumer onKeyDown with built-in Escape handling', () => {
257
+ const handleKeyDown = vi.fn();
258
+ const handleOpenChange = vi.fn();
259
+ render(
260
+ <Drawer
261
+ isOpen
262
+ onOpenChange={handleOpenChange}
263
+ label="Details"
264
+ onKeyDown={handleKeyDown}>
265
+ Content
266
+ </Drawer>,
267
+ );
268
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
269
+ expect(handleKeyDown).toHaveBeenCalledTimes(1);
270
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
271
+ });
272
+
273
+ it('lets a consumer preventDefault opt out of built-in Escape handling', () => {
274
+ const handleOpenChange = vi.fn();
275
+ render(
276
+ <Drawer
277
+ isOpen
278
+ onOpenChange={handleOpenChange}
279
+ label="Details"
280
+ onKeyDown={event => event.preventDefault()}>
281
+ Content
282
+ </Drawer>,
283
+ );
284
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
285
+ expect(handleOpenChange).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it('hands a consumer onKeyDown the Escape press ahead of the shared stack', () => {
289
+ // The consumer handler sits on the dialog; the stack's one listener sits
290
+ // on the document. The press must reach the consumer first so its
291
+ // preventDefault (above) can still stand the stack down.
292
+ const callOrder: string[] = [];
293
+ render(
294
+ <Drawer
295
+ isOpen
296
+ onOpenChange={() => callOrder.push('dismiss')}
297
+ label="Details"
298
+ onKeyDown={event => callOrder.push(`consumer:${event.key}`)}>
299
+ Content
300
+ </Drawer>,
301
+ );
302
+ fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
303
+ expect(callOrder).toEqual(['consumer:Escape', 'dismiss']);
304
+ });
305
+ });
306
+
307
+ describe('API surface', () => {
308
+ // FR11: the root filters `open`, merges `xstyle`/`className`/`style`,
309
+ // forwards remaining unclaimed props, reaches the <dialog> through both
310
+ // ref forms, and composes the consumer onClick with scrim dismissal.
311
+ it('does not forward a native open prop to the dialog element', () => {
312
+ render(
313
+ <Drawer
314
+ isOpen={false}
315
+ onOpenChange={() => {}}
316
+ label="Details"
317
+ {...({open: true} as Record<string, unknown>)}>
318
+ Content
319
+ </Drawer>,
320
+ );
321
+ // isOpen controls the native state; a caller-passed `open` must not put
322
+ // the closed dialog into the open state behind the presence hook's back.
323
+ const dialog = screen.getByRole('dialog', {hidden: true});
324
+ expect(dialog).not.toHaveAttribute('open');
325
+ });
326
+
327
+ it('merges a consumer className with the theme target and StyleX classes', () => {
328
+ render(
329
+ <Drawer
330
+ isOpen
331
+ onOpenChange={() => {}}
332
+ label="Details"
333
+ className="inspector-panel">
334
+ Content
335
+ </Drawer>,
336
+ );
337
+ const dialog = screen.getByRole('dialog');
338
+ const classes = dialog.className.split(' ');
339
+ expect(classes).toContain('astryx-drawer');
340
+ expect(classes).toContain('inspector-panel');
341
+ // Drawer's own StyleX styling survives the merge.
342
+ expect(getComputedStyle(dialog).position).toBe('fixed');
343
+ });
344
+
345
+ it('merges a consumer xstyle after Drawer styles so the override wins', () => {
346
+ const overrides = stylex.create({narrow: {maxWidth: '240px'}});
347
+ render(
348
+ <Drawer
349
+ isOpen
350
+ onOpenChange={() => {}}
351
+ label="Details"
352
+ xstyle={overrides.narrow}>
353
+ Content
354
+ </Drawer>,
355
+ );
356
+ const dialog = screen.getByRole('dialog');
357
+ expect(getComputedStyle(dialog).maxWidth).toBe('240px');
358
+ expect(getComputedStyle(dialog).position).toBe('fixed');
359
+ expect(dialog.className).toContain('astryx-drawer');
360
+ });
361
+
362
+ it('merges a consumer style with Drawer inline styles, consumer last', () => {
363
+ render(
364
+ <Drawer
365
+ isOpen
366
+ onOpenChange={() => {}}
367
+ label="Details"
368
+ style={{zIndex: 30}}>
369
+ Content
370
+ </Drawer>,
371
+ );
372
+ const dialog = screen.getByRole('dialog');
373
+ expect(dialog.style.zIndex).toBe('30');
374
+ // The width budget travels as a StyleX dynamic inline variable and must
375
+ // survive the merge rather than be replaced by the consumer style.
376
+ expect(dialog.getAttribute('style')).toContain('400px');
377
+ });
378
+
379
+ it('forwards a data attribute and an ARIA attribute to the root dialog', () => {
380
+ render(
381
+ <Drawer
382
+ isOpen
383
+ onOpenChange={() => {}}
384
+ label="Details"
385
+ data-tracking-id="inspector"
386
+ aria-describedby="drawer-hint">
387
+ Content
388
+ </Drawer>,
389
+ );
390
+ const dialog = screen.getByRole('dialog');
391
+ expect(dialog).toHaveAttribute('data-tracking-id', 'inspector');
392
+ expect(dialog).toHaveAttribute('aria-describedby', 'drawer-hint');
393
+ });
394
+
395
+ it('reaches the dialog element through an object ref', () => {
396
+ const ref = createRef<HTMLDialogElement>();
397
+ render(
398
+ <Drawer isOpen onOpenChange={() => {}} label="Details" ref={ref}>
399
+ Content
400
+ </Drawer>,
401
+ );
402
+ expect(ref.current).toBe(screen.getByRole('dialog'));
403
+ });
404
+
405
+ it('reaches the dialog element through a callback ref', () => {
406
+ let current: HTMLDialogElement | null = null;
407
+ render(
408
+ <Drawer
409
+ isOpen
410
+ onOpenChange={() => {}}
411
+ label="Details"
412
+ ref={node => {
413
+ current = node;
414
+ }}>
415
+ Content
416
+ </Drawer>,
417
+ );
418
+ expect(current).toBe(screen.getByRole('dialog'));
419
+ });
420
+
421
+ it('runs a consumer onClick before scrim dismissal on a backdrop click', () => {
422
+ const callOrder: string[] = [];
423
+ const handleClick = vi.fn(() => callOrder.push('consumer'));
424
+ const handleOpenChange = vi.fn(() => callOrder.push('dismiss'));
425
+ render(
426
+ <Drawer
427
+ isOpen
428
+ onOpenChange={handleOpenChange}
429
+ label="Details"
430
+ onClick={handleClick}>
431
+ Content
432
+ </Drawer>,
433
+ );
434
+ fireEvent.click(screen.getByRole('dialog'));
435
+ expect(handleClick).toHaveBeenCalledTimes(1);
436
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
437
+ expect(callOrder).toEqual(['consumer', 'dismiss']);
438
+ });
439
+
440
+ it('lets a consumer onClick preventDefault cancel the scrim dismissal', () => {
441
+ const handleOpenChange = vi.fn();
442
+ render(
443
+ <Drawer
444
+ isOpen
445
+ onOpenChange={handleOpenChange}
446
+ label="Details"
447
+ onClick={event => event.preventDefault()}>
448
+ Content
449
+ </Drawer>,
450
+ );
451
+ fireEvent.click(screen.getByRole('dialog'));
452
+ expect(handleOpenChange).not.toHaveBeenCalled();
453
+ });
454
+ });
455
+
456
+ describe('layer content boundary', () => {
457
+ // Drawer's content root uses the private layer content boundary, like
458
+ // Dialog's: surface-scoped ancestor membership stops at the drawer while
459
+ // ordinary application contexts keep flowing (layerScopedContext.test.tsx
460
+ // proves the same for the other content roots).
461
+ it('ends ancestor group membership while explicit and inner-group state hold', () => {
462
+ const press = vi.fn();
463
+ render(
464
+ <ButtonGroup label="Outer" isDisabled>
465
+ <Button label="Outer member" />
466
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
467
+ <Button label="Independent" onClick={press} />
468
+ <Button label="Explicit disabled" isDisabled />
469
+ <ButtonGroup label="Inner" isDisabled>
470
+ <Button label="Inner member" />
471
+ </ButtonGroup>
472
+ </Drawer>
473
+ </ButtonGroup>,
474
+ );
475
+ expect(screen.getByRole('button', {name: 'Outer member'})).toBeDisabled();
476
+ const independent = screen.getByRole('button', {name: 'Independent'});
477
+ expect(independent).toBeEnabled();
478
+ fireEvent.click(independent);
479
+ expect(press).toHaveBeenCalledOnce();
480
+ expect(
481
+ screen.getByRole('button', {name: 'Explicit disabled'}),
482
+ ).toBeDisabled();
483
+ expect(screen.getByRole('button', {name: 'Inner member'})).toBeDisabled();
484
+ });
485
+
486
+ it('keeps unrelated application contexts flowing into drawer content', () => {
487
+ const ServiceContext = createContext('fallback');
488
+ ServiceContext.displayName = 'ServiceContext';
489
+ function ReadService() {
490
+ return <output>{use(ServiceContext)}</output>;
491
+ }
492
+ render(
493
+ <ServiceContext value="live service">
494
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
495
+ <ReadService />
496
+ </Drawer>
497
+ </ServiceContext>,
498
+ );
499
+ expect(screen.getByText('live service')).toBeInTheDocument();
500
+ });
501
+
502
+ it('applies the shared text baseline and structural reset on the root dialog', () => {
503
+ render(
504
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
505
+ Content
506
+ </Drawer>,
507
+ );
508
+ const dialog = screen.getByRole('dialog');
509
+ const classes = dialog.className.split(' ');
510
+ // The shared resets, not a local copy, supply the layer baseline.
511
+ for (const cls of (stylex.props(layerTextReset.reset).className ?? '')
512
+ .split(' ')
513
+ .filter(Boolean)) {
514
+ expect(classes).toContain(cls);
515
+ }
516
+ for (const cls of (
517
+ stylex.props(layerStructureReset.reset).className ?? ''
518
+ )
519
+ .split(' ')
520
+ .filter(Boolean)) {
521
+ expect(classes).toContain(cls);
522
+ }
523
+ // The baseline resolves on the root itself, not from the page.
524
+ expect(getComputedStyle(dialog).textAlign).toBe('start');
525
+ });
526
+ });
527
+
528
+ describe('scrim click', () => {
529
+ it('calls onOpenChange(false) when the ::backdrop (dialog element itself) is clicked', () => {
530
+ const handleOpenChange = vi.fn();
531
+ render(
532
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
533
+ Content
534
+ </Drawer>,
535
+ );
536
+ fireEvent.click(screen.getByRole('dialog'));
537
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
538
+ });
539
+
540
+ it('does not close when drawer content is clicked', () => {
541
+ const handleOpenChange = vi.fn();
542
+ render(
543
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
544
+ <button type="button">Inside</button>
545
+ </Drawer>,
546
+ );
547
+ fireEvent.click(screen.getByRole('button', {name: 'Inside'}));
548
+ expect(handleOpenChange).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('does not close on self-click when non-modal (no scrim to click)', () => {
552
+ const handleOpenChange = vi.fn();
553
+ render(
554
+ <Drawer
555
+ isOpen
556
+ onOpenChange={handleOpenChange}
557
+ label="Details"
558
+ hasScrim={false}>
559
+ Content
560
+ </Drawer>,
561
+ );
562
+ fireEvent.click(screen.getByRole('dialog'));
563
+ expect(handleOpenChange).not.toHaveBeenCalled();
564
+ });
565
+ });
566
+
567
+ describe('close and focus restore', () => {
568
+ function Harness() {
569
+ const [isOpen, setIsOpen] = useState(false);
570
+ return (
571
+ <>
572
+ <button type="button" onClick={() => setIsOpen(true)}>
573
+ Open inspector
574
+ </button>
575
+ <Drawer isOpen={isOpen} onOpenChange={setIsOpen} label="Inspector">
576
+ <button type="button" onClick={() => setIsOpen(false)}>
577
+ Close inspector
578
+ </button>
579
+ </Drawer>
580
+ </>
581
+ );
582
+ }
583
+
584
+ it('delays dialog.close() so the exit transition can play', () => {
585
+ vi.useFakeTimers();
586
+ try {
587
+ render(<Harness />);
588
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
589
+ const dialog = screen.getByRole('dialog', {hidden: true});
590
+ expect(dialog).toHaveAttribute('open');
591
+
592
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
593
+ // Still open while the slide-out transition plays
594
+ expect(dialog).toHaveAttribute('open');
595
+ act(() => {
596
+ vi.advanceTimersByTime(250);
597
+ });
598
+ expect(dialog).toHaveAttribute('open');
599
+ act(() => {
600
+ vi.advanceTimersByTime(300);
601
+ });
602
+ expect(dialog).not.toHaveAttribute('open');
603
+ } finally {
604
+ vi.useRealTimers();
605
+ }
606
+ });
607
+
608
+ it('closes as soon as the slide-out transition ends', () => {
609
+ vi.useFakeTimers();
610
+ try {
611
+ render(<Harness />);
612
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
613
+ const dialog = screen.getByRole('dialog', {hidden: true});
614
+
615
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
616
+ expect(dialog).toHaveAttribute('open');
617
+
618
+ // The transition is authoritative — no need to wait out the backstop.
619
+ act(() => {
620
+ fireEvent.transitionEnd(dialog, {propertyName: 'transform'});
621
+ });
622
+ expect(dialog).not.toHaveAttribute('open');
623
+ } finally {
624
+ vi.useRealTimers();
625
+ }
626
+ });
627
+
628
+ it('keeps sliding while a transitionend for another property arrives', () => {
629
+ vi.useFakeTimers();
630
+ try {
631
+ render(<Harness />);
632
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
633
+ const dialog = screen.getByRole('dialog', {hidden: true});
634
+
635
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
636
+ act(() => {
637
+ fireEvent.transitionEnd(dialog, {propertyName: 'opacity'});
638
+ });
639
+ expect(dialog).toHaveAttribute('open');
640
+ } finally {
641
+ vi.useRealTimers();
642
+ }
643
+ });
644
+
645
+ it('restores focus to the trigger element on close', () => {
646
+ vi.useFakeTimers();
647
+ try {
648
+ render(<Harness />);
649
+ const trigger = screen.getByRole('button', {name: 'Open inspector'});
650
+ trigger.focus();
651
+ fireEvent.click(trigger);
652
+
653
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
654
+ act(() => {
655
+ vi.advanceTimersByTime(300);
656
+ });
657
+ expect(trigger).toHaveFocus();
658
+ } finally {
659
+ vi.useRealTimers();
660
+ }
661
+ });
662
+
663
+ it('can be re-opened after closing', () => {
664
+ vi.useFakeTimers();
665
+ try {
666
+ render(<Harness />);
667
+ const dialog = screen.getByRole('dialog', {hidden: true});
668
+
669
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
670
+ expect(dialog).toHaveAttribute('open');
671
+
672
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
673
+ act(() => {
674
+ vi.advanceTimersByTime(300);
675
+ });
676
+ expect(dialog).not.toHaveAttribute('open');
677
+
678
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
679
+ act(() => {
680
+ vi.advanceTimersByTime(300);
681
+ });
682
+ expect(dialog).toHaveAttribute('open');
683
+ } finally {
684
+ vi.useRealTimers();
685
+ }
686
+ });
687
+ });
688
+
689
+ it('traps Tab focus inside a modal drawer', () => {
690
+ render(
691
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
692
+ <button type="button">First action</button>
693
+ <button type="button">Last action</button>
694
+ </Drawer>,
695
+ );
696
+ screen.getByRole('button', {name: 'Last action'}).focus();
697
+
698
+ fireEvent.keyDown(document, {key: 'Tab'});
699
+
700
+ expect(screen.getByRole('button', {name: 'First action'})).toHaveFocus();
701
+ });
702
+
703
+ it('focuses the element with data-autofocus on open', () => {
704
+ render(
705
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
706
+ <button type="button">First</button>
707
+ <button type="button" data-autofocus>
708
+ Second
709
+ </button>
710
+ </Drawer>,
711
+ );
712
+ expect(screen.getByRole('button', {name: 'Second'})).toHaveFocus();
713
+ });
714
+
715
+ it('renders the side as a data attribute for theming', () => {
716
+ render(
717
+ <Drawer isOpen onOpenChange={() => {}} label="Details" side="start">
718
+ Content
719
+ </Drawer>,
720
+ );
721
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', 'start');
722
+ });
723
+
724
+ describe('sides', () => {
725
+ it.each(['start', 'end'] as const)(
726
+ 'renders side="%s" with the matching data attribute',
727
+ side => {
728
+ render(
729
+ <Drawer isOpen onOpenChange={() => {}} label="Details" side={side}>
730
+ Content
731
+ </Drawer>,
732
+ );
733
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-side', side);
734
+ },
735
+ );
736
+ });
737
+
738
+ describe('width', () => {
739
+ it('applies the default 400px inline budget', () => {
740
+ render(
741
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
742
+ Content
743
+ </Drawer>,
744
+ );
745
+ expect(screen.getByRole('dialog').getAttribute('style')).toContain(
746
+ '400px',
747
+ );
748
+ });
749
+
750
+ it('accepts a number of pixels', () => {
751
+ render(
752
+ <Drawer isOpen onOpenChange={() => {}} label="Details" width={320}>
753
+ Content
754
+ </Drawer>,
755
+ );
756
+ expect(screen.getByRole('dialog').getAttribute('style')).toContain(
757
+ '320px',
758
+ );
759
+ });
760
+
761
+ it('accepts any CSS length string', () => {
762
+ render(
763
+ <Drawer isOpen onOpenChange={() => {}} label="Details" width="50%">
764
+ Content
765
+ </Drawer>,
766
+ );
767
+ expect(screen.getByRole('dialog').getAttribute('style')).toContain('50%');
768
+ });
769
+
770
+ it('preserves a 56px page reveal without exceeding the width budget on mobile', () => {
771
+ render(
772
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
773
+ Content
774
+ </Drawer>,
775
+ );
776
+ // Desktop budget and mobile cap are both emitted as custom properties;
777
+ // the media query itself is compiled CSS that jsdom cannot evaluate,
778
+ // so assert both values reach the element.
779
+ const style = screen.getByRole('dialog').getAttribute('style') ?? '';
780
+ expect(style).toContain('400px');
781
+ expect(style).toContain('min(400px, calc(100dvw - 56px))');
782
+ });
783
+
784
+ it('covers the full viewport on mobile with experimental.isFullWidthOnMobile', () => {
785
+ render(
786
+ <Drawer
787
+ isOpen
788
+ onOpenChange={() => {}}
789
+ label="Details"
790
+ experimental={{isFullWidthOnMobile: true}}>
791
+ Content
792
+ </Drawer>,
793
+ );
794
+ const dialog = screen.getByRole('dialog');
795
+ const style = dialog.getAttribute('style') ?? '';
796
+ expect(style).toContain('100dvw');
797
+ expect(style).not.toContain('100dvw - 56px');
798
+ // The experimental object is a prop namespace, never a DOM attribute.
799
+ expect(dialog).not.toHaveAttribute('experimental');
800
+ });
801
+
802
+ it('keeps the 56px page reveal when experimental omits the option', () => {
803
+ render(
804
+ <Drawer
805
+ isOpen
806
+ onOpenChange={() => {}}
807
+ label="Details"
808
+ experimental={{}}>
809
+ Content
810
+ </Drawer>,
811
+ );
812
+ const style = screen.getByRole('dialog').getAttribute('style') ?? '';
813
+ expect(style).toContain('min(400px, calc(100dvw - 56px))');
814
+ });
815
+ });
816
+
817
+ describe('close control', () => {
818
+ it('renders no close button of its own, modal or non-modal', () => {
819
+ const {rerender} = render(
820
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
821
+ Content
822
+ </Drawer>,
823
+ );
824
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
825
+ rerender(
826
+ <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}>
827
+ Content
828
+ </Drawer>,
829
+ );
830
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
831
+ });
832
+
833
+ it('closes through a DrawerHeader given the same onOpenChange', () => {
834
+ const handleOpenChange = vi.fn();
835
+ render(
836
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
837
+ <DrawerHeader title="Details" onOpenChange={handleOpenChange} />
838
+ Content
839
+ </Drawer>,
840
+ );
841
+ fireEvent.click(screen.getByRole('button', {name: 'Close'}));
842
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
843
+ });
844
+ });
845
+
846
+ describe('purpose', () => {
847
+ it("defaults to 'info': Escape and a scrim click both request close", () => {
848
+ const handleOpenChange = vi.fn();
849
+ render(
850
+ <Drawer isOpen onOpenChange={handleOpenChange} label="Details">
851
+ Content
852
+ </Drawer>,
853
+ );
854
+ const dialog = screen.getByRole('dialog');
855
+ fireEvent.keyDown(dialog, {key: 'Escape'});
856
+ fireEvent.click(dialog);
857
+ expect(handleOpenChange).toHaveBeenCalledTimes(2);
858
+ expect(handleOpenChange).toHaveBeenNthCalledWith(1, false);
859
+ expect(handleOpenChange).toHaveBeenNthCalledWith(2, false);
860
+ });
861
+
862
+ it("'form' closes on Escape but ignores a scrim click", () => {
863
+ const handleOpenChange = vi.fn();
864
+ render(
865
+ <Drawer
866
+ isOpen
867
+ onOpenChange={handleOpenChange}
868
+ label="Details"
869
+ purpose="form">
870
+ Content
871
+ </Drawer>,
872
+ );
873
+ const dialog = screen.getByRole('dialog');
874
+ fireEvent.click(dialog);
875
+ expect(handleOpenChange).not.toHaveBeenCalled();
876
+ fireEvent.keyDown(dialog, {key: 'Escape'});
877
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
878
+ });
879
+
880
+ it("'required' ignores Escape, the native cancel event, and a scrim click", () => {
881
+ const handleOpenChange = vi.fn();
882
+ render(
883
+ <Drawer
884
+ isOpen
885
+ onOpenChange={handleOpenChange}
886
+ label="Accept terms"
887
+ purpose="required">
888
+ Content
889
+ </Drawer>,
890
+ );
891
+ const dialog = screen.getByRole('alertdialog');
892
+ fireEvent.keyDown(dialog, {key: 'Escape'});
893
+ fireEvent.click(dialog);
894
+ const cancelEvent = new Event('cancel', {cancelable: true});
895
+ fireEvent(dialog, cancelEvent);
896
+ expect(cancelEvent.defaultPrevented).toBe(true);
897
+ expect(handleOpenChange).not.toHaveBeenCalled();
898
+ });
899
+
900
+ it("'required' consumes Escape so a drawer behind it stays open", () => {
901
+ const closeOuter = vi.fn();
902
+ const closeInner = vi.fn();
903
+ function Harness() {
904
+ const [isInnerOpen, setIsInnerOpen] = useState(false);
905
+ return (
906
+ <>
907
+ <Drawer
908
+ isOpen
909
+ onOpenChange={closeOuter}
910
+ label="Order"
911
+ hasScrim={false}>
912
+ <button type="button" onClick={() => setIsInnerOpen(true)}>
913
+ Open terms
914
+ </button>
915
+ </Drawer>
916
+ <Drawer
917
+ isOpen={isInnerOpen}
918
+ onOpenChange={closeInner}
919
+ label="Accept terms"
920
+ purpose="required">
921
+ Terms
922
+ </Drawer>
923
+ </>
924
+ );
925
+ }
926
+ render(<Harness />);
927
+ fireEvent.click(screen.getByRole('button', {name: 'Open terms'}));
928
+ fireEvent.keyDown(document, {key: 'Escape'});
929
+ expect(closeInner).not.toHaveBeenCalled();
930
+ expect(closeOuter).not.toHaveBeenCalled();
931
+ });
932
+
933
+ it("exposes a modal 'required' drawer as an alertdialog", () => {
934
+ render(
935
+ <Drawer
936
+ isOpen
937
+ onOpenChange={() => {}}
938
+ label="Accept terms"
939
+ purpose="required">
940
+ Content
941
+ </Drawer>,
942
+ );
943
+ expect(
944
+ screen.getByRole('alertdialog', {name: 'Accept terms'}),
945
+ ).toHaveAttribute('aria-modal', 'true');
946
+ });
947
+
948
+ it("keeps a non-modal 'required' drawer in the dialog role", () => {
949
+ render(
950
+ <Drawer
951
+ isOpen
952
+ onOpenChange={() => {}}
953
+ label="Accept terms"
954
+ purpose="required"
955
+ hasScrim={false}>
956
+ Content
957
+ </Drawer>,
958
+ );
959
+ expect(
960
+ screen.getByRole('dialog', {name: 'Accept terms'}),
961
+ ).toBeInTheDocument();
962
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
963
+ });
964
+ });
965
+
966
+ describe('LIFO stacking', () => {
967
+ it('Escape closes only the last-opened drawer, regardless of event target', () => {
968
+ const closeFirst = vi.fn();
969
+ const closeSecond = vi.fn();
970
+ render(
971
+ <>
972
+ <Drawer
973
+ isOpen
974
+ onOpenChange={closeFirst}
975
+ label="First"
976
+ hasScrim={false}>
977
+ First content
978
+ </Drawer>
979
+ <Drawer
980
+ isOpen
981
+ onOpenChange={closeSecond}
982
+ label="Second"
983
+ hasScrim={false}>
984
+ Second content
985
+ </Drawer>
986
+ </>,
987
+ );
988
+
989
+ // The shared document-level owner routes Escape to the top layer even
990
+ // when the event starts inside a lower sibling.
991
+ fireEvent.keyDown(screen.getByRole('dialog', {name: 'First'}), {
992
+ key: 'Escape',
993
+ });
994
+ expect(closeSecond).toHaveBeenCalledWith(false);
995
+ expect(closeFirst).not.toHaveBeenCalled();
996
+ });
997
+
998
+ function StackHarness() {
999
+ const [outerOpen, setOuterOpen] = useState(true);
1000
+ const [innerOpen, setInnerOpen] = useState(true);
1001
+ return (
1002
+ <>
1003
+ <Drawer
1004
+ isOpen={outerOpen}
1005
+ onOpenChange={setOuterOpen}
1006
+ label="Outer"
1007
+ hasScrim={false}>
1008
+ Outer content
1009
+ </Drawer>
1010
+ <Drawer
1011
+ isOpen={innerOpen}
1012
+ onOpenChange={setInnerOpen}
1013
+ label="Inner"
1014
+ hasScrim={false}>
1015
+ Inner content
1016
+ </Drawer>
1017
+ </>
1018
+ );
1019
+ }
1020
+
1021
+ it('keeps a closing top drawer on the stack until its exit completes', () => {
1022
+ render(<StackHarness />);
1023
+ const outer = screen.getByRole('dialog', {name: 'Outer'});
1024
+ const inner = screen.getByRole('dialog', {name: 'Inner'});
1025
+
1026
+ fireEvent.keyDown(inner, {key: 'Escape'});
1027
+ // The inner host is still top-layer present while it slides out. A second
1028
+ // Escape must be consumed by that closing surface, not reach the outer.
1029
+ fireEvent.keyDown(outer, {key: 'Escape'});
1030
+ expect(outer).toHaveAttribute('open');
1031
+ expect(inner).toHaveAttribute('open');
1032
+
1033
+ act(() => {
1034
+ fireEvent.transitionEnd(inner, {propertyName: 'transform'});
1035
+ });
1036
+ expect(inner).not.toHaveAttribute('open');
1037
+ expect(outer).toHaveAttribute('open');
1038
+
1039
+ fireEvent.keyDown(outer, {key: 'Escape'});
1040
+ act(() => {
1041
+ fireEvent.transitionEnd(outer, {propertyName: 'transform'});
1042
+ });
1043
+ expect(outer).not.toHaveAttribute('open');
1044
+ });
1045
+
1046
+ function ReopenHarness() {
1047
+ const [firstOpen, setFirstOpen] = useState(true);
1048
+ const [secondOpen, setSecondOpen] = useState(true);
1049
+ return (
1050
+ <>
1051
+ <button type="button" onClick={() => setFirstOpen(false)}>
1052
+ Close first
1053
+ </button>
1054
+ <button type="button" onClick={() => setFirstOpen(true)}>
1055
+ Reopen first
1056
+ </button>
1057
+ <Drawer
1058
+ isOpen={firstOpen}
1059
+ onOpenChange={setFirstOpen}
1060
+ label="First"
1061
+ hasScrim={false}>
1062
+ First content
1063
+ </Drawer>
1064
+ <Drawer
1065
+ isOpen={secondOpen}
1066
+ onOpenChange={setSecondOpen}
1067
+ label="Second"
1068
+ hasScrim={false}>
1069
+ Second content
1070
+ </Drawer>
1071
+ </>
1072
+ );
1073
+ }
1074
+
1075
+ it('moves a reopened sibling above a drawer that stayed open', () => {
1076
+ render(<ReopenHarness />);
1077
+ const first = screen.getByRole('dialog', {name: 'First'});
1078
+ const second = screen.getByRole('dialog', {name: 'Second'});
1079
+
1080
+ fireEvent.click(screen.getByRole('button', {name: 'Close first'}));
1081
+ act(() => {
1082
+ fireEvent.transitionEnd(first, {propertyName: 'transform'});
1083
+ });
1084
+ expect(first).not.toHaveAttribute('open');
1085
+ expect(second).toHaveAttribute('open');
1086
+
1087
+ fireEvent.click(screen.getByRole('button', {name: 'Reopen first'}));
1088
+ expect(first).toHaveAttribute('open');
1089
+
1090
+ // Browser top-layer order now paints First above Second. The shared
1091
+ // dismissal stack must use the same reopened-last ordering even when the
1092
+ // key event starts in the lower sibling.
1093
+ fireEvent.keyDown(second, {key: 'Escape'});
1094
+ act(() => {
1095
+ fireEvent.transitionEnd(first, {propertyName: 'transform'});
1096
+ });
1097
+ expect(first).not.toHaveAttribute('open');
1098
+ expect(second).toHaveAttribute('open');
1099
+ });
1100
+
1101
+ it('unregisters unmounted drawers so the remaining one becomes top', () => {
1102
+ const closeFirst = vi.fn();
1103
+ const {rerender} = render(
1104
+ <>
1105
+ <Drawer
1106
+ isOpen
1107
+ onOpenChange={closeFirst}
1108
+ label="First"
1109
+ hasScrim={false}>
1110
+ First content
1111
+ </Drawer>
1112
+ <Drawer
1113
+ isOpen
1114
+ onOpenChange={() => {}}
1115
+ label="Second"
1116
+ hasScrim={false}>
1117
+ Second content
1118
+ </Drawer>
1119
+ </>,
1120
+ );
1121
+ rerender(
1122
+ <Drawer isOpen onOpenChange={closeFirst} label="First" hasScrim={false}>
1123
+ First content
1124
+ </Drawer>,
1125
+ );
1126
+ fireEvent.keyDown(screen.getByRole('dialog', {name: 'First'}), {
1127
+ key: 'Escape',
1128
+ });
1129
+ expect(closeFirst).toHaveBeenCalledWith(false);
1130
+ });
1131
+ });
1132
+
1133
+ describe('exit anchoring', () => {
1134
+ it('slides out to the side it opened from, even if the prop flips', () => {
1135
+ // The common consumer shape: `side` is derived from the same state that
1136
+ // drives isOpen, so it reverts to the default the moment the drawer
1137
+ // closes. The panel must still leave by the edge it came in from.
1138
+ function Harness() {
1139
+ const [side, setSide] = useState<'start' | 'end' | null>(null);
1140
+ return (
1141
+ <>
1142
+ <button type="button" onClick={() => setSide('start')}>
1143
+ Open from start
1144
+ </button>
1145
+ <Drawer
1146
+ isOpen={side != null}
1147
+ onOpenChange={isOpen => !isOpen && setSide(null)}
1148
+ label="Filters"
1149
+ side={side ?? 'end'}>
1150
+ Content
1151
+ </Drawer>
1152
+ </>
1153
+ );
1154
+ }
1155
+ render(<Harness />);
1156
+ fireEvent.click(screen.getByRole('button', {name: 'Open from start'}));
1157
+ const dialog = screen.getByRole('dialog', {hidden: true});
1158
+ expect(dialog).toHaveAttribute('data-side', 'start');
1159
+
1160
+ fireEvent.keyDown(dialog, {key: 'Escape'});
1161
+ // Mid-exit: the live prop is now 'end', the anchor must still be 'start'.
1162
+ expect(dialog).toHaveAttribute('data-side', 'start');
1163
+ });
1164
+ });
1165
+
1166
+ describe('container padding isolation', () => {
1167
+ it('resets container padding custom properties on the root dialog element', () => {
1168
+ render(
1169
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
1170
+ Content
1171
+ </Drawer>,
1172
+ );
1173
+ const dialog = screen.getByRole('dialog');
1174
+ const computed = window.getComputedStyle(dialog);
1175
+ expect(
1176
+ computed.getPropertyValue('--container-padding-inline-start'),
1177
+ ).toBe('0px');
1178
+ expect(computed.getPropertyValue('--container-padding-inline-end')).toBe(
1179
+ '0px',
1180
+ );
1181
+ expect(computed.getPropertyValue('--container-padding-block-start')).toBe(
1182
+ '0px',
1183
+ );
1184
+ expect(computed.getPropertyValue('--container-padding-block-end')).toBe(
1185
+ '0px',
1186
+ );
1187
+ });
1188
+ });
1189
+
1190
+ describe('container padding', () => {
1191
+ // StyleX lowers the block edges to their physical properties. block-end is
1192
+ // asserted separately: its applied edge composes the safe-area inset.
1193
+ const EDGES = [
1194
+ ['inline-start', 'padding-inline-start'],
1195
+ ['inline-end', 'padding-inline-end'],
1196
+ ['block-start', 'padding-top'],
1197
+ ] as const;
1198
+ // StyleX strips the space after the var() fallback comma.
1199
+ const SAFE_AREA_BLOCK_END =
1200
+ 'calc(var(--container-padding-block-end,0px) + env(safe-area-inset-bottom,0px))';
1201
+
1202
+ function renderWithPadding(padding?: 0 | 2) {
1203
+ render(
1204
+ <Drawer
1205
+ isOpen
1206
+ onOpenChange={() => {}}
1207
+ label="Details"
1208
+ padding={padding}>
1209
+ <span data-testid="drawer-child">Content</span>
1210
+ </Drawer>,
1211
+ );
1212
+ // The content area is the child's parent: the scrolling box inside the
1213
+ // panel, not the panel itself.
1214
+ return getComputedStyle(
1215
+ screen.getByTestId('drawer-child').parentElement!,
1216
+ );
1217
+ }
1218
+
1219
+ it("defaults to Dialog's --spacing-4 inset and publishes the theme chain", () => {
1220
+ const computed = renderWithPadding();
1221
+ for (const [edge, property] of EDGES) {
1222
+ const published = computed
1223
+ .getPropertyValue(`--container-padding-${edge}`)
1224
+ .replace(/\s+/g, '');
1225
+ expect(published, edge).toContain(
1226
+ `var(--astryx-drawer-padding-${edge}`,
1227
+ );
1228
+ // No theme padding resolves to --spacing-4, as in Dialog.
1229
+ expect(published, edge).toContain(
1230
+ 'var(--astryx-drawer-padding,var(--spacing-4))',
1231
+ );
1232
+ // Applied padding reads the published value, so bleed children
1233
+ // subtract exactly the inset they sit in.
1234
+ expect(computed.getPropertyValue(property), edge).toBe(
1235
+ `var(--container-padding-${edge})`,
1236
+ );
1237
+ }
1238
+ // block-end publishes the same chain, while the APPLIED edge adds the
1239
+ // home-indicator safe area on top of the container inset.
1240
+ const publishedEnd = computed
1241
+ .getPropertyValue('--container-padding-block-end')
1242
+ .replace(/\s+/g, '');
1243
+ expect(publishedEnd).toContain('var(--astryx-drawer-padding-block-end');
1244
+ expect(publishedEnd).toContain(
1245
+ 'var(--astryx-drawer-padding,var(--spacing-4))',
1246
+ );
1247
+ expect(computed.getPropertyValue('padding-bottom')).toBe(
1248
+ SAFE_AREA_BLOCK_END,
1249
+ );
1250
+ // The Layout insets read the same chain, so a Layout inside the drawer
1251
+ // lines its regions up on the drawer's inset, as in Dialog.
1252
+ for (const name of [
1253
+ '--layout-padding-outer-x',
1254
+ '--layout-padding-outer-y',
1255
+ '--layout-padding-inner-x',
1256
+ '--layout-padding-inner-y',
1257
+ ]) {
1258
+ const value = computed.getPropertyValue(name).replace(/\s+/g, '');
1259
+ expect(value, name).toContain(
1260
+ 'var(--astryx-drawer-padding,var(--spacing-4))',
1261
+ );
1262
+ }
1263
+ });
1264
+
1265
+ it.each([0, 2] as const)(
1266
+ 'applies and publishes an explicit padding step (%s)',
1267
+ padding => {
1268
+ const computed = renderWithPadding(padding);
1269
+ const expected = `var(--spacing-${padding})`;
1270
+ for (const [edge, property] of EDGES) {
1271
+ expect(
1272
+ computed.getPropertyValue(`--container-padding-${edge}`),
1273
+ edge,
1274
+ ).toBe(expected);
1275
+ expect(computed.getPropertyValue(property), edge).toBe(expected);
1276
+ }
1277
+ expect(computed.getPropertyValue('--container-padding-block-end')).toBe(
1278
+ expected,
1279
+ );
1280
+ expect(computed.getPropertyValue('padding-bottom')).toBe(
1281
+ SAFE_AREA_BLOCK_END,
1282
+ );
1283
+ expect(computed.getPropertyValue('--layout-padding-inner-x')).toBe(
1284
+ expected,
1285
+ );
1286
+ },
1287
+ );
1288
+
1289
+ it('keeps the panel itself unpadded and free of a padding attribute', () => {
1290
+ renderWithPadding(2);
1291
+ const dialog = screen.getByRole('dialog');
1292
+ expect(dialog.hasAttribute('padding')).toBe(false);
1293
+ expect(
1294
+ getComputedStyle(dialog).getPropertyValue(
1295
+ '--container-padding-inline-start',
1296
+ ),
1297
+ ).toBe('0px');
1298
+ });
1299
+ });
1300
+ });