@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
@@ -0,0 +1,57 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenu.ssr.test.tsx
5
+ * @input DropdownMenu server markup and React hydration
6
+ * @output Proves a bottom-sheet DropdownMenu hydrates without a recoverable
7
+ * error while its on-demand sheet has not loaded (spec:AST-013 FR9)
8
+ * @position SSR acceptance test for DropdownMenu
9
+ */
10
+
11
+ import {act} from 'react';
12
+ import {afterEach, describe, expect, it, vi} from 'vitest';
13
+ import {hydrateRoot} from 'react-dom/client';
14
+ import {renderToString} from 'react-dom/server';
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ document.body.replaceChildren();
19
+ });
20
+
21
+ async function serverRenderThenHydrate(): Promise<unknown[]> {
22
+ // A fresh module graph is a fresh server process: the sheet has not loaded.
23
+ vi.resetModules();
24
+ const {DropdownMenu} = await import('./DropdownMenu');
25
+ const tree = (
26
+ <DropdownMenu
27
+ button={{label: 'Actions'}}
28
+ presentation="bottom-sheet"
29
+ items={[{label: 'Edit project'}, {label: 'Delete'}]}
30
+ />
31
+ );
32
+ const container = document.createElement('div');
33
+ container.innerHTML = renderToString(tree);
34
+ document.body.appendChild(container);
35
+
36
+ vi.spyOn(console, 'error').mockImplementation(() => {});
37
+ const recoverableErrors: unknown[] = [];
38
+ let root!: ReturnType<typeof hydrateRoot>;
39
+ await act(async () => {
40
+ root = hydrateRoot(container, tree, {
41
+ onRecoverableError: error => recoverableErrors.push(error),
42
+ });
43
+ });
44
+ await act(async () => {
45
+ root.unmount();
46
+ });
47
+ container.remove();
48
+ return recoverableErrors;
49
+ }
50
+
51
+ describe('DropdownMenu — SSR', () => {
52
+ it('hydrates a bottom-sheet menu cleanly on the first renders of a server process', async () => {
53
+ expect(await serverRenderThenHydrate()).toEqual([]);
54
+ expect(await serverRenderThenHydrate()).toEqual([]);
55
+ expect(await serverRenderThenHydrate()).toEqual([]);
56
+ }, 30000);
57
+ });
@@ -9,21 +9,31 @@
9
9
  * SYNC: When DropdownMenu.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
12
+ import {
13
+ describe,
14
+ it,
15
+ expect,
16
+ vi,
17
+ beforeAll,
18
+ beforeEach,
19
+ afterEach,
20
+ } from 'vitest';
13
21
  import {readFileSync} from 'node:fs';
14
22
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
15
23
  import userEvent from '@testing-library/user-event';
16
- import {useState} from 'react';
24
+ import {createRef, useState} from 'react';
17
25
  import * as stylex from '@stylexjs/stylex';
18
26
  import {DropdownMenu, type DropdownMenuTriggerProps} from './DropdownMenu';
19
27
  import {DropdownMenuItem} from './DropdownMenuItem';
20
28
  import {DropdownMenuDivider} from './DropdownMenuDivider';
21
29
  import {DropdownMenuGroup} from './DropdownMenuGroup';
30
+ import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet';
22
31
  import {DropdownMenuSubMenu} from './DropdownMenuSubMenu';
23
32
  import {Divider} from '../Divider';
24
33
  import {rtlStyles} from '../utils';
25
34
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
26
35
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
36
+ import {spacingVars} from '../theme/tokens.stylex';
27
37
 
28
38
  // Mock showPopover and hidePopover methods since they're not implemented in jsdom
