@astryxdesign/core 0.7.2-canary.fe2c655 → 0.7.2

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 (297) hide show
  1. package/CHANGELOG.md +156 -2
  2. package/README.md +4 -4
  3. package/dist/AppShell/AppShell.d.ts.map +1 -1
  4. package/dist/AppShell/AppShell.js +40 -1
  5. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  6. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  7. package/dist/AspectRatio/AspectRatio.js +44 -3
  8. package/dist/Avatar/Avatar.d.ts.map +1 -1
  9. package/dist/Avatar/Avatar.js +8 -1
  10. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  11. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  12. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  13. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  14. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  15. package/dist/Chat/ChatLayoutScrollButton.js +1 -0
  16. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  18. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  19. package/dist/CheckboxList/CheckboxList.d.ts +1 -1
  20. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  21. package/dist/CheckboxList/CheckboxList.js +6 -2
  22. package/dist/CheckboxList/CheckboxListItem.d.ts +9 -1
  23. package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
  24. package/dist/CheckboxList/CheckboxListItem.js +4 -2
  25. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  26. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  27. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  28. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  29. package/dist/ContextMenu/ContextMenu.js +5 -2
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +20 -8
  32. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenu.js +84 -113
  35. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +1 -1
  36. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  37. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  38. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  39. package/dist/DropdownMenu/DropdownMenuDivider.d.ts +5 -3
  40. package/dist/DropdownMenu/DropdownMenuDivider.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuDivider.js +7 -3
  42. package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
  43. package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
  44. package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
  45. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  46. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  47. package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
  48. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +24 -0
  49. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -0
  50. package/dist/DropdownMenu/lazyMenuBottomSheet.js +46 -0
  51. package/dist/DropdownMenu/menuRegionStops.d.ts +38 -0
  52. package/dist/DropdownMenu/menuRegionStops.d.ts.map +1 -0
  53. package/dist/DropdownMenu/menuRegionStops.js +131 -0
  54. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  55. package/dist/EmptyState/EmptyState.js +4 -10
  56. package/dist/Field/Field.d.ts +1 -1
  57. package/dist/Field/Field.d.ts.map +1 -1
  58. package/dist/Field/Field.js +8 -4
  59. package/dist/FormLayout/FormLayout.d.ts +5 -4
  60. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  61. package/dist/FormLayout/FormLayout.js +1 -1
  62. package/dist/FormLayout/FormLayoutContext.d.ts +3 -3
  63. package/dist/FormLayout/FormLayoutContext.js +3 -3
  64. package/dist/Grid/Grid.d.ts +8 -7
  65. package/dist/Grid/Grid.d.ts.map +1 -1
  66. package/dist/Grid/Grid.js +5 -4
  67. package/dist/Grid/GridSpan.d.ts +1 -1
  68. package/dist/Grid/GridSpan.d.ts.map +1 -1
  69. package/dist/Grid/GridSpan.js +1 -0
  70. package/dist/Heading/Heading.d.ts.map +1 -1
  71. package/dist/Heading/Heading.js +4 -2
  72. package/dist/Item/Item.d.ts +6 -2
  73. package/dist/Item/Item.d.ts.map +1 -1
  74. package/dist/Item/Item.js +19 -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/parser.d.ts.map +1 -1
  89. package/dist/Markdown/parser.js +8 -2
  90. package/dist/Markdown/plugins/headingLinks.d.ts.map +1 -1
  91. package/dist/Markdown/plugins/headingLinks.js +9 -1
  92. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  93. package/dist/MetadataList/MetadataList.js +5 -1
  94. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  95. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  96. package/dist/MobileNav/MobileNav.d.ts +9 -0
  97. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  98. package/dist/MobileNav/MobileNav.js +30 -3
  99. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  100. package/dist/PowerSearch/PowerSearch.js +5 -0
  101. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  102. package/dist/PowerSearch/PowerSearchValueEditor.js +45 -11
  103. package/dist/RadioList/RadioList.d.ts +1 -1
  104. package/dist/RadioList/RadioList.d.ts.map +1 -1
  105. package/dist/RadioList/RadioList.js +6 -2
  106. package/dist/RadioList/RadioListItem.d.ts +9 -1
  107. package/dist/RadioList/RadioListItem.d.ts.map +1 -1
  108. package/dist/RadioList/RadioListItem.js +2 -0
  109. package/dist/SideNav/SideNavItem.d.ts +8 -1
  110. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  111. package/dist/SideNav/SideNavItem.js +76 -10
  112. package/dist/Text/Text.d.ts.map +1 -1
  113. package/dist/Text/Text.js +4 -2
  114. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  115. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  116. package/dist/ToggleButton/ToggleButton.js +12 -1
  117. package/dist/Token/Token.js +1 -1
  118. package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
  119. package/dist/TopNav/TopNavMegaMenu.js +5 -3
  120. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  121. package/dist/TopNav/TopNavMenu.js +5 -3
  122. package/dist/TreeList/TreeList.d.ts.map +1 -1
  123. package/dist/TreeList/TreeList.js +1 -0
  124. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  125. package/dist/TreeList/TreeListBranches.js +2 -2
  126. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  127. package/dist/TreeList/TreeListItem.js +57 -9
  128. package/dist/astryx.css +27 -4
  129. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  130. package/dist/hooks/useMenuHover.js +18 -13
  131. package/dist/reset.a11y.chromium.spec.js +50 -1
  132. package/dist/utils/plainDate.d.ts.map +1 -1
  133. package/dist/utils/plainDate.js +15 -9
  134. package/docs.mjs +4 -4
  135. package/package.json +4 -4
  136. package/scripts/agent-doc-state.mjs +66 -6
  137. package/scripts/postinstall.mjs +44 -3
  138. package/src/AppShell/AppShell.tsx +45 -1
  139. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  140. package/src/AspectRatio/AspectRatio.tsx +38 -18
  141. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  142. package/src/Avatar/Avatar.doc.mjs +5 -3
  143. package/src/Avatar/Avatar.tsx +11 -2
  144. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  145. package/src/AvatarGroup/AvatarGroupOverflow.test.tsx +24 -1
  146. package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -0
  147. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  148. package/src/Breadcrumbs/Breadcrumbs.test.tsx +30 -1
  149. package/src/Card/Card.doc.mjs +1 -1
  150. package/src/Chat/ChatLayoutScrollButton.test.tsx +13 -0
  151. package/src/Chat/ChatLayoutScrollButton.tsx +4 -2
  152. package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +64 -19
  153. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  154. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  155. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  156. package/src/CheckboxList/CheckboxList.fr10.test.tsx +29 -2
  157. package/src/CheckboxList/CheckboxList.test.tsx +22 -0
  158. package/src/CheckboxList/CheckboxList.tsx +4 -1
  159. package/src/CheckboxList/CheckboxListItem.doc.mjs +16 -0
  160. package/src/CheckboxList/CheckboxListItem.tsx +10 -0
  161. package/src/Code/Code.doc.mjs +1 -0
  162. package/src/CommandPalette/CommandPalette.doc.mjs +2 -0
  163. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  164. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  165. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  166. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +292 -0
  167. package/src/ContextMenu/ContextMenu.doc.mjs +2 -3
  168. package/src/ContextMenu/ContextMenu.test.tsx +12 -2
  169. package/src/ContextMenu/ContextMenu.tsx +4 -1
  170. package/src/ContextMenu/__tests__/ContextMenuAudit.a11y.chromium.spec.ts +438 -0
  171. package/src/Dialog/Dialog.test.tsx +46 -0
  172. package/src/Dialog/Dialog.tsx +20 -7
  173. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  174. package/src/Drawer/Drawer.doc.mjs +1 -0
  175. package/src/DropdownMenu/DropdownMenu.ssr.test.tsx +57 -0
  176. package/src/DropdownMenu/DropdownMenu.test.tsx +79 -6
  177. package/src/DropdownMenu/DropdownMenu.tsx +88 -144
  178. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +1 -1
  179. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +1 -1
  180. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  181. package/src/DropdownMenu/DropdownMenuDivider.doc.mjs +2 -2
  182. package/src/DropdownMenu/DropdownMenuDivider.spec.md +192 -0
  183. package/src/DropdownMenu/DropdownMenuDivider.tsx +9 -5
  184. package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
  185. package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
  186. package/src/DropdownMenu/DropdownMenuLazySheet.test.tsx +126 -0
  187. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +1 -1
  188. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  189. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  190. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
  191. package/src/DropdownMenu/__tests__/DropdownMenuDividerAudit.a11y.chromium.spec.ts +280 -0
  192. package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
  193. package/src/DropdownMenu/__tests__/warmMenuBottomSheet.tsx +43 -0
  194. package/src/DropdownMenu/lazyMenuBottomSheet.ts +71 -0
  195. package/src/DropdownMenu/menuRegionStops.ts +167 -0
  196. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  197. package/src/EmptyState/EmptyState.spec.md +189 -0
  198. package/src/EmptyState/EmptyState.test.tsx +74 -3
  199. package/src/EmptyState/EmptyState.tsx +4 -8
  200. package/src/Field/Field.doc.mjs +14 -8
  201. package/src/Field/Field.spec.md +217 -0
  202. package/src/Field/Field.test.tsx +60 -2
  203. package/src/Field/Field.tsx +17 -8
  204. package/src/Field/FieldLabel.doc.mjs +133 -26
  205. package/src/Field/FieldLabel.spec.md +172 -0
  206. package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
  207. package/src/FormLayout/FormLayout.doc.mjs +6 -3
  208. package/src/FormLayout/FormLayout.spec.md +192 -0
  209. package/src/FormLayout/FormLayout.test.tsx +15 -0
  210. package/src/FormLayout/FormLayout.tsx +7 -6
  211. package/src/FormLayout/FormLayoutContext.ts +3 -3
  212. package/src/Grid/Grid.doc.mjs +7 -1
  213. package/src/Grid/Grid.spec.md +93 -113
  214. package/src/Grid/Grid.test.tsx +22 -0
  215. package/src/Grid/Grid.tsx +10 -9
  216. package/src/Grid/GridSpan.doc.mjs +3 -3
  217. package/src/Grid/GridSpan.tsx +4 -1
  218. package/src/Heading/Heading.test.tsx +57 -0
  219. package/src/Heading/Heading.tsx +6 -2
  220. package/src/Icon/Icon.doc.mjs +36 -0
  221. package/src/Indicator/Indicator.doc.mjs +3 -3
  222. package/src/Item/Item.doc.mjs +12 -8
  223. package/src/Item/Item.test.tsx +61 -0
  224. package/src/Item/Item.tsx +40 -4
  225. package/src/Layout/LayoutHeader.doc.mjs +5 -0
  226. package/src/List/List.doc.mjs +14 -1
  227. package/src/List/List.spec.md +129 -77
  228. package/src/List/List.test.tsx +163 -94
  229. package/src/List/List.tsx +74 -18
  230. package/src/List/ListContext.tsx +9 -3
  231. package/src/List/ListItem.doc.mjs +16 -0
  232. package/src/List/ListItem.tsx +56 -27
  233. package/src/List/index.ts +1 -1
  234. package/src/Markdown/Markdown.tsx +14 -1
  235. package/src/Markdown/parser.ts +8 -2
  236. package/src/Markdown/plugins/headingLinks.ts +9 -1
  237. package/src/MetadataList/MetadataList.doc.mjs +3 -3
  238. package/src/MetadataList/MetadataList.test.tsx +38 -0
  239. package/src/MetadataList/MetadataList.tsx +8 -1
  240. package/src/MetadataList/MetadataListContext.tsx +1 -1
  241. package/src/MobileNav/MobileNav.tsx +30 -3
  242. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +173 -15
  243. package/src/MoreMenu/MoreMenu.test.tsx +5 -1
  244. package/src/PowerSearch/PowerSearch.test.tsx +62 -0
  245. package/src/PowerSearch/PowerSearch.tsx +9 -0
  246. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +71 -0
  247. package/src/PowerSearch/PowerSearchValueEditor.tsx +45 -0
  248. package/src/ProgressBar/ProgressBar.doc.mjs +1 -0
  249. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  250. package/src/RadioList/RadioList.test.tsx +22 -0
  251. package/src/RadioList/RadioList.tsx +4 -1
  252. package/src/RadioList/RadioListItem.doc.mjs +16 -0
  253. package/src/RadioList/RadioListItem.tsx +10 -1
  254. package/src/Resizable/useResizable.doc.mjs +2 -0
  255. package/src/ScrollableArea/ScrollableArea.doc.mjs +1 -1
  256. package/src/SideNav/SideNav.spec.md +227 -0
  257. package/src/SideNav/SideNav.test.tsx +220 -0
  258. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  259. package/src/SideNav/SideNavItem.tsx +99 -17
  260. package/src/Slider/Slider.spec.md +39 -18
  261. package/src/Spinner/Spinner.doc.mjs +1 -1
  262. package/src/TabList/TabList.spec.md +57 -44
  263. package/src/Table/Table.spec.md +6 -1
  264. package/src/Table/plugins/activation/useTableActivation.spec.md +254 -0
  265. package/src/Text/Text.test.tsx +57 -0
  266. package/src/Text/Text.tsx +6 -2
  267. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  268. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  269. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  270. package/src/ToggleButton/ToggleButton.tsx +12 -1
  271. package/src/ToggleButton/ToggleButtonGroup.doc.mjs +4 -0
  272. package/src/Token/Token.tsx +1 -1
  273. package/src/Token/__tests__/TokenRemoveTarget.a11y.chromium.spec.ts +86 -0
  274. package/src/Toolbar/Toolbar.test.tsx +0 -8
  275. package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
  276. package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
  277. package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
  278. package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
  279. package/src/TopNav/TopNavMegaMenu.test.tsx +47 -0
  280. package/src/TopNav/TopNavMegaMenu.tsx +9 -3
  281. package/src/TopNav/TopNavMenu.test.tsx +46 -1
  282. package/src/TopNav/TopNavMenu.tsx +13 -3
  283. package/src/TreeList/TreeList.doc.mjs +1 -0
  284. package/src/TreeList/TreeList.test.tsx +327 -6
  285. package/src/TreeList/TreeList.tsx +5 -1
  286. package/src/TreeList/TreeListBranches.tsx +6 -0
  287. package/src/TreeList/TreeListItem.tsx +88 -13
  288. package/src/docPropLiterals.test.ts +36 -10
  289. package/src/hooks/useMenuHover.ts +18 -13
  290. package/src/postinstall.test.mjs +79 -0
  291. package/src/reset.a11y.chromium.spec.ts +52 -1
  292. package/src/reset.css +9 -29
  293. package/src/reset.test.ts +11 -0
  294. package/src/theme/derivedVarRegistry.test.ts +7 -0
  295. package/src/unicodePropertyEscapes.source-build.test.mjs +69 -0
  296. package/src/utils/plainDate.test.ts +37 -1
  297. package/src/utils/plainDate.ts +15 -5
