@astryxdesign/core 0.7.2-canary.fdd384f → 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 (132) hide show
  1. package/CHANGELOG.md +156 -2
  2. package/README.md +4 -4
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +8 -1
  5. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  6. package/dist/AvatarGroup/AvatarGroupOverflow.js +4 -1
  7. package/dist/Chat/ChatLayoutScrollButton.d.ts.map +1 -1
  8. package/dist/Chat/ChatLayoutScrollButton.js +1 -0
  9. package/dist/CheckboxList/CheckboxList.d.ts +1 -1
  10. package/dist/CheckboxList/CheckboxList.d.ts.map +1 -1
  11. package/dist/CheckboxList/CheckboxList.js +6 -2
  12. package/dist/DropdownMenu/DropdownMenu.d.ts +1 -1
  13. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  14. package/dist/DropdownMenu/DropdownMenu.js +38 -110
  15. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +1 -1
  16. package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
  17. package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
  18. package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
  19. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
  20. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts +24 -0
  21. package/dist/DropdownMenu/lazyMenuBottomSheet.d.ts.map +1 -0
  22. package/dist/DropdownMenu/lazyMenuBottomSheet.js +46 -0
  23. package/dist/DropdownMenu/menuRegionStops.d.ts +38 -0
  24. package/dist/DropdownMenu/menuRegionStops.d.ts.map +1 -0
  25. package/dist/DropdownMenu/menuRegionStops.js +131 -0
  26. package/dist/FormLayout/FormLayout.d.ts +5 -4
  27. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  28. package/dist/FormLayout/FormLayout.js +1 -1
  29. package/dist/FormLayout/FormLayoutContext.d.ts +3 -3
  30. package/dist/FormLayout/FormLayoutContext.js +3 -3
  31. package/dist/Grid/Grid.d.ts +8 -7
  32. package/dist/Grid/Grid.d.ts.map +1 -1
  33. package/dist/Grid/Grid.js +5 -4
  34. package/dist/Grid/GridSpan.d.ts +1 -1
  35. package/dist/Markdown/parser.d.ts.map +1 -1
  36. package/dist/Markdown/parser.js +8 -2
  37. package/dist/Markdown/plugins/headingLinks.d.ts.map +1 -1
  38. package/dist/Markdown/plugins/headingLinks.js +9 -1
  39. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  40. package/dist/PowerSearch/PowerSearch.js +5 -0
  41. package/dist/PowerSearch/PowerSearchValueEditor.d.ts.map +1 -1
  42. package/dist/PowerSearch/PowerSearchValueEditor.js +45 -11
  43. package/dist/SideNav/SideNavItem.d.ts.map +1 -1
  44. package/dist/SideNav/SideNavItem.js +6 -2
  45. package/dist/Token/Token.js +1 -1
  46. package/dist/TopNav/TopNavMegaMenu.js +2 -0
  47. package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
  48. package/dist/TopNav/TopNavMenu.js +2 -0
  49. package/dist/astryx.css +1 -1
  50. package/dist/reset.a11y.chromium.spec.js +50 -1
  51. package/docs.mjs +4 -4
  52. package/package.json +4 -4
  53. package/scripts/agent-doc-state.mjs +66 -6
  54. package/scripts/postinstall.mjs +44 -3
  55. package/src/Avatar/Avatar.tsx +11 -2
  56. package/src/AvatarGroup/AvatarGroupOverflow.test.tsx +24 -1
  57. package/src/AvatarGroup/AvatarGroupOverflow.tsx +2 -0
  58. package/src/Chat/ChatLayoutScrollButton.test.tsx +13 -0
  59. package/src/Chat/ChatLayoutScrollButton.tsx +4 -2
  60. package/src/Chat/__tests__/ChatLayoutScrollButton.a11y.chromium.spec.ts +64 -19
  61. package/src/CheckboxList/CheckboxList.fr10.test.tsx +29 -2
  62. package/src/CheckboxList/CheckboxList.tsx +4 -1
  63. package/src/Code/Code.doc.mjs +1 -0
  64. package/src/CommandPalette/CommandPalette.doc.mjs +2 -0
  65. package/src/ContextMenu/ContextMenu.test.tsx +12 -2
  66. package/src/Drawer/Drawer.doc.mjs +1 -0
  67. package/src/DropdownMenu/DropdownMenu.ssr.test.tsx +57 -0
  68. package/src/DropdownMenu/DropdownMenu.test.tsx +17 -5
  69. package/src/DropdownMenu/DropdownMenu.tsx +43 -142
  70. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +1 -1
  71. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +1 -1
  72. package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
  73. package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
  74. package/src/DropdownMenu/DropdownMenuLazySheet.test.tsx +126 -0
  75. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +1 -1
  76. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
  77. package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
  78. package/src/DropdownMenu/__tests__/warmMenuBottomSheet.tsx +43 -0
  79. package/src/DropdownMenu/lazyMenuBottomSheet.ts +71 -0
  80. package/src/DropdownMenu/menuRegionStops.ts +167 -0
  81. package/src/Field/FieldLabel.doc.mjs +133 -26
  82. package/src/Field/FieldLabel.spec.md +172 -0
  83. package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
  84. package/src/FormLayout/FormLayout.doc.mjs +6 -3
  85. package/src/FormLayout/FormLayout.spec.md +192 -0
  86. package/src/FormLayout/FormLayout.test.tsx +15 -0
  87. package/src/FormLayout/FormLayout.tsx +7 -6
  88. package/src/FormLayout/FormLayoutContext.ts +3 -3
  89. package/src/Grid/Grid.doc.mjs +7 -1
  90. package/src/Grid/Grid.spec.md +93 -113
  91. package/src/Grid/Grid.test.tsx +22 -0
  92. package/src/Grid/Grid.tsx +10 -9
  93. package/src/Grid/GridSpan.doc.mjs +3 -3
  94. package/src/Grid/GridSpan.tsx +1 -1
  95. package/src/Icon/Icon.doc.mjs +36 -0
  96. package/src/Indicator/Indicator.doc.mjs +3 -3
  97. package/src/Layout/LayoutHeader.doc.mjs +5 -0
  98. package/src/Markdown/parser.ts +8 -2
  99. package/src/Markdown/plugins/headingLinks.ts +9 -1
  100. package/src/MetadataList/MetadataList.doc.mjs +1 -1
  101. package/src/MoreMenu/MoreMenu.test.tsx +5 -1
  102. package/src/PowerSearch/PowerSearch.test.tsx +62 -0
  103. package/src/PowerSearch/PowerSearch.tsx +9 -0
  104. package/src/PowerSearch/PowerSearchValueEditor.test.tsx +71 -0
  105. package/src/PowerSearch/PowerSearchValueEditor.tsx +45 -0
  106. package/src/ProgressBar/ProgressBar.doc.mjs +1 -0
  107. package/src/Resizable/useResizable.doc.mjs +2 -0
  108. package/src/ScrollableArea/ScrollableArea.doc.mjs +1 -1
  109. package/src/SideNav/SideNav.spec.md +227 -0
  110. package/src/SideNav/SideNav.test.tsx +41 -0
  111. package/src/SideNav/SideNavItem.tsx +5 -2
  112. package/src/Spinner/Spinner.doc.mjs +1 -1
  113. package/src/Table/Table.spec.md +6 -1
  114. package/src/Table/plugins/activation/useTableActivation.spec.md +254 -0
  115. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  116. package/src/ToggleButton/ToggleButtonGroup.doc.mjs +4 -0
  117. package/src/Token/Token.tsx +1 -1
  118. package/src/Token/__tests__/TokenRemoveTarget.a11y.chromium.spec.ts +86 -0
  119. package/src/Toolbar/Toolbar.test.tsx +0 -8
  120. package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
  121. package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
  122. package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
  123. package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
  124. package/src/TopNav/TopNavMegaMenu.test.tsx +25 -0
  125. package/src/TopNav/TopNavMegaMenu.tsx +2 -0
  126. package/src/TopNav/TopNavMenu.test.tsx +27 -1
  127. package/src/TopNav/TopNavMenu.tsx +2 -0
  128. package/src/postinstall.test.mjs +79 -0
  129. package/src/reset.a11y.chromium.spec.ts +52 -1
  130. package/src/reset.css +9 -0
  131. package/src/reset.test.ts +11 -0
  132. package/src/unicodePropertyEscapes.source-build.test.mjs +69 -0