29
39
  beforeEach(() => {
@@ -73,6 +83,10 @@ beforeEach(() => {
73
83
  };
74
84
  });
75
85
 
86
+ // The touch sheet is loaded lazily; resolve it once so sheets below render
87
+ // synchronously.
88
+ beforeAll(warmMenuBottomSheet, 20000);
89
+
76
90
  describe('DropdownMenu', () => {
77
91
  it('renders trigger button with label', () => {
78
92
  render(
@@ -1553,10 +1567,9 @@ describe('DropdownMenuItem host attributes', () => {
1553
1567
 
1554
1568
  describe('DropdownMenuGroup (compound mode)', () => {
1555
1569
  it('a focusable node in the heading is not reachable by the arrow keys', async () => {
1556
- // A heading is not a menu row, so a control inside one lands in the
1557
- // group but outside the roving focus order. The type does not prevent
1558
- // this — a rich heading is a legitimate need — so the behavior is
1559
- // pinned rather than discovered.
1570
+ // The public ReactNode type permits a focusable descendant, but the menu's
1571
+ // roving focus model cannot reach it. Pin that shipped gap while the public
1572
+ // title contract remains unsettled.
1560
1573
  const user = userEvent.setup();
1561
1574
  render(
1562
1575
  <DropdownMenu button={{label: 'Actions'}}>
@@ -2508,6 +2521,66 @@ describe('DropdownMenu data/compound parity', () => {
2508
2521
  expect(fromCompound).toHaveClass('astryx-dropdown-menu-divider');
2509
2522
  });
2510
2523
 
2524
+ it('forwards host attributes to a compound divider while keeping its role', () => {
2525
+ render(
2526
+ <DropdownMenu button={{label: 'Actions'}}>
2527
+ <DropdownMenuItem label="Edit" />
2528
+ <DropdownMenuDivider
2529
+ id="menu-sep"
2530
+ data-testid="menu-separator"
2531
+ aria-label="Danger zone"
2532
+ // @ts-expect-error role is component-owned
2533
+ role="presentation"
2534
+ />
2535
+ <DropdownMenuItem label="Delete" />
2536
+ </DropdownMenu>,
2537
+ );
2538
+ const separator = screen.getByTestId('menu-separator');
2539
+ expect(separator).toHaveAttribute('id', 'menu-sep');
2540
+ expect(separator).toHaveAttribute('aria-label', 'Danger zone');
2541
+ expect(separator).toHaveAttribute('role', 'separator');
2542
+ expect(separator).toHaveClass('astryx-dropdown-menu-divider');
2543
+ });
2544
+
2545
+ it('composes the complete public root surface on the separator', () => {
2546
+ const ref = createRef<HTMLDivElement>();
2547
+ const onClick = vi.fn();
2548
+ const customStyles = stylex.create({
2549
+ divider: {marginBlock: spacingVars['--spacing-5']},
2550
+ });
2551
+ const customClass = stylex
2552
+ .props(customStyles.divider)
2553
+ .className!.split(' ')
2554
+ .pop()!;
2555
+
2556
+ render(
2557
+ <DropdownMenu button={{label: 'Actions'}}>
2558
+ <DropdownMenuItem label="Edit" />
2559
+ <DropdownMenuDivider
2560
+ ref={ref}
2561
+ data-testid="menu-separator"
2562
+ className="consumer-divider"
2563
+ style={{backgroundColor: 'rgb(1, 2, 3)'}}
2564
+ xstyle={customStyles.divider}
2565
+ onClick={onClick}
2566
+ />
2567
+ <DropdownMenuItem label="Delete" />
2568
+ </DropdownMenu>,
2569
+ );
2570
+
2571
+ const separator = screen.getByTestId('menu-separator');
2572
+ expect(ref.current).toBe(separator);
2573
+ expect(separator).toHaveClass(
2574
+ 'astryx-dropdown-menu-divider',
2575
+ 'astryx-divider',
2576
+ 'consumer-divider',
2577
+ customClass,
2578
+ );
2579
+ expect(separator).toHaveStyle({backgroundColor: 'rgb(1, 2, 3)'});
2580
+ fireEvent.click(separator);
2581
+ expect(onClick).toHaveBeenCalledTimes(1);
2582
+ });
2583
+
2511
2584
  it('skips a compound divider in the arrow-key order', async () => {
2512
2585
  const user = userEvent.setup();
2513
2586
  render(
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file DropdownMenu.tsx
7
- * @input Uses React, StyleX, usePopover, MenuBottomSheet, Button, List,
7
+ * @input Uses React, StyleX, usePopover, MenuBottomSheet (lazy), Button, List,
8
8
  * useListFocus, and the shared viewport-safe menu-width resolver
9
9
  * @output Exports DropdownMenu with caller-selected popover or bottom-sheet
10
10
  * presentation
@@ -60,6 +60,7 @@ import {
60
60
  } from './menuItemRoles';
61
61
  import {
62
62
  DropdownMenuContext,
63
+ DropdownMenuRootOpenContext,
63
64
  type DropdownMenuContextValue,
64
65
  } from './DropdownMenuContext';
65
66
  import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
@@ -71,7 +72,7 @@ import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
71
72
  import {focusForLastInput} from '../utils/focusReturn';
72
73
  import {useMenuOverflow} from './useMenuOverflow';
73
74
  import {MenuRegionsContext} from './menuRegionsContext';
74
- import {getFocusableElements} from '../hooks/useFocusTrap';
75
+ import {moveRegionsTab} from './menuRegionStops';
75
76
  import {useDevWarning} from '../hooks/useDevWarning';
76
77
  import {useMenuDrillIn} from './useMenuDrillIn';
77
78
  import {resolveMenuWidth} from './menuWidth';
@@ -80,8 +81,11 @@ import {
80
81
  useAdaptivePresentation,
81
82
  type AdaptivePresentation,
82
83
  } from '../hooks/useAdaptivePresentation';
83
- import {MenuBottomSheet} from './MenuBottomSheet';
84
- import {MenuBottomSheetActionList} from './MenuBottomSheetActionList';
84
+ // Loaded on demand: only the bottom-sheet presentation renders the sheet.
85
+ import {
86
+ MenuBottomSheet,
87
+ MenuBottomSheetActionList,
88
+ } from './lazyMenuBottomSheet';
85
89
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
86
90
  import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
87
91
  import {
@@ -108,102 +112,6 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
108
112
  const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
109
113
  const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
110
114
 
111
- /**
112
- * The elements Tab reaches inside a header or footer region, in order. Hidden
113
- * controls (`hidden`, `display: none`, `visibility: hidden`, inert or
114
- * aria-hidden subtrees) are not reachable, judged by the popover focus trap's
115
- * own test so the two never disagree about where Tab can go.
116
- */
117
- /**
118
- * Collapse each named radio group to the one Tab stop a browser gives it: the
119
- * focused radio, else the checked one, else the first radio moving forward or
120
- * the last moving backward. The stop takes the group's first position.
121
- */
122
- function collapseRadioGroups(
123
- elements: HTMLElement[],
124
- active: Element | null,
125
- isBackward: boolean,
126
- ): HTMLElement[] {
127
- const groups = new Map<string, HTMLInputElement[]>();
128
- const groupKey = (el: HTMLElement): string | null =>
129
- el instanceof HTMLInputElement &&
130
- el.getAttribute('type') === 'radio' &&
131
- el.name !== ''
132
- ? `${el.form?.id ?? ''}\u0000${el.name}`
133
- : null;
134
- for (const el of elements) {
135
- const key = groupKey(el);
136
- if (key != null) {
137
- const members = groups.get(key) ?? [];
138
- members.push(el as HTMLInputElement);
139
- groups.set(key, members);
140
- }
141
- }
142
- const placed = new Set<string>();
143
- const result: HTMLElement[] = [];
144
- for (const el of elements) {
145
- const key = groupKey(el);
146
- if (key == null) {
147
- result.push(el);
148
- continue;
149
- }
150
- if (placed.has(key)) {
151
- continue;
152
- }
153
- placed.add(key);
154
- const members = groups.get(key) ?? [];
155
- const stop =
156
- members.find(radio => radio === active) ??
157
- members.find(radio => radio.checked) ??
158
- (isBackward ? members[members.length - 1] : members[0]);
159
- result.push(stop);
160
- }
161
- return result;
162
- }
163
-
164
- function tabbablesIn(region: HTMLElement | null): HTMLElement[] {
165
- if (region == null) {
166
- return [];
167
- }
168
- return getFocusableElements(region).filter(
169
- element => element.tabIndex >= 0 && !isInsideHiddenWrapper(element, region),
170
- );
171
- }
172
-
173
- /**
174
- * Whether a `display: none` ancestor between the element and its region hides
175
- * it — a control in a wrapper hidden at some widths, say. The trap's test reads
176
- * only the element's own display.
177
- */
178
- function isInsideHiddenWrapper(
179
- element: HTMLElement,
180
- region: HTMLElement,
181
- ): boolean {
182
- if (typeof window === 'undefined' || !window.getComputedStyle) {
183
- return false;
184
- }
185
- for (
186
- let node = element.parentElement;
187
- node != null && node !== region.parentElement;
188
- node = node.parentElement
189
- ) {
190
- if (window.getComputedStyle(node).display === 'none') {
191
- return true;
192
- }
193
- }
194
- return false;
195
- }
196
-
197
- /**
198
- * Move focus to a Tab stop in the dialog. Reports whether focus actually
199
- * arrived, so a caller can fall back to the menu instead of stranding focus on
200
- * the page.
201
- */
202
- function focusRegionControl(element: HTMLElement): boolean {
203
- element.focus();
204
- return element.ownerDocument.activeElement === element;
205
- }
206
-
207
115
  const styles = stylex.create({
208
116
  triggerOpen: {
209
117
  backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
@@ -758,12 +666,37 @@ function DropdownMenuBottomSheet({
758
666
  if (!isOpen || openModalityRef.current !== 'keyboard') {
759
667
  return;
760
668
  }
761
- const frame = requestAnimationFrame(() => {
762
- actionListRef.current
763
- ?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
764
- ?.focus({preventScroll: true});
669
+ const findFirstAction = () =>
670
+ actionListRef.current?.querySelector<HTMLElement>(
671
+ 'button:not(:disabled), a[href]',
672
+ ) ?? null;
673
+ let observer: MutationObserver | null = null;
674
+ let frame = requestAnimationFrame(() => {
675
+ const action = findFirstAction();
676
+ if (action != null) {
677
+ action.focus({preventScroll: true});
678
+ return;
679
+ }
680
+ // The sheet loads on demand, so a keyboard open can arrive before its
681
+ // rows exist. Wait for them, then focus a frame later so the sheet's
682
+ // own open-focus has already run.
683
+ observer = new MutationObserver(() => {
684
+ const loaded = findFirstAction();
685
+ if (loaded == null) {
686
+ return;
687
+ }
688
+ observer?.disconnect();
689
+ observer = null;
690
+ frame = requestAnimationFrame(() => {
691
+ loaded.focus({preventScroll: true});
692
+ });
693
+ });
694
+ observer.observe(document.body, {childList: true, subtree: true});
765
695
  });
766
- return () => cancelAnimationFrame(frame);
696
+ return () => {
697
+ cancelAnimationFrame(frame);
698
+ observer?.disconnect();
699
+ };
767
700
  }, [currentItems, isOpen]);
768
701
 
769
702
  useEffect(() => {
@@ -987,6 +920,43 @@ function DropdownMenuPopover({
987
920
  // above it ends the gesture in flight through this ref.
988
921
  const cancelMenuPressRef = useRef<() => void>(() => {});
989
922
 
923
+ // Submenu flyouts are `popover="manual"` elements nested inside the menu's
924
+ // `popover="auto"` element, so the browser's light dismiss of the menu never
925
+ // reaches them: without a sweep they keep their open state and their
926
+ // top-layer entry while the menu is hidden, and re-anchor to the viewport
927
+ // corner when the menu reopens (#6893). Closing them through the popover
928
+ // API lets useLayer's toggle handling reset their state. Only flyouts that
929
+ // are actually open are touched: `:popover-open` ships wherever
930
+ // `hidePopover` does (they are one API), and the display fallback keeps its
931
+ // own open signal (`display: block`) so it is guarded the same way.
932
+ const hideOpenFlyouts = useCallback(() => {
933
+ const menuPopover = contentRef.current;
934
+ if (!menuPopover) {
935
+ return;
936
+ }
937
+ // Innermost first: hiding a flyout may unmount the flyouts nested inside
938
+ // it, and a detached element no longer has the toggle listener that
939
+ // resets its open state.
940
+ const flyouts = [
941
+ ...menuPopover.querySelectorAll<HTMLElement>('[popover]'),
942
+ ].reverse();
943
+ for (const flyout of flyouts) {
944
+ if (typeof flyout.hidePopover === 'function') {
945
+ if (flyout.matches(':popover-open')) {
946
+ flyout.hidePopover();
947
+ }
948
+ } else if (flyout.style.display === 'block') {
949
+ flyout.style.display = 'none';
950
+ const toggle = new Event('toggle', {bubbles: false});
951
+ Object.defineProperty(toggle, 'newState', {
952
+ value: 'closed',
953
+ configurable: true,
954
+ });
955
+ flyout.dispatchEvent(toggle);
956
+ }
957
+ }
958
+ }, []);
959
+
990
960
  // Focus lands somewhere stated when the menu closes. A press outside that
991
961
  // landed on a focusable control has already moved focus there; leave it.
992
962
  // Otherwise — a row acted, Escape, a press on nothing — focus returns to
@@ -994,6 +964,9 @@ function DropdownMenuPopover({
994
964
  // keyboard (the modality tracker decides; the suppressed style hides the
995
965
  // pointer case, which Safari would otherwise paint after a touch pick).
996
966
  const handleLayerHide = useCallback(() => {
967
+ // Whatever closed the menu — light dismiss, the dismissal stack, Escape
968
+ // or a controlled close — its flyouts close with it.
969
+ hideOpenFlyouts();
997
970
  if (suppressControlledRollbackHideRef.current) {
998
971
  suppressControlledRollbackHideRef.current = false;
999
972
  return;
@@ -1024,7 +997,7 @@ function DropdownMenuPopover({
1024
997
  // also reaches a control the caller renders, which the browser would
1025
998
  // otherwise ring (Chromium after a mouse pick, WebKit after a touch).
1026
999
  focusForLastInput(trigger, {preventScroll: true});
1027
- }, [isControlled, onOpenChange, prepareFocusReturn]);
1000
+ }, [hideOpenFlyouts, isControlled, onOpenChange, prepareFocusReturn]);
1028
1001
 
1029
1002
  // Defer item focus until the layer has committed open, so focus restore
1030
1003
  // captures the trigger instead of the first menu item.
@@ -1202,44 +1175,13 @@ function DropdownMenuPopover({
1202
1175
  // second time.
1203
1176
  const moveDialogTab = useCallback(
1204
1177
  (e: React.KeyboardEvent, isFromFlyout = false) => {
1205
- const menu = listRef.current;
1206
- if (menu == null) {
1207
- return;
1208
- }
1209
- e.preventDefault();
1210
- e.stopPropagation();
1211
- // A named radio group is one stop, as the browser's own Tab order has it.
1212
- const cycle = collapseRadioGroups(
1213
- [
1214
- ...tabbablesIn(headerRef.current),
1215
- menu,
1216
- ...tabbablesIn(footerRef.current),
1217
- ],
1218
- document.activeElement,
1219
- e.shiftKey,
1178
+ moveRegionsTab(
1179
+ e,
1180
+ listRef.current,
1181
+ headerRef.current,
1182
+ footerRef.current,
1183
+ isFromFlyout,
1220
1184
  );
1221
- if (cycle.length === 1) {
1222
- // Nothing else to reach: focus stays in the menu. From a flyout that
1223
- // has just closed itself, that is the menu's own stop.
1224
- if (isFromFlyout) {
1225
- menu.focus();
1226
- }
1227
- return;
1228
- }
1229
- const active = document.activeElement;
1230
- const menuIndex = cycle.indexOf(menu);
1231
- // A row, or a row of an open flyout, counts as the menu's stop.
1232
- let index = menu.contains(active)
1233
- ? menuIndex
1234
- : cycle.findIndex(el => el === active || el.contains(active));
1235
- if (index < 0) {
1236
- index = menuIndex;
1237
- }
1238
- const step = e.shiftKey ? -1 : 1;
1239
- const next = cycle[(index + step + cycle.length) % cycle.length];
1240
- if (!focusRegionControl(next)) {
1241
- menu.focus();
1242
- }
1243
1185
  },
1244
1186
  [listRef],
1245
1187
  );
@@ -1694,7 +1636,9 @@ function DropdownMenuPopover({
1694
1636
  )}>
1695
1637
  <MenuRegionsContext value={hasRegions}>
1696
1638
  <DropdownMenuContext value={contextValue}>
1697
- {menuContent}
1639
+ <DropdownMenuRootOpenContext value={isOpen}>
1640
+ {menuContent}
1641
+ </DropdownMenuRootOpenContext>
1698
1642
  </DropdownMenuContext>
1699
1643
  </MenuRegionsContext>
1700
1644
  </div>,
@@ -51,7 +51,7 @@ export const docs = {
51
51
  name: 'icon',
52
52
  type: 'IconType',
53
53
  description:
54
- 'Icon to display before the label. See `npx @astryxdesign/cli docs icons` for valid semantic names.',
54
+ 'Icon to display before the label. See `astryx docs icons` for valid semantic names.',
55
55
  },
56
56
  {
57
57
  name: 'value',
@@ -98,7 +98,7 @@ export interface DropdownMenuCheckboxItemProps extends Omit<
98
98
  description?: ReactNode;
99
99
  /**
100
100
  * Icon to display before the label. Accepts a semantic icon name (see
101
- * `npx @astryxdesign/cli docs icons`) or a rendered node.
101
+ * `astryx docs icons`) or a rendered node.
102
102
  */
103
103
  icon?: ReactNode | IconType;
104
104
  /**
@@ -65,6 +65,30 @@ export interface DropdownMenuContextValue {
65
65
  menuLabel?: string;
66
66
  }
67
67
 
68
+ /**
69
+ * Whether the root menu of this layer tree is open; internal companion to
70
+ * `DropdownMenuContext`, on its own unexported context so the public
71
+ * `DropdownMenuContextValue` surface stays unchanged. A sub-menu's hover
72
+ * intent must die with the menu: a hover-open scheduled on its trigger
73
+ * before the menu closed from elsewhere would otherwise fire into the
74
+ * hidden menu and reopen the flyout behind it (#6893). `null` means no
75
+ * signal, treated as open, matching providers that have no state to give.
76
+ * @internal
77
+ */
78
+ export const DropdownMenuRootOpenContext = createLayerScopedContext<
79
+ boolean | null
80
+ >(null);
81
+ DropdownMenuRootOpenContext.displayName = 'DropdownMenuRootOpenContext';
82
+
83
+ /**
84
+ * Whether the root menu backing these compound items is open. True when no
85
+ * provider gave a signal.
86
+ * @internal
87
+ */
88
+ export function useDropdownMenuRootOpen(): boolean {
89
+ return use(DropdownMenuRootOpenContext) !== false;
90
+ }
91
+
68
92
  export const DropdownMenuContext =
69
93
  createLayerScopedContext<DropdownMenuContextValue | null>(null);
70
94
  DropdownMenuContext.displayName = 'DropdownMenuContext';
@@ -8,7 +8,7 @@ export const docs = {
8
8
  displayName: 'Dropdown Menu Divider',
9
9
  isHiddenFromOverview: true,
10
10
  description:
11
- 'A horizontal rule separating groups of rows in a compound menu. Renders role="separator", so it is never a stop in the arrow-key order. The data-driven equivalent is the `{type: "divider"}` entry in `items`; both modes render this component, so they look and theme identically.',
11
+ 'A horizontal rule separating groups of rows in a compound menu. Renders role="separator", so it is never a stop in the arrow-key order. The data-driven equivalent is the `{type: "divider"}` entry in `items`; both modes render this component, so they look and theme identically. Host attributes and DOM handlers (id, data-*, aria-*) reach the separator; its role and orientation stay component-owned.',
12
12
  props: [
13
13
  {
14
14
  name: 'xstyle',
@@ -27,7 +27,7 @@ export const docsDense = {
27
27
  isHiddenFromOverview: true,
28
28
  displayName: 'Dropdown Menu Divider',
29
29
  description:
30
- 'separator row for compound menus; compound peer of the data API\'s {type: "divider"}',
30
+ 'separator row for compound menus; compound peer of the data API\'s {type: "divider"}; host attributes reach the separator, role/orientation stay owned',
31
31
  propDescriptions: {
32
32
  xstyle: 'StyleX styles applied after the menu spacing',
33
33
  },