@@ -96,14 +96,13 @@ export const docs = {
96
96
  name: 'items',
97
97
  type: 'ContextMenuOption[]',
98
98
  description:
99
- 'Array of menu entries. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
100
- required: true,
99
+ 'Data-driven menu entries. Pass exactly one of `items` or `menuContent`. Each entry is one of: an action item `{label, onClick?, icon?, isDisabled?, variant?, items?}` (nested `items` open a flyout in popover presentation and drill into a new view in bottom-sheet presentation; variant `"destructive"` renders it in the error color), a divider `{type: "divider"}`, or a section `{type: "section", title?, items: [...action items]}`.',
101
100
  },
102
101
  {
103
102
  name: 'menuContent',
104
103
  type: 'ReactNode',
105
104
  description:
106
- 'Custom JSX menu content for compound mode. Use instead of items for dynamic or stateful menus.',
105
+ 'Custom JSX menu content for compound mode. Pass exactly one of `menuContent` or `items`.',
107
106
  },
108
107
  {
109
108
  name: 'menuWidth',
@@ -10,7 +10,7 @@
10
10
 
11
11
  import {useRef, useState} from 'react';
12
12
  import {createPortal} from 'react-dom';
13
- import {describe, it, expect, vi, beforeEach} from 'vitest';
13
+ import {describe, it, expect, vi, beforeAll, beforeEach} from 'vitest';
14
14
  import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
16
16
  import {readFileSync} from 'node:fs';
@@ -26,6 +26,11 @@ import {
26
26
  import {DropdownMenuItem} from '../DropdownMenu/DropdownMenuItem';
27
27
  import {Divider} from '../Divider';
28
28
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
29
+ import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet';
30
+
31
+ // The touch sheet is loaded lazily; resolve its modules once up front, so
32
+ // ContextMenu's own lazy sheet renders within the cases' waitFor.
33
+ beforeAll(warmMenuBottomSheet, 20000);
29
34
 
30
35
  function NestedDismissibleLayer({onDismiss}: {onDismiss: () => void}) {
31
36
  const [isOpen, setIsOpen] = useState(true);
@@ -268,7 +273,12 @@ describe('ContextMenu', () => {
268
273
  );
269
274
 
270
275
  fireEvent.contextMenu(screen.getByText('Right-click me'));
271
- await user.click(screen.getByRole('button', {name: 'Move to project'}));
276
+ // The sheet is lazy: its first render suspends once even when the module
277
+ // is already loaded, so wait for the row rather than relying on an
278
+ // earlier case to have rendered a sheet.
279
+ await user.click(
280
+ await screen.findByRole('button', {name: 'Move to project'}),
281
+ );
272
282
 
273
283
  const submenuHeading = screen.getByRole('heading', {
274
284
  name: 'Move to project',
@@ -56,6 +56,7 @@ import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionLi
56
56
  import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
57
57
  import {
58
58
  DropdownMenuContext,
59
+ DropdownMenuRootOpenContext,
59
60
  type DropdownMenuContextValue,
60
61
  } from '../DropdownMenu/DropdownMenuContext';
61
62
  import {
@@ -701,7 +702,9 @@ export function ContextMenu({
701
702
  style,
702
703
  )}>
703
704
  <DropdownMenuContext value={contextValue}>
704
- {resolvedMenuContent}
705
+ <DropdownMenuRootOpenContext value={isOpen}>
706
+ {resolvedMenuContent}
707
+ </DropdownMenuRootOpenContext>
705
708
  </DropdownMenuContext>
706
709
  </div>
707
710
  );
@@ -0,0 +1,438 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ContextMenuAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head ContextMenu stories across pointer/touch presentation, state, theme, direction, and viewport
6
+ * @output Screenshots and fail-closed sensor receipts for the ContextMenu whole-component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a ContextMenu audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Locator, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('context-menu-audit-evidence');
23
+ const WIDE = {width: 1024, height: 768};
24
+ const NARROW = {width: 320, height: 720};
25
+
26
+ type Mode = 'light' | 'dark';
27
+ type Direction = 'ltr' | 'rtl';
28
+ type Theme = 'neutral' | 'butter';
29
+ type Surface = 'menu' | 'dialog';
30
+ type AuditCase = {
31
+ key: string;
32
+ storyId: string;
33
+ mode: Mode;
34
+ direction: Direction;
35
+ theme: Theme;
36
+ viewport: {width: number; height: number};
37
+ surface: Surface;
38
+ triggerText?: string;
39
+ menuName?: string;
40
+ expectedState?: 'default' | 'disabled' | 'sections' | 'selectable';
41
+ };
42
+
43
+ const cases: AuditCase[] = [
44
+ {
45
+ key: 'pointer-neutral-light',
46
+ storyId: 'core-contextmenu--pointer-presentation',
47
+ mode: 'light',
48
+ direction: 'ltr',
49
+ theme: 'neutral',
50
+ viewport: WIDE,
51
+ surface: 'menu',
52
+ menuName: 'Document actions',
53
+ },
54
+ {
55
+ key: 'pointer-neutral-dark',
56
+ storyId: 'core-contextmenu--pointer-presentation',
57
+ mode: 'dark',
58
+ direction: 'ltr',
59
+ theme: 'neutral',
60
+ viewport: WIDE,
61
+ surface: 'menu',
62
+ menuName: 'Document actions',
63
+ },
64
+ {
65
+ key: 'pointer-butter-light',
66
+ storyId: 'core-contextmenu--pointer-presentation',
67
+ mode: 'light',
68
+ direction: 'ltr',
69
+ theme: 'butter',
70
+ viewport: WIDE,
71
+ surface: 'menu',
72
+ menuName: 'Document actions',
73
+ },
74
+ {
75
+ key: 'pointer-butter-dark',
76
+ storyId: 'core-contextmenu--pointer-presentation',
77
+ mode: 'dark',
78
+ direction: 'ltr',
79
+ theme: 'butter',
80
+ viewport: WIDE,
81
+ surface: 'menu',
82
+ menuName: 'Document actions',
83
+ },
84
+ {
85
+ key: 'pointer-rtl-light',
86
+ storyId: 'core-contextmenu--pointer-presentation',
87
+ mode: 'light',
88
+ direction: 'rtl',
89
+ theme: 'neutral',
90
+ viewport: WIDE,
91
+ surface: 'menu',
92
+ menuName: 'Document actions',
93
+ },
94
+ {
95
+ key: 'pointer-rtl-dark',
96
+ storyId: 'core-contextmenu--pointer-presentation',
97
+ mode: 'dark',
98
+ direction: 'rtl',
99
+ theme: 'neutral',
100
+ viewport: WIDE,
101
+ surface: 'menu',
102
+ menuName: 'Document actions',
103
+ },
104
+ {
105
+ key: 'pointer-narrow-light',
106
+ storyId: 'core-contextmenu--pointer-presentation',
107
+ mode: 'light',
108
+ direction: 'ltr',
109
+ theme: 'neutral',
110
+ viewport: NARROW,
111
+ surface: 'menu',
112
+ menuName: 'Document actions',
113
+ },
114
+ {
115
+ key: 'pointer-narrow-dark',
116
+ storyId: 'core-contextmenu--pointer-presentation',
117
+ mode: 'dark',
118
+ direction: 'ltr',
119
+ theme: 'neutral',
120
+ viewport: NARROW,
121
+ surface: 'menu',
122
+ menuName: 'Document actions',
123
+ },
124
+ {
125
+ key: 'disabled-item-light',
126
+ storyId: 'core-contextmenu--with-disabled-items',
127
+ mode: 'light',
128
+ direction: 'ltr',
129
+ theme: 'neutral',
130
+ viewport: WIDE,
131
+ surface: 'menu',
132
+ triggerText: 'Right-click this area',
133
+ expectedState: 'disabled',
134
+ },
135
+ {
136
+ key: 'sections-dark',
137
+ storyId: 'core-contextmenu--with-sections',
138
+ mode: 'dark',
139
+ direction: 'ltr',
140
+ theme: 'neutral',
141
+ viewport: WIDE,
142
+ surface: 'menu',
143
+ triggerText: 'Right-click for grouped actions',
144
+ expectedState: 'sections',
145
+ },
146
+ {
147
+ key: 'selectable-light',
148
+ storyId: 'core-contextmenu--with-selectable-items',
149
+ mode: 'light',
150
+ direction: 'ltr',
151
+ theme: 'neutral',
152
+ viewport: WIDE,
153
+ surface: 'menu',
154
+ triggerText: 'Right-click for selectable items',
155
+ expectedState: 'selectable',
156
+ },
157
+ {
158
+ key: 'selectable-dark',
159
+ storyId: 'core-contextmenu--with-selectable-items',
160
+ mode: 'dark',
161
+ direction: 'ltr',
162
+ theme: 'neutral',
163
+ viewport: WIDE,
164
+ surface: 'menu',
165
+ triggerText: 'Right-click for selectable items',
166
+ expectedState: 'selectable',
167
+ },
168
+ {
169
+ key: 'sheet-light',
170
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
171
+ mode: 'light',
172
+ direction: 'ltr',
173
+ theme: 'neutral',
174
+ viewport: NARROW,
175
+ surface: 'dialog',
176
+ },
177
+ {
178
+ key: 'sheet-dark',
179
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
180
+ mode: 'dark',
181
+ direction: 'ltr',
182
+ theme: 'neutral',
183
+ viewport: NARROW,
184
+ surface: 'dialog',
185
+ },
186
+ {
187
+ key: 'sheet-rtl',
188
+ storyId: 'core-contextmenu--bottom-sheet-presentation',
189
+ mode: 'light',
190
+ direction: 'rtl',
191
+ theme: 'neutral',
192
+ viewport: NARROW,
193
+ surface: 'dialog',
194
+ },
195
+ {
196
+ key: 'inline-light',
197
+ storyId: 'core-contextmenu--inline-trigger',
198
+ mode: 'light',
199
+ direction: 'ltr',
200
+ theme: 'neutral',
201
+ viewport: WIDE,
202
+ surface: 'menu',
203
+ triggerText: 'T123',
204
+ menuName: 'Task actions',
205
+ },
206
+ {
207
+ key: 'inline-dark',
208
+ storyId: 'core-contextmenu--inline-trigger',
209
+ mode: 'dark',
210
+ direction: 'ltr',
211
+ theme: 'neutral',
212
+ viewport: WIDE,
213
+ surface: 'menu',
214
+ triggerText: 'T123',
215
+ menuName: 'Task actions',
216
+ },
217
+ {
218
+ key: 'scroll-container-light',
219
+ storyId: 'core-contextmenu--in-scroll-container',
220
+ mode: 'light',
221
+ direction: 'ltr',
222
+ theme: 'neutral',
223
+ viewport: WIDE,
224
+ surface: 'menu',
225
+ triggerText: 'Right-click this area',
226
+ },
227
+ ];
228
+
229
+ test.describe.configure({mode: 'serial', retries: 0});
230
+ let server: StaticServer;
231
+ let checkoutSha: string;
232
+ let storybookSha: string;
233
+
234
+ function storyUrl(item: AuditCase) {
235
+ return `${server.origin}/iframe.html?id=${item.storyId}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
236
+ }
237
+
238
+ async function settle(page: Page) {
239
+ await holdMotionStill(page);
240
+ await page.evaluate(async () => document.fonts.ready);
241
+ await page.waitForTimeout(40);
242
+ }
243
+
244
+ async function openSurface(
245
+ page: Page,
246
+ item: AuditCase,
247
+ ): Promise<{surface: Locator; pointer: {x: number; y: number}}> {
248
+ const trigger =
249
+ item.storyId === 'core-contextmenu--pointer-presentation'
250
+ ? page.getByTestId('pointer-context-menu')
251
+ : item.storyId === 'core-contextmenu--bottom-sheet-presentation'
252
+ ? page.getByTestId('bottom-sheet-context-menu')
253
+ : item.triggerText
254
+ ? page.getByText(item.triggerText, {exact: true}).first()
255
+ : null;
256
+ if (trigger === null) {
257
+ throw new Error(`No trigger configured for ${item.key}`);
258
+ }
259
+ await trigger.scrollIntoViewIfNeeded();
260
+ const triggerBox = await trigger.boundingBox();
261
+ if (triggerBox === null) {
262
+ throw new Error(`Trigger has no rendered box for ${item.key}`);
263
+ }
264
+ const pointer = {
265
+ x: triggerBox.x + Math.min(40, triggerBox.width / 2),
266
+ y: triggerBox.y + Math.min(40, triggerBox.height / 2),
267
+ };
268
+ await page.mouse.click(pointer.x, pointer.y, {button: 'right'});
269
+ const surface =
270
+ item.surface === 'dialog'
271
+ ? page.getByRole('dialog').last()
272
+ : item.menuName
273
+ ? page.getByRole('menu', {name: item.menuName}).last()
274
+ : page.getByRole('menu').last();
275
+ await expect(surface).toBeVisible();
276
+ return {surface, pointer};
277
+ }
278
+
279
+ test.beforeAll(async () => {
280
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
281
+ fs.mkdirSync(OUTPUT, {recursive: true});
282
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
283
+ encoding: 'utf8',
284
+ }).trim();
285
+ const expected = process.env.ASTRYX_HEAD_SHA;
286
+ if (expected && expected !== checkoutSha) {
287
+ throw new Error(
288
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
289
+ );
290
+ }
291
+ server = await serveStorybook(
292
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
293
+ );
294
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
295
+ if (!response.ok) {
296
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
297
+ }
298
+ storybookSha = (await response.text()).trim();
299
+ if (storybookSha !== checkoutSha) {
300
+ throw new Error(
301
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
302
+ );
303
+ }
304
+ });
305
+
306
+ test.afterAll(async () => {
307
+ await server?.close();
308
+ });
309
+
310
+ for (const item of cases) {
311
+ test(item.key, async ({browser}) => {
312
+ const context = await browser.newContext({
313
+ viewport: item.viewport,
314
+ colorScheme: item.mode,
315
+ reducedMotion: 'reduce',
316
+ });
317
+ const page = await context.newPage();
318
+ const pageErrors: string[] = [];
319
+ page.on('pageerror', error => pageErrors.push(String(error)));
320
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
321
+ await page.locator('#storybook-root').waitFor();
322
+ const {surface, pointer} = await openSurface(page, item);
323
+ await settle(page);
324
+
325
+ const menuItems = page.locator(
326
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
327
+ );
328
+ if (item.surface === 'menu') {
329
+ await expect(menuItems.first()).toBeVisible();
330
+ } else {
331
+ await expect(surface.getByRole('button').first()).toBeVisible();
332
+ }
333
+ if (item.expectedState === 'disabled') {
334
+ await expect(
335
+ page.locator('[role="menuitem"][aria-disabled="true"]'),
336
+ ).toHaveCount(1);
337
+ }
338
+ if (item.expectedState === 'sections') {
339
+ await expect(page.getByRole('group')).toHaveCount(2);
340
+ }
341
+ if (item.expectedState === 'selectable') {
342
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(2);
343
+ await expect(page.getByRole('menuitemradio')).toHaveCount(3);
344
+ await expect(
345
+ page.locator('[role="menuitemradio"][aria-checked="true"]'),
346
+ ).toHaveCount(1);
347
+ }
348
+
349
+ const observed = await surface.evaluate(element => {
350
+ const box = element.getBoundingClientRect();
351
+ return {
352
+ theme: document
353
+ .querySelector('[data-astryx-theme]')
354
+ ?.getAttribute('data-astryx-theme'),
355
+ mode: document.documentElement.getAttribute('data-theme'),
356
+ direction: getComputedStyle(element).direction,
357
+ viewport: {width: innerWidth, height: innerHeight},
358
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
359
+ horizontalOverflow:
360
+ document.documentElement.scrollWidth > innerWidth + 1,
361
+ fonts: document.fonts.status,
362
+ surfaceRole:
363
+ element.getAttribute('role') ??
364
+ (element instanceof HTMLDialogElement ? 'dialog' : null),
365
+ surfaceName:
366
+ element.getAttribute('aria-label') ??
367
+ element.getAttribute('aria-labelledby'),
368
+ geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
369
+ };
370
+ });
371
+ const geometryPass =
372
+ observed.geometry !== null &&
373
+ observed.geometry.width > 0 &&
374
+ observed.geometry.height > 0;
375
+ let pointerPlacementPass = true;
376
+ if (item.storyId === 'core-contextmenu--pointer-presentation') {
377
+ const surfaceBox = await surface.boundingBox();
378
+ pointerPlacementPass =
379
+ surfaceBox !== null &&
380
+ Math.abs(
381
+ (item.direction === 'rtl'
382
+ ? surfaceBox.x + surfaceBox.width
383
+ : surfaceBox.x) - pointer.x,
384
+ ) <= 24 &&
385
+ Math.abs(surfaceBox.y - pointer.y) <= 24;
386
+ }
387
+
388
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
389
+ const image = `${item.key}.png`;
390
+ const receipt = {
391
+ schemaVersion: 1,
392
+ expected: {
393
+ build: checkoutSha,
394
+ storyId: item.storyId,
395
+ mode: item.mode,
396
+ direction: item.direction,
397
+ theme: item.theme,
398
+ viewport: item.viewport,
399
+ surface: item.surface,
400
+ state: item.expectedState ?? 'default',
401
+ },
402
+ observed: {
403
+ build: storybookSha,
404
+ ...observed,
405
+ menuItemCount: await menuItems.count(),
406
+ pageErrors,
407
+ },
408
+ image: {
409
+ file: image,
410
+ sha256: createHash('sha256').update(png).digest('hex'),
411
+ width: png.readUInt32BE(16),
412
+ height: png.readUInt32BE(20),
413
+ },
414
+ checks: {geometryPass, pointerPlacementPass},
415
+ passed:
416
+ storybookSha === checkoutSha &&
417
+ pageErrors.length === 0 &&
418
+ observed.theme === item.theme &&
419
+ observed.mode === item.mode &&
420
+ observed.direction === item.direction &&
421
+ observed.viewport.width === item.viewport.width &&
422
+ observed.viewport.height === item.viewport.height &&
423
+ observed.reducedMotion &&
424
+ !observed.horizontalOverflow &&
425
+ observed.fonts === 'loaded' &&
426
+ observed.surfaceRole === item.surface &&
427
+ geometryPass &&
428
+ pointerPlacementPass,
429
+ };
430
+ fs.writeFileSync(path.join(OUTPUT, image), png);
431
+ fs.writeFileSync(
432
+ path.join(OUTPUT, `${item.key}.sensors.json`),
433
+ `${JSON.stringify(receipt, null, 2)}\n`,
434
+ );
435
+ expect(receipt.passed).toBe(true);
436
+ await context.close();
437
+ });
438
+ }
@@ -110,6 +110,52 @@ beforeEach(() => {
110
110
  });
111
111
 
112
112
  describe('Dialog', () => {
113
+ it('restores focus to the opener when content mounts in the open commit', () => {
114
+ function Harness({
115
+ open,
116
+ onOpenChange,
117
+ }: {
118
+ open: boolean;
119
+ onOpenChange: (v: boolean) => void;
120
+ }) {
121
+ return (
122
+ <>
123
+ <button type="button" onClick={() => onOpenChange(true)}>
124
+ Open
125
+ </button>
126
+ <Dialog isOpen={open} onOpenChange={onOpenChange}>
127
+ {open ? (
128
+ <div>
129
+ <DialogHeader title="Review" />
130
+ <p>Body</p>
131
+ </div>
132
+ ) : null}
133
+ </Dialog>
134
+ </>
135
+ );
136
+ }
137
+
138
+ let open = false;
139
+ const setOpen = (v: boolean) => {
140
+ open = v;
141
+ };
142
+ const harness = render(<Harness open={open} onOpenChange={setOpen} />);
143
+ const opener = screen.getByRole('button', {name: 'Open'});
144
+ opener.focus();
145
+
146
+ fireEvent.click(opener);
147
+ harness.rerender(<Harness open={open} onOpenChange={setOpen} />);
148
+
149
+ // The freshly mounted header title is the focused screen reader anchor
150
+ // while the dialog is open...
151
+ expect(document.activeElement).toBe(screen.getByText('Review'));
152
+
153
+ // ...but closing restores the element that opened the dialog, not the
154
+ // (now unmounted) title.
155
+ harness.rerender(<Harness open={false} onOpenChange={setOpen} />);
156
+ expect(document.activeElement).toBe(opener);
157
+ });
158
+
113
159
  it('renders when isOpen is true', () => {
114
160
  render(
115
161
  <Dialog isOpen={true} onOpenChange={() => {}}>
@@ -32,6 +32,7 @@ import {
32
32
  useCallback,
33
33
  useEffect,
34
34
  useId,
35
+ useLayoutEffect,
35
36
  useMemo,
36
37
  useRef,
37
38
  type ReactNode,
@@ -505,12 +506,26 @@ export function Dialog({
505
506
 
506
507
  const mergedDialogRef = useMergedRefs(ref, attachDialog);
507
508
 
508
- // Capture the element that was focused when the dialog opened,
509
- // for directional animation origin and focus restoration on close.
509
+ // Capture the element that was focused when the dialog opened, for
510
+ // directional animation origin and focus restoration on close.
510
511
  const triggerElementRef = useRef<HTMLElement | null>(null);
511
512
 
512
513
  useInteractionModalityTracking();
513
514
 
515
+ // This runs in a layout effect so the capture lands before any descendant's
516
+ // passive mount effect: all layout effects run before any passive effect,
517
+ // while a child's passive effect runs before this parent's passive effect.
518
+ // Content that mounts in the open commit — e.g. a DialogHeader focusing its
519
+ // own title on mount — would otherwise move document.activeElement inside
520
+ // the dialog before the capture ran, and closing would then restore focus
521
+ // to a detached node, stranding the user's focus on <body>.
522
+ useLayoutEffect(() => {
523
+ if (isInline || !isOpen) {
524
+ return;
525
+ }
526
+ triggerElementRef.current = document.activeElement as HTMLElement | null;
527
+ }, [isOpen, isInline]);
528
+
514
529
  // Derive dismissal behavior from purpose
515
530
  const allowEscape = purpose !== 'required';
516
531
  const allowBackdropClick = purpose === 'info';
@@ -526,11 +541,9 @@ export function Dialog({
526
541
  }
527
542
 
528
543
  if (isOpen) {
529
- // Capture the currently focused element as the trigger — used for
530
- // directional animation origin and focus restoration on close.
531
- triggerElementRef.current = document.activeElement as HTMLElement | null;
532
-
533
- // Set directional CSS custom properties before opening
544
+ // Set directional CSS custom properties before opening. The trigger was
545
+ // captured by the layout effect above, before any descendant effect
546
+ // could move focus.
534
547
  const trigger = triggerElementRef.current;
535
548
  if (trigger && trigger !== document.body) {
536
549
  const dir = getDialogDirection(trigger);
@@ -25,18 +25,4 @@ export const DIALOG_MODAL_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
25
25
  reason:
26
26
  'This migration records the existing focus-order violation without changing the overlapping Dialog implementation path.',
27
27
  },
28
- {
29
- expectation: 'modal-dialog.focus.restored',
30
- binding: 'Dialog',
31
- state: 'conditional-content-focus-restoration',
32
- evidenceLayer: 'real-browser',
33
- failureEquals:
34
- 'closing the modal dialog did not return focus to its still-available invoker',
35
- standardsReference:
36
- 'Astryx component:Dialog FR4 — closing a native Dialog must return focus to its still-connected invoker when it can receive focus.',
37
- userImpact:
38
- 'When focus-moving content mounts only while the dialog is open, closing can leave the user at the document body instead of the control that opened the task.',
39
- reason:
40
- 'This migration records the existing restoration violation without changing the overlapping Dialog implementation path.',
41
- },
42
28
  ];
@@ -55,6 +55,7 @@ export const docs = {
55
55
  'sidebar',
56
56
  'dialog',
57
57
  'side drawer',
58
+ 'sidesheet',
58
59
  ],
59
60
  // No isInline docs-preview prop (the drawer has no inline containment), so
60
61
  // this is the overlay preview: closed on load with an open trigger, knobs