@@ -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
@@ -72,7 +72,7 @@ import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
72
72
  import {focusForLastInput} from '../utils/focusReturn';
73
73
  import {useMenuOverflow} from './useMenuOverflow';
74
74
  import {MenuRegionsContext} from './menuRegionsContext';
75
- import {getFocusableElements} from '../hooks/useFocusTrap';
75
+ import {moveRegionsTab} from './menuRegionStops';
76
76
  import {useDevWarning} from '../hooks/useDevWarning';
77
77
  import {useMenuDrillIn} from './useMenuDrillIn';
78
78
  import {resolveMenuWidth} from './menuWidth';
@@ -81,8 +81,11 @@ import {
81
81
  useAdaptivePresentation,
82
82
  type AdaptivePresentation,
83
83
  } from '../hooks/useAdaptivePresentation';
84
- import {MenuBottomSheet} from './MenuBottomSheet';
85
- 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';
86
89
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
87
90
  import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
88
91
  import {
@@ -109,102 +112,6 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
109
112
  const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
110
113
  const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
111
114
 
112
- /**
113
- * The elements Tab reaches inside a header or footer region, in order. Hidden
114
- * controls (`hidden`, `display: none`, `visibility: hidden`, inert or
115
- * aria-hidden subtrees) are not reachable, judged by the popover focus trap's
116
- * own test so the two never disagree about where Tab can go.
117
- */
118
- /**
119
- * Collapse each named radio group to the one Tab stop a browser gives it: the
120
- * focused radio, else the checked one, else the first radio moving forward or
121
- * the last moving backward. The stop takes the group's first position.
122
- */
123
- function collapseRadioGroups(
124
- elements: HTMLElement[],
125
- active: Element | null,
126
- isBackward: boolean,
127
- ): HTMLElement[] {
128
- const groups = new Map<string, HTMLInputElement[]>();
129
- const groupKey = (el: HTMLElement): string | null =>
130
- el instanceof HTMLInputElement &&
131
- el.getAttribute('type') === 'radio' &&
132
- el.name !== ''
133
- ? `${el.form?.id ?? ''}\u0000${el.name}`
134
- : null;
135
- for (const el of elements) {
136
- const key = groupKey(el);
137
- if (key != null) {
138
- const members = groups.get(key) ?? [];
139
- members.push(el as HTMLInputElement);
140
- groups.set(key, members);
141
- }
142
- }
143
- const placed = new Set<string>();
144
- const result: HTMLElement[] = [];
145
- for (const el of elements) {
146
- const key = groupKey(el);
147
- if (key == null) {
148
- result.push(el);
149
- continue;
150
- }
151
- if (placed.has(key)) {
152
- continue;
153
- }
154
- placed.add(key);
155
- const members = groups.get(key) ?? [];
156
- const stop =
157
- members.find(radio => radio === active) ??
158
- members.find(radio => radio.checked) ??
159
- (isBackward ? members[members.length - 1] : members[0]);
160
- result.push(stop);
161
- }
162
- return result;
163
- }
164
-
165
- function tabbablesIn(region: HTMLElement | null): HTMLElement[] {
166
- if (region == null) {
167
- return [];
168
- }
169
- return getFocusableElements(region).filter(
170
- element => element.tabIndex >= 0 && !isInsideHiddenWrapper(element, region),
171
- );
172
- }
173
-
174
- /**
175
- * Whether a `display: none` ancestor between the element and its region hides
176
- * it — a control in a wrapper hidden at some widths, say. The trap's test reads
177
- * only the element's own display.
178
- */
179
- function isInsideHiddenWrapper(
180
- element: HTMLElement,
181
- region: HTMLElement,
182
- ): boolean {
183
- if (typeof window === 'undefined' || !window.getComputedStyle) {
184
- return false;
185
- }
186
- for (
187
- let node = element.parentElement;
188
- node != null && node !== region.parentElement;
189
- node = node.parentElement
190
- ) {
191
- if (window.getComputedStyle(node).display === 'none') {
192
- return true;
193
- }
194
- }
195
- return false;
196
- }
197
-
198
- /**
199
- * Move focus to a Tab stop in the dialog. Reports whether focus actually
200
- * arrived, so a caller can fall back to the menu instead of stranding focus on
201
- * the page.
202
- */
203
- function focusRegionControl(element: HTMLElement): boolean {
204
- element.focus();
205
- return element.ownerDocument.activeElement === element;
206
- }
207
-
208
115
  const styles = stylex.create({
209
116
  triggerOpen: {
210
117
  backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
@@ -759,12 +666,37 @@ function DropdownMenuBottomSheet({
759
666
  if (!isOpen || openModalityRef.current !== 'keyboard') {
760
667
  return;
761
668
  }
762
- const frame = requestAnimationFrame(() => {
763
- actionListRef.current
764
- ?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
765
- ?.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});
766
695
  });
767
- return () => cancelAnimationFrame(frame);
696
+ return () => {
697
+ cancelAnimationFrame(frame);
698
+ observer?.disconnect();
699
+ };
768
700
  }, [currentItems, isOpen]);
769
701
 
770
702
  useEffect(() => {
@@ -1243,44 +1175,13 @@ function DropdownMenuPopover({
1243
1175
  // second time.
1244
1176
  const moveDialogTab = useCallback(
1245
1177
  (e: React.KeyboardEvent, isFromFlyout = false) => {
1246
- const menu = listRef.current;
1247
- if (menu == null) {
1248
- return;
1249
- }
1250
- e.preventDefault();
1251
- e.stopPropagation();
1252
- // A named radio group is one stop, as the browser's own Tab order has it.
1253
- const cycle = collapseRadioGroups(
1254
- [
1255
- ...tabbablesIn(headerRef.current),
1256
- menu,
1257
- ...tabbablesIn(footerRef.current),
1258
- ],
1259
- document.activeElement,
1260
- e.shiftKey,
1178
+ moveRegionsTab(
1179
+ e,
1180
+ listRef.current,
1181
+ headerRef.current,
1182
+ footerRef.current,
1183
+ isFromFlyout,
1261
1184
  );
1262
- if (cycle.length === 1) {
1263
- // Nothing else to reach: focus stays in the menu. From a flyout that
1264
- // has just closed itself, that is the menu's own stop.
1265
- if (isFromFlyout) {
1266
- menu.focus();
1267
- }
1268
- return;
1269
- }
1270
- const active = document.activeElement;
1271
- const menuIndex = cycle.indexOf(menu);
1272
- // A row, or a row of an open flyout, counts as the menu's stop.
1273
- let index = menu.contains(active)
1274
- ? menuIndex
1275
- : cycle.findIndex(el => el === active || el.contains(active));
1276
- if (index < 0) {
1277
- index = menuIndex;
1278
- }
1279
- const step = e.shiftKey ? -1 : 1;
1280
- const next = cycle[(index + step + cycle.length) % cycle.length];
1281
- if (!focusRegionControl(next)) {
1282
- menu.focus();
1283
- }
1284
1185
  },
1285
1186
  [listRef],
1286
1187
  );
@@ -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
  /**
@@ -13,25 +13,86 @@ export const docs = {
13
13
  defaults: {title: 'Version history'},
14
14
  },
15
15
  description:
16
- 'A titled group of rows in a compound menu. Renders role="group" named by its heading; the heading is plain text, not a menuitem, so arrow keys and typeahead skip it. The data-driven equivalent is the `{type: "section", title, items}` entry in `items`; the heading shares its typography and theme target.',
16
+ 'A group of rows in a compound menu. A visible title names role="group" through aria-labelledby; an omitted title leaves the group unnamed. The heading is not a menuitem, so arrow keys and typeahead skip it. Keep title content non-interactive and place controls in DropdownMenu header or footer. The data-driven equivalent is the `{type: "section", title, items}` entry in `items`; both forms share the heading treatment and theme target.',
17
+ usage: {
18
+ description:
19
+ 'Use DropdownMenuGroup to give related compound-menu rows one visible and programmatic name. Use an untitled group only when a separate name would not help people understand the rows.',
20
+ bestPractices: [
21
+ {
22
+ guidance: true,
23
+ description:
24
+ 'Use short, descriptive title content that explains what the grouped rows have in common.',
25
+ },
26
+ {
27
+ guidance: true,
28
+ description:
29
+ 'Keep the title non-interactive so every available action remains in the menu arrow-key order.',
30
+ },
31
+ {
32
+ guidance: false,
33
+ description:
34
+ 'Place buttons, links, fields, or other controls in title. Put them in DropdownMenu header or footer instead.',
35
+ },
36
+ {
37
+ guidance: false,
38
+ description:
39
+ 'Wrap rows that do not form a meaningful set; a group adds structure only when its relationship helps users.',
40
+ },
41
+ ],
42
+ anatomy: [
43
+ {
44
+ name: 'Group',
45
+ required: true,
46
+ description:
47
+ 'The role="group" wrapper containing the heading and menu rows.',
48
+ },
49
+ {
50
+ name: 'Heading',
51
+ required: false,
52
+ description:
53
+ 'Visible non-interactive title content that names the group and carries the dropdown-menu-section-heading theme target.',
54
+ },
55
+ {
56
+ name: 'Rows',
57
+ required: false,
58
+ description:
59
+ 'Menu items, selectable items, radio groups, or submenus owned by DropdownMenu.',
60
+ },
61
+ ],
62
+ },
17
63
  props: [
18
64
  {
19
65
  name: 'title',
20
66
  type: 'ReactNode',
21
67
  description:
22
- "Heading shown above the rows and used as the group's accessible name. Omit for an unnamed group.",
68
+ "Non-interactive heading content shown above the rows and used as the group's accessible name. Omit for an unnamed group; place controls in DropdownMenu header or footer.",
23
69
  },
24
70
  {
25
71
  name: 'children',
26
72
  type: 'ReactNode',
27
73
  description:
28
- 'The rows of the group: DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuSubMenu.',
74
+ 'The grouped rows: DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, or DropdownMenuSubMenu.',
29
75
  },
30
76
  {
31
77
  name: 'xstyle',
32
78
  type: 'StyleXStyles',
33
79
  description:
34
- 'StyleX styles applied to the group wrapper. Must be a stylex.create() value: not an inline style object like style={{}}.',
80
+ 'StyleX styles applied to the role="group" wrapper. Must be a stylex.create() value, not an inline style object.',
81
+ },
82
+ {
83
+ name: 'className',
84
+ type: 'string',
85
+ description: 'CSS class name applied to the role="group" wrapper.',
86
+ },
87
+ {
88
+ name: 'style',
89
+ type: 'React.CSSProperties',
90
+ description: 'Inline styles applied to the role="group" wrapper.',
91
+ },
92
+ {
93
+ name: 'ref',
94
+ type: 'React.Ref<HTMLDivElement>',
95
+ description: 'Ref forwarded to the role="group" wrapper.',
35
96
  },
36
97
  ],
37
98
  theming: {
@@ -39,15 +100,113 @@ export const docs = {
39
100
  },
40
101
  };
41
102
 
42
- export const docsDense = {
103
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
104
+ export const docsZh = {
43
105
  name: 'DropdownMenuGroup',
44
- isHiddenFromOverview: true,
106
+ subComponentOf: 'DropdownMenu',
45
107
  displayName: 'Dropdown Menu Group',
108
+ isHiddenFromOverview: true,
109
+ description:
110
+ '复合菜单中的一组菜单行。可见标题通过 aria-labelledby 为 role="group" 命名;省略标题时该组没有名称。标题不是 menuitem,因此方向键和预输入会跳过它。标题内容应保持非交互;控件应放在 DropdownMenu 的 header 或 footer 中。',
111
+ usage: {
112
+ description:
113
+ '使用 DropdownMenuGroup 为相关的复合菜单行提供同一个可见名称和无障碍名称。只有当名称能帮助理解这些菜单行时才使用分组。',
114
+ bestPractices: [
115
+ {guidance: true, description: '使用简短且能说明菜单行关系的标题。'},
116
+ {
117
+ guidance: true,
118
+ description: '保持标题非交互,让所有操作都位于菜单的方向键顺序中。',
119
+ },
120
+ {
121
+ guidance: false,
122
+ description:
123
+ '在标题中放置按钮、链接或输入控件;应改用 DropdownMenu 的 header 或 footer。',
124
+ },
125
+ {guidance: false, description: '对没有共同含义的菜单行进行分组。'},
126
+ ],
127
+ anatomy: [
128
+ {
129
+ name: '组',
130
+ required: true,
131
+ description: '包含标题和菜单行的 role="group" 包装元素。',
132
+ },
133
+ {
134
+ name: '标题',
135
+ required: false,
136
+ description: '用于命名分组的可见非交互标题内容。',
137
+ },
138
+ {
139
+ name: '菜单行',
140
+ required: false,
141
+ description: '由 DropdownMenu 持有的菜单项、可选项、单选组或子菜单。',
142
+ },
143
+ ],
144
+ },
145
+ props: [
146
+ {
147
+ name: 'title',
148
+ type: 'ReactNode',
149
+ description:
150
+ '显示在菜单行上方并作为分组无障碍名称的非交互标题内容。省略时分组无名称;控件应放在 DropdownMenu 的 header 或 footer 中。',
151
+ },
152
+ {name: 'children', type: 'ReactNode', description: '该分组中的菜单行。'},
153
+ {
154
+ name: 'xstyle',
155
+ type: 'StyleXStyles',
156
+ description: '应用于 role="group" 包装元素的 StyleX 样式。',
157
+ },
158
+ {
159
+ name: 'className',
160
+ type: 'string',
161
+ description: '应用于 role="group" 包装元素的 CSS 类名。',
162
+ },
163
+ {
164
+ name: 'style',
165
+ type: 'React.CSSProperties',
166
+ description: '应用于 role="group" 包装元素的内联样式。',
167
+ },
168
+ {
169
+ name: 'ref',
170
+ type: 'React.Ref<HTMLDivElement>',
171
+ description: '转发到 role="group" 包装元素的 ref。',
172
+ },
173
+ ],
174
+ theming: {
175
+ targets: [{className: 'astryx-dropdown-menu-section-heading'}],
176
+ },
177
+ };
178
+
179
+ /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
180
+ export const docsDense = {
46
181
  description:
47
- 'titled role="group" of rows for compound menus; compound peer of the data API\'s {type: "section"}',
182
+ 'named role="group" of compound-menu rows; compound peer of the data API\'s {type: "section"}',
183
+ usage: {
184
+ description:
185
+ 'Use to give related compound-menu rows one visible and programmatic name.',
186
+ bestPractices: [
187
+ {
188
+ guidance: true,
189
+ description: "Short title that explains the rows' relationship.",
190
+ },
191
+ {
192
+ guidance: true,
193
+ description:
194
+ 'Non-interactive title keeps every action in menu arrow navigation.',
195
+ },
196
+ {
197
+ guidance: false,
198
+ description:
199
+ 'Controls in title. Put them in DropdownMenu header or footer instead.',
200
+ },
201
+ {guidance: false, description: 'Group unrelated rows.'},
202
+ ],
203
+ },
48
204
  propDescriptions: {
49
- title: 'heading above the rows; names the group',
50
- children: 'the rows of the group',
51
- xstyle: 'StyleX styles applied to the group wrapper',
205
+ title: 'non-interactive heading above the rows; names the group',
206
+ children: 'the grouped menu rows',
207
+ xstyle: 'StyleX styles on the role="group" wrapper',
208
+ className: 'CSS class on the role="group" wrapper',
209
+ style: 'inline styles on the role="group" wrapper',
210
+ ref: 'ref forwarded to the role="group" wrapper',
52
211
  },
53
212
  };
@@ -53,7 +53,8 @@ export interface DropdownMenuGroupProps extends Pick<
53
53
  > {
54
54
  /**
55
55
  * Heading shown above the group's rows and used as the group's accessible
56
- * name (`aria-labelledby`). Omit it for an unnamed group.
56
+ * name (`aria-labelledby`). Omit it for an unnamed group. Keep this content
57
+ * non-interactive; controls belong in DropdownMenu's `header` or `footer`.
57
58
  */
58
59
  title?: ReactNode;
59
60
  /** The rows of the group: menu items, selectable items, sub-menus. */
@@ -65,9 +66,10 @@ export interface DropdownMenuGroupProps extends Pick<
65
66
  /**
66
67
  * A titled group of menu rows.
67
68
  *
68
- * Renders `role="group"` named by its heading. The heading is plain text, not
69
- * a `menuitem`, so it is never a stop in the menu's arrow-key order and
70
- * typeahead never lands on it. The compound peer of
69
+ * Renders `role="group"` named by its heading. The heading is not a
70
+ * `menuitem`, so arrow-key navigation and typeahead skip it. Keep heading
71
+ * content non-interactive; place controls in DropdownMenu's `header` or
72
+ * `footer`. The compound peer of
71
73
  * `{type: 'section', title, items}` in the `items` data API.
72
74
  *
73
75
  * @example
@@ -0,0 +1,126 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuLazySheet.test.tsx
5
+ * @input Uses DropdownMenu, Testing Library, and a held module load
6
+ * @output Tests DropdownMenu while its on-demand touch sheet is still loading
7
+ * @position Test file for DropdownMenu
8
+ *
9
+ * This file never warms the sheet, and it checks that a popover menu never
10
+ * requests it: each test holds the sheet modules until it
11
+ * releases them, so it exercises the path a real page takes while the sheet's
12
+ * code is still downloading.
13
+ */
14
+
15
+ import {describe, it, expect, vi, beforeAll, beforeEach} from 'vitest';
16
+ import {render, screen, waitFor, act} from '@testing-library/react';
17
+ import userEvent from '@testing-library/user-event';
18
+ import type {DropdownMenu as DropdownMenuComponent} from './DropdownMenu';
19
+
20
+ const sheetLoad = vi.hoisted(() => {
21
+ let release: () => void = () => {};
22
+ const gate = new Promise<void>(resolve => {
23
+ release = resolve;
24
+ });
25
+ return {gate, release: () => release(), requested: [] as string[]};
26
+ });
27
+
28
+ vi.mock('./MenuBottomSheet', async importOriginal => {
29
+ sheetLoad.requested.push('MenuBottomSheet');
30
+ await sheetLoad.gate;
31
+ return importOriginal();
32
+ });
33
+
34
+ vi.mock('./MenuBottomSheetActionList', async importOriginal => {
35
+ sheetLoad.requested.push('MenuBottomSheetActionList');
36
+ await sheetLoad.gate;
37
+ return importOriginal();
38
+ });
39
+
40
+ let DropdownMenu: typeof DropdownMenuComponent;
41
+
42
+ // If any module the dropdown loads imported the sheet eagerly, this import
43
+ // would wait on the held sheet and the hook would time out.
44
+ beforeAll(async () => {
45
+ ({DropdownMenu} = await import('./DropdownMenu'));
46
+ }, 10000);
47
+
48
+ beforeEach(() => {
49
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
50
+ this: HTMLDialogElement,
51
+ ) {
52
+ this.setAttribute('open', '');
53
+ });
54
+ HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
55
+ this.removeAttribute('open');
56
+ });
57
+ vi.stubGlobal(
58
+ 'matchMedia',
59
+ vi.fn().mockImplementation((query: string) => ({
60
+ matches: false,
61
+ media: query,
62
+ onchange: null,
63
+ addEventListener: vi.fn(),
64
+ removeEventListener: vi.fn(),
65
+ addListener: vi.fn(),
66
+ removeListener: vi.fn(),
67
+ dispatchEvent: vi.fn(),
68
+ })),
69
+ );
70
+ });
71
+
72
+ describe('DropdownMenu touch sheet loaded on demand', () => {
73
+ // Runs first: nothing in this file has rendered a sheet yet.
74
+ it('never loads the sheet for a popover menu', async () => {
75
+ const user = userEvent.setup();
76
+ HTMLElement.prototype.showPopover = vi.fn();
77
+ HTMLElement.prototype.hidePopover = vi.fn();
78
+ render(
79
+ <DropdownMenu
80
+ button={{label: 'Actions'}}
81
+ items={[{label: 'Edit project'}, {label: 'Delete'}]}
82
+ />,
83
+ );
84
+
85
+ await user.click(screen.getByRole('button', {name: /Actions/}));
86
+ await act(async () => {
87
+ await new Promise(resolve => setTimeout(resolve, 50));
88
+ });
89
+
90
+ // Importing DropdownMenu and opening a popover menu requested neither
91
+ // sheet module, so no other module the dropdown loads imports it eagerly.
92
+ expect(sheetLoad.requested).toEqual([]);
93
+ });
94
+
95
+ it('focuses the first action when a keyboard open beats the sheet load', async () => {
96
+ const user = userEvent.setup();
97
+ render(
98
+ <DropdownMenu
99
+ button={{label: 'Actions'}}
100
+ presentation="bottom-sheet"
101
+ items={[{label: 'Edit project'}, {label: 'Delete'}]}
102
+ />,
103
+ );
104
+
105
+ const trigger = screen.getByRole('button', {name: /Actions/});
106
+ trigger.focus();
107
+ await user.keyboard('{Enter}');
108
+ // The open has committed, but the sheet's code has not arrived yet.
109
+ await act(async () => {
110
+ await new Promise(resolve => setTimeout(resolve, 50));
111
+ });
112
+ expect(screen.queryByRole('button', {name: 'Edit project'})).toBeNull();
113
+
114
+ await act(async () => {
115
+ sheetLoad.release();
116
+ });
117
+
118
+ await waitFor(
119
+ () =>
120
+ expect(
121
+ screen.getByRole('button', {name: 'Edit project'}),
122
+ ).toHaveFocus(),
123
+ {timeout: 15000},
124
+ );
125
+ }, 20000);
126
+ });
@@ -43,7 +43,7 @@ export const docs = {
43
43
  name: 'icon',
44
44
  type: 'IconType',
45
45
  description:
46
- 'Icon to display before the label. See `npx @astryxdesign/cli docs icons` for valid semantic names.',
46
+ 'Icon to display before the label. See `astryx docs icons` for valid semantic names.',
47
47
  },
48
48
  {
49
49
  name: 'isDisabled',
@@ -93,7 +93,7 @@ export interface DropdownMenuRadioItemProps extends Omit<
93
93
  description?: ReactNode;
94
94
  /**
95
95
  * Icon to display before the label. Accepts a semantic icon name (see
96
- * `npx @astryxdesign/cli docs icons`) or a rendered node.
96
+ * `astryx docs icons`) or a rendered node.
97
97
  */
98
98
  icon?: ReactNode | IconType;
99
99
  /**