@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
@@ -21,6 +21,29 @@ describe('AvatarGroupOverflow', () => {
21
21
  expect(overflow).toHaveTextContent('+5');
22
22
  });
23
23
 
24
+ it('exposes the static overflow as a named image', () => {
25
+ render(
26
+ <AvatarGroup>
27
+ <Avatar name="Alice" />
28
+ <AvatarGroupOverflow count={5} />
29
+ </AvatarGroup>,
30
+ );
31
+
32
+ const overflow = screen.getByRole('img', {name: '5 more'});
33
+ expect(overflow).toHaveAttribute('role', 'img');
34
+ });
35
+
36
+ it('keeps a caller-supplied role on the static overflow', () => {
37
+ render(
38
+ <AvatarGroup>
39
+ <Avatar name="Alice" />
40
+ <AvatarGroupOverflow count={5} role="note" />
41
+ </AvatarGroup>,
42
+ );
43
+
44
+ expect(screen.getByLabelText('5 more')).toHaveAttribute('role', 'note');
45
+ });
46
+
24
47
  it('reflects the group size on the overflow chip', () => {
25
48
  render(
26
49
  <AvatarGroup size="lg">
@@ -66,7 +89,7 @@ describe('AvatarGroupOverflow', () => {
66
89
  </AvatarGroup>,
67
90
  );
68
91
 
69
- const overflow = screen.getByLabelText('3 more');
92
+ const overflow = screen.getByRole('button', {name: '3 more'});
70
93
  expect(overflow.tagName).toBe('BUTTON');
71
94
  });
72
95
 
@@ -208,6 +208,8 @@ export function AvatarGroupOverflow({
208
208
  return (
209
209
  <span
210
210
  ref={ref}
211
+ // Before the caller's props so a caller-supplied role still wins.
212
+ role="img"
211
213
  {...rest}
212
214
  aria-label={label}
213
215
  {...mergeProps(
@@ -70,6 +70,19 @@ describe('ChatLayoutScrollButton', () => {
70
70
  ).toHaveFocus();
71
71
  });
72
72
 
73
+ it('makes the pill inert whenever the visible state turns off', () => {
74
+ const {container, rerender} = render(
75
+ <ChatLayoutScrollButton isVisible onClick={() => {}} />,
76
+ );
77
+ const pill = container.querySelector('.astryx-chat-layout-scroll-button');
78
+
79
+ expect(pill).not.toHaveAttribute('inert');
80
+
81
+ rerender(<ChatLayoutScrollButton isVisible={false} onClick={() => {}} />);
82
+
83
+ expect(pill).toHaveAttribute('inert');
84
+ });
85
+
73
86
  it('is not keyboard reachable while hidden', async () => {
74
87
  const user = userEvent.setup();
75
88
  render(
@@ -86,8 +86,9 @@ const styles = stylex.create({
86
86
  opacity: 0,
87
87
  pointerEvents: 'none',
88
88
  // The hidden pill paints nothing, so focus landing on it would have no
89
- // visible indicator (WCAG 2.2 SC 2.4.7). `opacity` and `pointer-events`
90
- // leave the button in sequential focus navigation; `visibility` removes it.
89
+ // visible indicator (WCAG 2.2 SC 2.4.7). `visibility` removes it from
90
+ // sequential navigation after the fade; `inert` immediately prevents new
91
+ // focus and makes an already-focused descendant relinquish focus.
91
92
  visibility: 'hidden',
92
93
  maxWidth: sizeVars['--size-element-md'],
93
94
  },
@@ -151,6 +152,7 @@ export function ChatLayoutScrollButton({
151
152
  {...mergeProps(stylex.props(styles.wrapper, xstyle), className, style)}
152
153
  {...rest}>
153
154
  <div
155
+ inert={!isVisible}
154
156
  {...mergeProps(
155
157
  themeProps('chat-layout-scroll-button'),
156
158
  stylex.props(
@@ -44,6 +44,7 @@ const STORY_ID = 'core-chatlayout--scroll-affordance-states';
44
44
  * a stable handle for it.
45
45
  */
46
46
  const PILL = '.astryx-chat-layout-scroll-button';
47
+ const AFFORDANCE = `${PILL} button`;
47
48
 
48
49
  /** One observed tab stop, identified structurally rather than by its text. */
49
50
  type FocusStop = {
@@ -126,7 +127,11 @@ test.afterAll(async () => {
126
127
 
127
128
  async function openStory(
128
129
  page: Page,
129
- {theme = 'neutral', colorMode = 'light'} = {},
130
+ {
131
+ theme = 'neutral',
132
+ colorMode = 'light',
133
+ holdMotion = true,
134
+ }: {theme?: string; colorMode?: string; holdMotion?: boolean} = {},
130
135
  ): Promise<Locator> {
131
136
  page.on('pageerror', error => pageErrors.push(String(error)));
132
137
  browserVersion = page.context().browser()?.version() ?? 'unknown';
@@ -136,7 +141,9 @@ async function openStory(
136
141
  );
137
142
  const root = page.locator('.astryx-chat-layout');
138
143
  await root.waitFor({state: 'visible'});
139
- await holdMotionStill(page);
144
+ if (holdMotion) {
145
+ await holdMotionStill(page);
146
+ }
140
147
  await page.evaluate(async () => document.fonts.ready);
141
148
  return root;
142
149
  }
@@ -169,21 +176,25 @@ async function pillVisibility(page: Page): Promise<string> {
169
176
  }
170
177
 
171
178
  /**
172
- * Can the affordance take focus at all? This is the mechanism under test:
173
- * `visibility: hidden` removes an element from sequential focus navigation AND
174
- * refuses programmatic focus, which `opacity: 0` does neither of. A CSS locator
175
- * is used rather than a role query because a hidden button is not in the
176
- * accessibility tree and a role query would not resolve it.
179
+ * Can the affordance take new focus at all? This is one half of the mechanism
180
+ * under test: `inert` refuses programmatic focus immediately, while delayed
181
+ * `visibility: hidden` removes the control from sequential navigation after the
182
+ * fade. A CSS locator is used rather than a role query because a hidden button
183
+ * is not in the accessibility tree and a role query would not resolve it.
177
184
  */
178
- async function affordanceAcceptsFocus(page: Page): Promise<boolean> {
179
- return page
180
- .locator('.astryx-chat-layout-scroll-button button')
181
- .evaluate(element => {
182
- // preventScroll: focusing must not move the scroll position, or the
183
- // probe would change the very state the next assertion reads.
184
- (element as HTMLElement).focus({preventScroll: true});
185
- return document.activeElement === element;
186
- });
185
+ async function affordanceAcceptsFocus(
186
+ page: Page,
187
+ resetFocus: Locator,
188
+ ): Promise<boolean> {
189
+ // Start outside the affordance so this probe proves a NEW focus transfer;
190
+ // equality alone would also be true when a button stayed active as it hid.
191
+ await resetFocus.focus();
192
+ return page.locator(AFFORDANCE).evaluate(element => {
193
+ // preventScroll: focusing must not move the scroll position, or the
194
+ // probe would change the very state the next assertion reads.
195
+ (element as HTMLElement).focus({preventScroll: true});
196
+ return document.activeElement === element;
197
+ });
187
198
  }
188
199
 
189
200
  /**
@@ -340,6 +351,36 @@ async function capture(
340
351
  return receipt;
341
352
  }
342
353
 
354
+ test('the hiding affordance releases focus before its fade finishes', async ({
355
+ page,
356
+ }) => {
357
+ const root = await openStory(page, {holdMotion: false});
358
+ const pill = page.locator(PILL);
359
+ const affordance = page.locator(AFFORDANCE);
360
+
361
+ await scrollToTop(root);
362
+ await expect(pill).toHaveCSS('visibility', 'visible');
363
+ await affordance.focus();
364
+ await expect(affordance).toBeFocused();
365
+
366
+ await page.keyboard.press('Enter');
367
+ await expect
368
+ .poll(
369
+ async () =>
370
+ affordance.evaluate(element => document.activeElement === element),
371
+ {timeout: 5000},
372
+ )
373
+ .toBe(false);
374
+
375
+ // `visibility` flips only after the fade. Focus leaving while it is still
376
+ // visible proves the immediate `inert` state rather than the delayed CSS.
377
+ await expect(pill).toHaveCSS('visibility', 'visible');
378
+ await expect
379
+ .poll(async () => distanceFromBottom(root), {timeout: 5000})
380
+ .toBeLessThan(2);
381
+ await expect(pill).toHaveCSS('visibility', 'hidden');
382
+ });
383
+
343
384
  test('the scroll affordance is keyboard reachable exactly while it is visible', async ({
344
385
  page,
345
386
  }) => {
@@ -367,7 +408,7 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
367
408
  expect(await distanceFromBottom(root)).toBeLessThan(2);
368
409
  await expect(pill).toHaveCSS('visibility', 'hidden');
369
410
  expect(
370
- await affordanceAcceptsFocus(page),
411
+ await affordanceAcceptsFocus(page, before),
371
412
  'a control that paints nothing must refuse focus (WCAG 2.2 SC 2.4.7)',
372
413
  ).toBe(false);
373
414
  const restFrame = await capture(page, 'rest-hidden', {
@@ -384,7 +425,7 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
384
425
  await expect(pill).toHaveCSS('visibility', 'visible');
385
426
  expect(await distanceFromBottom(root)).toBeGreaterThan(100);
386
427
  expect(
387
- await affordanceAcceptsFocus(page),
428
+ await affordanceAcceptsFocus(page, before),
388
429
  'the visible affordance must keep its keyboard access',
389
430
  ).toBe(true);
390
431
  await expect(
@@ -415,8 +456,12 @@ test('the scroll affordance is keyboard reachable exactly while it is visible',
415
456
  .poll(async () => distanceFromBottom(root), {timeout: 5000})
416
457
  .toBeLessThan(2);
417
458
  await expect(pill).toHaveCSS('visibility', 'hidden');
459
+ await expect(
460
+ page.locator(AFFORDANCE),
461
+ 'the affordance must relinquish focus when it hides',
462
+ ).not.toBeFocused();
418
463
  expect(
419
- await affordanceAcceptsFocus(page),
464
+ await affordanceAcceptsFocus(page, before),
420
465
  'after returning to the bottom the affordance must refuse focus again',
421
466
  ).toBe(false);
422
467
  const returnedFrame = await capture(page, 'returned-hidden', {
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it} from 'vitest';
4
- import {screen} from '@testing-library/react';
3
+ import {describe, it, expect} from 'vitest';
4
+ import {render, screen} from '@testing-library/react';
5
5
  import {expectFieldInputProps} from '../Field/fieldInputFamily.testing';
6
6
  import {describeFieldInputParts} from '../Field/fieldInputFamily.render-testing';
7
7
  import {CheckboxList, type CheckboxListProps} from './CheckboxList';
@@ -22,3 +22,30 @@ describeFieldInputParts('CheckboxList', {
22
22
  getControl: () => screen.getByRole('group'),
23
23
  inGroup: false,
24
24
  });
25
+
26
+ describe('CheckboxList FR10 details', () => {
27
+ it("names the group by its label and then a caller's aria-labelledby", () => {
28
+ render(
29
+ <>
30
+ <span id="caller-name">Billing</span>
31
+ <CheckboxList
32
+ label="Preferences"
33
+ value={[]}
34
+ onChange={() => {}}
35
+ aria-labelledby="caller-name">
36
+ <CheckboxListItem label="A" value="a" />
37
+ </CheckboxList>
38
+ </>,
39
+ );
40
+ const group = screen.getByRole('group');
41
+ const ids = group.getAttribute('aria-labelledby')?.split(' ');
42
+ expect(ids).toHaveLength(2);
43
+ expect(document.getElementById(ids?.[0] ?? '')?.textContent).toContain(
44
+ 'Preferences',
45
+ );
46
+ expect(ids?.[1]).toBe('caller-name');
47
+ expect(screen.getByRole('group', {name: 'Preferences Billing'})).toBe(
48
+ group,
49
+ );
50
+ });
51
+ });
@@ -38,6 +38,7 @@ import {useTooltip} from '../Tooltip';
38
38
  import * as stylex from '@stylexjs/stylex';
39
39
  import {mergeProps} from '../utils';
40
40
  import {themeProps} from '../utils/themeProps';
41
+ import {joinAriaIDs} from '../utils/inputAria';
41
42
  import {
42
43
  CheckboxListContext,
43
44
  type CheckboxListContextValue,
@@ -170,6 +171,7 @@ export function CheckboxList({
170
171
  controlXstyle,
171
172
  controlClassName,
172
173
  controlStyle,
174
+ 'aria-labelledby': ariaLabelledByProp,
173
175
  'aria-describedby': ariaDescribedByProp,
174
176
  ...restProps
175
177
  }: CheckboxListProps) {
@@ -290,7 +292,8 @@ export function CheckboxList({
290
292
  controlStyle,
291
293
  )}
292
294
  role="group"
293
- aria-labelledby={labelID}
295
+ // A caller aria-labelledby names the group together with its label.
296
+ aria-labelledby={joinAriaIDs(labelID, ariaLabelledByProp)}
294
297
  aria-describedby={
295
298
  [
296
299
  ariaDescribedByProp,
@@ -4,6 +4,7 @@
4
4
  export const docs = {
5
5
  name: 'Code',
6
6
  displayName: 'Code',
7
+ keywords: ['inlinecode', 'monospace'],
7
8
  group: 'Code',
8
9
  category: 'Content',
9
10
  description:
@@ -87,6 +87,8 @@ export const docs = {
87
87
  'modal',
88
88
  'dialog',
89
89
  'navigation',
90
+ 'commandmenu',
91
+ 'cmdk',
90
92
  ],
91
93
  description:
92
94
  'Root component. Manages open state, search, keyboard navigation, and composition slots.',
@@ -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',
@@ -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
@@ -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,7 +9,15 @@
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';
@@ -19,6 +27,7 @@ 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';
@@ -74,6 +83,10 @@ beforeEach(() => {
74
83
  };
75
84
  });
76
85
 
86
+ // The touch sheet is loaded lazily; resolve it once so sheets below render
87
+ // synchronously.
88
+ beforeAll(warmMenuBottomSheet, 20000);
89
+
77
90
  describe('DropdownMenu', () => {
78
91
  it('renders trigger button with label', () => {
79
92
  render(
@@ -1554,10 +1567,9 @@ describe('DropdownMenuItem host attributes', () => {
1554
1567
 
1555
1568
  describe('DropdownMenuGroup (compound mode)', () => {
1556
1569
  it('a focusable node in the heading is not reachable by the arrow keys', async () => {
1557
- // A heading is not a menu row, so a control inside one lands in the
1558
- // group but outside the roving focus order. The type does not prevent
1559
- // this — a rich heading is a legitimate need — so the behavior is
1560
- // 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.
1561
1573
  const user = userEvent.setup();
1562
1574
  render(
1563
1575
  <DropdownMenu button={{label: 'Actions'}}>