@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
@@ -0,0 +1,334 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuGroupAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head DropdownMenuGroup story across themes, color modes, direction, and viewport widths
6
+ * @output Screenshots and fail-closed sensor receipts for the DropdownMenuGroup component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuGroup audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('dropdown-menu-group-audit-evidence');
23
+ const STORY_ID = 'core-dropdownmenugroup--named-and-unnamed-groups';
24
+ const WIDE = {width: 1024, height: 768};
25
+ const NARROW = {width: 320, height: 720};
26
+
27
+ type Mode = 'light' | 'dark';
28
+ type Direction = 'ltr' | 'rtl';
29
+ type Theme = 'neutral' | 'butter';
30
+ type AuditCase = {
31
+ key: string;
32
+ mode: Mode;
33
+ direction: Direction;
34
+ theme: Theme;
35
+ viewport: {width: number; height: number};
36
+ };
37
+
38
+ const cases: AuditCase[] = [
39
+ {
40
+ key: 'neutral-light',
41
+ mode: 'light',
42
+ direction: 'ltr',
43
+ theme: 'neutral',
44
+ viewport: WIDE,
45
+ },
46
+ {
47
+ key: 'neutral-dark',
48
+ mode: 'dark',
49
+ direction: 'ltr',
50
+ theme: 'neutral',
51
+ viewport: WIDE,
52
+ },
53
+ {
54
+ key: 'butter-light',
55
+ mode: 'light',
56
+ direction: 'ltr',
57
+ theme: 'butter',
58
+ viewport: WIDE,
59
+ },
60
+ {
61
+ key: 'butter-dark',
62
+ mode: 'dark',
63
+ direction: 'ltr',
64
+ theme: 'butter',
65
+ viewport: WIDE,
66
+ },
67
+ {
68
+ key: 'rtl-light',
69
+ mode: 'light',
70
+ direction: 'rtl',
71
+ theme: 'neutral',
72
+ viewport: WIDE,
73
+ },
74
+ {
75
+ key: 'rtl-dark',
76
+ mode: 'dark',
77
+ direction: 'rtl',
78
+ theme: 'neutral',
79
+ viewport: WIDE,
80
+ },
81
+ {
82
+ key: 'narrow-light',
83
+ mode: 'light',
84
+ direction: 'ltr',
85
+ theme: 'neutral',
86
+ viewport: NARROW,
87
+ },
88
+ {
89
+ key: 'narrow-dark',
90
+ mode: 'dark',
91
+ direction: 'ltr',
92
+ theme: 'neutral',
93
+ viewport: NARROW,
94
+ },
95
+ ];
96
+
97
+ test.describe.configure({mode: 'serial', retries: 0});
98
+ let server: StaticServer;
99
+ let checkoutSha: string;
100
+ let storybookSha: string;
101
+
102
+ function storyUrl(item: AuditCase) {
103
+ return `${server.origin}/iframe.html?id=${STORY_ID}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
104
+ }
105
+
106
+ async function settle(page: Page) {
107
+ await holdMotionStill(page);
108
+ await page.evaluate(async () => document.fonts.ready);
109
+ await page.waitForTimeout(40);
110
+ }
111
+
112
+ test.beforeAll(async () => {
113
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
114
+ fs.mkdirSync(OUTPUT, {recursive: true});
115
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
116
+ encoding: 'utf8',
117
+ }).trim();
118
+ const expected = process.env.ASTRYX_HEAD_SHA;
119
+ if (expected && expected !== checkoutSha) {
120
+ throw new Error(
121
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
122
+ );
123
+ }
124
+ server = await serveStorybook(
125
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
126
+ );
127
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
128
+ if (!response.ok) {
129
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
130
+ }
131
+ storybookSha = (await response.text()).trim();
132
+ if (storybookSha !== checkoutSha) {
133
+ throw new Error(
134
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
135
+ );
136
+ }
137
+ });
138
+
139
+ test.afterAll(async () => {
140
+ await server?.close();
141
+ });
142
+
143
+ for (const item of cases) {
144
+ test(item.key, async ({browser}) => {
145
+ const context = await browser.newContext({
146
+ viewport: item.viewport,
147
+ colorScheme: item.mode,
148
+ reducedMotion: 'reduce',
149
+ });
150
+ const page = await context.newPage();
151
+ const pageErrors: string[] = [];
152
+ page.on('pageerror', error => pageErrors.push(String(error)));
153
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
154
+ await page.locator('#storybook-root').waitFor();
155
+
156
+ const menu = page.getByRole('menu').last();
157
+ await expect(menu).toBeVisible();
158
+ const groups = menu.getByRole('group');
159
+ await expect(groups).toHaveCount(2);
160
+ const namedGroup = menu.getByRole('group', {name: 'Version history'});
161
+ const unnamedGroup = groups.nth(1);
162
+ await expect(namedGroup).toBeVisible();
163
+ await expect(unnamedGroup).not.toHaveAttribute('aria-labelledby');
164
+ const heading = namedGroup.getByText('Version history', {exact: true});
165
+ await expect(heading).toBeVisible();
166
+ const headingId = await heading.getAttribute('id');
167
+ if (headingId == null) {
168
+ throw new Error('named group heading has no id');
169
+ }
170
+ await expect(namedGroup).toHaveAttribute('aria-labelledby', headingId);
171
+ await settle(page);
172
+
173
+ const menuItems = menu.getByRole('menuitem');
174
+ await expect(menuItems).toHaveCount(3);
175
+ await menu.focus();
176
+ await page.keyboard.press('ArrowDown');
177
+ await expect(menuItems.first()).toBeFocused();
178
+ await page.keyboard.press('ArrowDown');
179
+ await expect(menuItems.nth(1)).toBeFocused();
180
+ await page.keyboard.press('ArrowDown');
181
+ await expect(menuItems.last()).toBeFocused();
182
+ await page.keyboard.press('v');
183
+ await expect(menuItems.last()).toBeFocused();
184
+ await expect(namedGroup).not.toBeFocused();
185
+ await expect(heading).not.toBeFocused();
186
+
187
+ const observed = await namedGroup.evaluate(element => {
188
+ const headingId = element.getAttribute('aria-labelledby');
189
+ const currentHeading = headingId
190
+ ? document.getElementById(headingId)
191
+ : null;
192
+ const groupBox = element.getBoundingClientRect();
193
+ const headingBox = currentHeading?.getBoundingClientRect() ?? null;
194
+ const groupStyle = getComputedStyle(element);
195
+ const headingStyle = currentHeading
196
+ ? getComputedStyle(currentHeading)
197
+ : null;
198
+ const currentMenu = element.closest('[role="menu"]');
199
+ const currentGroups = currentMenu?.querySelectorAll('[role="group"]');
200
+ const currentUnnamed = currentGroups?.item(1) ?? null;
201
+ return {
202
+ theme: document
203
+ .querySelector('[data-astryx-theme]')
204
+ ?.getAttribute('data-astryx-theme'),
205
+ mode: document.documentElement.getAttribute('data-theme'),
206
+ direction: groupStyle.direction,
207
+ viewport: {width: innerWidth, height: innerHeight},
208
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
209
+ horizontalOverflow:
210
+ document.documentElement.scrollWidth > innerWidth + 1,
211
+ fonts: document.fonts.status,
212
+ named: {
213
+ role: element.getAttribute('role'),
214
+ labelledBy: headingId,
215
+ accessibleText: currentHeading?.textContent?.trim() ?? null,
216
+ headingRole: currentHeading?.getAttribute('role') ?? null,
217
+ headingTabIndex: currentHeading?.getAttribute('tabindex') ?? null,
218
+ headingTarget:
219
+ currentHeading?.classList.contains(
220
+ 'astryx-dropdown-menu-section-heading',
221
+ ) ?? false,
222
+ },
223
+ unnamed: {
224
+ role: currentUnnamed?.getAttribute('role') ?? null,
225
+ labelledBy: currentUnnamed?.getAttribute('aria-labelledby') ?? null,
226
+ ariaLabel: currentUnnamed?.getAttribute('aria-label') ?? null,
227
+ },
228
+ geometry: {
229
+ group: {
230
+ x: groupBox.x,
231
+ y: groupBox.y,
232
+ width: groupBox.width,
233
+ height: groupBox.height,
234
+ },
235
+ heading:
236
+ headingBox === null
237
+ ? null
238
+ : {
239
+ x: headingBox.x,
240
+ y: headingBox.y,
241
+ width: headingBox.width,
242
+ height: headingBox.height,
243
+ },
244
+ },
245
+ headingPaint:
246
+ headingStyle === null
247
+ ? null
248
+ : {
249
+ color: headingStyle.color,
250
+ fontFamily: headingStyle.fontFamily,
251
+ fontSize: headingStyle.fontSize,
252
+ lineHeight: headingStyle.lineHeight,
253
+ paddingInlineStart: headingStyle.paddingInlineStart,
254
+ paddingInlineEnd: headingStyle.paddingInlineEnd,
255
+ },
256
+ };
257
+ });
258
+
259
+ const semanticsPass =
260
+ observed.named.role === 'group' &&
261
+ observed.named.labelledBy !== null &&
262
+ observed.named.accessibleText === 'Version history' &&
263
+ observed.named.headingRole === null &&
264
+ observed.named.headingTabIndex === null &&
265
+ observed.named.headingTarget &&
266
+ observed.unnamed.role === 'group' &&
267
+ observed.unnamed.labelledBy === null &&
268
+ observed.unnamed.ariaLabel === null;
269
+ const geometryPass =
270
+ observed.geometry.group.width > 0 &&
271
+ observed.geometry.group.height > 0 &&
272
+ observed.geometry.heading !== null &&
273
+ observed.geometry.heading.width > 0 &&
274
+ observed.geometry.heading.height > 0;
275
+ const paintPass =
276
+ observed.headingPaint !== null &&
277
+ observed.headingPaint.color !== 'rgba(0, 0, 0, 0)' &&
278
+ observed.headingPaint.fontSize !== '0px' &&
279
+ observed.headingPaint.lineHeight !== '0px' &&
280
+ observed.headingPaint.paddingInlineStart ===
281
+ observed.headingPaint.paddingInlineEnd;
282
+
283
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
284
+ const image = `${item.key}.png`;
285
+ const receipt = {
286
+ schemaVersion: 1,
287
+ expected: {
288
+ build: checkoutSha,
289
+ storyId: STORY_ID,
290
+ mode: item.mode,
291
+ direction: item.direction,
292
+ theme: item.theme,
293
+ viewport: item.viewport,
294
+ },
295
+ observed: {
296
+ build: storybookSha,
297
+ ...observed,
298
+ groupCount: await groups.count(),
299
+ menuItemCount: await menuItems.count(),
300
+ pageErrors,
301
+ },
302
+ image: {
303
+ file: image,
304
+ sha256: createHash('sha256').update(png).digest('hex'),
305
+ width: png.readUInt32BE(16),
306
+ height: png.readUInt32BE(20),
307
+ },
308
+ checks: {semanticsPass, geometryPass, paintPass},
309
+ passed:
310
+ storybookSha === checkoutSha &&
311
+ pageErrors.length === 0 &&
312
+ observed.theme === item.theme &&
313
+ observed.mode === item.mode &&
314
+ observed.direction === item.direction &&
315
+ observed.viewport.width === item.viewport.width &&
316
+ observed.viewport.height === item.viewport.height &&
317
+ observed.reducedMotion &&
318
+ !observed.horizontalOverflow &&
319
+ observed.fonts === 'loaded' &&
320
+ (await groups.count()) === 2 &&
321
+ (await menuItems.count()) === 3 &&
322
+ semanticsPass &&
323
+ geometryPass &&
324
+ paintPass,
325
+ };
326
+ fs.writeFileSync(path.join(OUTPUT, image), png);
327
+ fs.writeFileSync(
328
+ path.join(OUTPUT, `${item.key}.sensors.json`),
329
+ `${JSON.stringify(receipt, null, 2)}\n`,
330
+ );
331
+ expect(receipt.passed).toBe(true);
332
+ await context.close();
333
+ });
334
+ }
@@ -0,0 +1,43 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file warmMenuBottomSheet.tsx
5
+ * @input Uses DropdownMenu and Testing Library
6
+ * @output Exports warmMenuBottomSheet, a test helper that resolves the lazily
7
+ * loaded touch sheet once
8
+ * @position Test-only helper for suites that open a DropdownMenu bottom sheet
9
+ *
10
+ * DropdownMenu loads its touch sheet with React.lazy, so the first sheet
11
+ * renders a tick after it opens. Opening one up front caches the resolved
12
+ * modules on the lazy components, and every later sheet renders
13
+ * synchronously. Call it from `beforeAll`.
14
+ */
15
+
16
+ import {cleanup, render, screen} from '@testing-library/react';
17
+ import {DropdownMenu} from '../DropdownMenu';
18
+
19
+ export async function warmMenuBottomSheet(): Promise<void> {
20
+ const {showModal, close} = HTMLDialogElement.prototype;
21
+ HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
22
+ this.setAttribute('open', '');
23
+ };
24
+ HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
25
+ this.removeAttribute('open');
26
+ };
27
+ try {
28
+ render(
29
+ <DropdownMenu
30
+ button={{label: 'Warm'}}
31
+ presentation="bottom-sheet"
32
+ isMenuOpen
33
+ items={[{label: 'Warm item'}]}
34
+ />,
35
+ );
36
+ // A cold import transforms the sheet's module graph; allow for it.
37
+ await screen.findByText('Warm item', undefined, {timeout: 15000});
38
+ } finally {
39
+ cleanup();
40
+ HTMLDialogElement.prototype.showModal = showModal;
41
+ HTMLDialogElement.prototype.close = close;
42
+ }
43
+ }
@@ -0,0 +1,71 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file lazyMenuBottomSheet.ts
7
+ * @input Uses React lazy and Suspense, useIsHydrating, MenuBottomSheet, and
8
+ * MenuBottomSheetActionList
9
+ * @output Exports MenuBottomSheet and MenuBottomSheetActionList wrappers that
10
+ * load the touch sheet on first render
11
+ * @position Internal to DropdownMenu
12
+ *
13
+ * The touch sheet (BottomSheet, its gestures, and the touch action list) only
14
+ * renders for the bottom-sheet presentation. Loading it on demand keeps it out
15
+ * of pages whose dropdowns only open as anchored popovers. Each wrapper keeps
16
+ * the wrapped component's props and renders nothing until its module loads.
17
+ *
18
+ * Neither wrapper renders on the server or in the render that hydrates server
19
+ * HTML (spec:AST-013 FR9): a lazy component that has not loaded yet would
20
+ * leave server markup incomplete and abort hydration. The closed sheet has no
21
+ * visible markup, so it first renders right after hydration.
22
+ */
23
+
24
+ import {
25
+ createElement,
26
+ lazy,
27
+ Suspense,
28
+ type ComponentProps,
29
+ type ReactElement,
30
+ } from 'react';
31
+ import {useIsHydrating} from '../hooks/useIsHydrating';
32
+ import type {MenuBottomSheet as EagerMenuBottomSheet} from './MenuBottomSheet';
33
+ import type {MenuBottomSheetActionList as EagerMenuBottomSheetActionList} from './MenuBottomSheetActionList';
34
+
35
+ const LazyMenuBottomSheet = lazy(async () =>
36
+ import('./MenuBottomSheet').then(module => ({
37
+ default: module.MenuBottomSheet,
38
+ })),
39
+ );
40
+
41
+ const LazyMenuBottomSheetActionList = lazy(async () =>
42
+ import('./MenuBottomSheetActionList').then(module => ({
43
+ default: module.MenuBottomSheetActionList,
44
+ })),
45
+ );
46
+
47
+ export function MenuBottomSheet(
48
+ props: ComponentProps<typeof EagerMenuBottomSheet>,
49
+ ): ReactElement | null {
50
+ if (useIsHydrating()) {
51
+ return null;
52
+ }
53
+ return createElement(
54
+ Suspense,
55
+ {fallback: null},
56
+ createElement(LazyMenuBottomSheet, props),
57
+ );
58
+ }
59
+
60
+ export function MenuBottomSheetActionList(
61
+ props: ComponentProps<typeof EagerMenuBottomSheetActionList>,
62
+ ): ReactElement | null {
63
+ if (useIsHydrating()) {
64
+ return null;
65
+ }
66
+ return createElement(
67
+ Suspense,
68
+ {fallback: null},
69
+ createElement(LazyMenuBottomSheetActionList, props),
70
+ );
71
+ }
@@ -0,0 +1,167 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file menuRegionStops.ts
7
+ * @input The focus trap's own reachability test (getFocusableElements)
8
+ * @output `tabbablesIn`, `focusRegionControl`, `moveRegionsTab`: the Tab stops
9
+ * of a header or footer region and the walk through a menu with regions,
10
+ * with a named radio group as one stop
11
+ * @position Internal; DropdownMenu's walk through its header and footer, in a
12
+ * module of its own so another menu level with regions can share it.
13
+ *
14
+ * A menu with a header or footer is a dialog: Tab and Shift+Tab walk the
15
+ * header's controls, the menu as one stop, then the footer's controls, and
16
+ * wrap at either end.
17
+ */
18
+ import type React from 'react';
19
+ import {getFocusableElements} from '../hooks/useFocusTrap';
20
+
21
+ /**
22
+ * The elements Tab reaches inside a header or footer region, in order. Hidden
23
+ * controls (`hidden`, `display: none`, `visibility: hidden`, inert or
24
+ * aria-hidden subtrees) are not reachable, judged by the popover focus trap's
25
+ * own test so the two never disagree about where Tab can go.
26
+ */
27
+ export function tabbablesIn(region: HTMLElement | null): HTMLElement[] {
28
+ if (region == null) {
29
+ return [];
30
+ }
31
+ return getFocusableElements(region).filter(
32
+ element => element.tabIndex >= 0 && !isInsideHiddenWrapper(element, region),
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Whether a `display: none` ancestor between the element and its region hides
38
+ * it — a control in a wrapper hidden at some widths, say. The trap's test reads
39
+ * only the element's own display.
40
+ */
41
+ function isInsideHiddenWrapper(
42
+ element: HTMLElement,
43
+ region: HTMLElement,
44
+ ): boolean {
45
+ if (typeof window === 'undefined' || !window.getComputedStyle) {
46
+ return false;
47
+ }
48
+ for (
49
+ let node = element.parentElement;
50
+ node != null && node !== region.parentElement;
51
+ node = node.parentElement
52
+ ) {
53
+ if (window.getComputedStyle(node).display === 'none') {
54
+ return true;
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Collapse each named radio group to the one Tab stop a browser gives it: the
62
+ * focused radio, else the checked one, else the first radio moving forward or
63
+ * the last moving backward. The stop takes the group's first position.
64
+ */
65
+ function collapseRadioGroups(
66
+ elements: HTMLElement[],
67
+ active: Element | null,
68
+ isBackward: boolean,
69
+ ): HTMLElement[] {
70
+ const groups = new Map<string, HTMLInputElement[]>();
71
+ const groupKey = (el: HTMLElement): string | null =>
72
+ el instanceof HTMLInputElement &&
73
+ el.getAttribute('type') === 'radio' &&
74
+ el.name !== ''
75
+ ? `${el.form?.id ?? ''}\u0000${el.name}`
76
+ : null;
77
+ for (const el of elements) {
78
+ const key = groupKey(el);
79
+ if (key != null) {
80
+ const members = groups.get(key) ?? [];
81
+ members.push(el as HTMLInputElement);
82
+ groups.set(key, members);
83
+ }
84
+ }
85
+ const placed = new Set<string>();
86
+ const result: HTMLElement[] = [];
87
+ for (const el of elements) {
88
+ const key = groupKey(el);
89
+ if (key == null) {
90
+ result.push(el);
91
+ continue;
92
+ }
93
+ if (placed.has(key)) {
94
+ continue;
95
+ }
96
+ placed.add(key);
97
+ const members = groups.get(key) ?? [];
98
+ const stop =
99
+ members.find(radio => radio === active) ??
100
+ members.find(radio => radio.checked) ??
101
+ (isBackward ? members[members.length - 1] : members[0]);
102
+ result.push(stop);
103
+ }
104
+ return result;
105
+ }
106
+
107
+ /**
108
+ * Move focus to a Tab stop in the dialog. Reports whether focus actually
109
+ * arrived, so a caller can fall back to the menu instead of stranding focus on
110
+ * the page.
111
+ */
112
+ export function focusRegionControl(element: HTMLElement): boolean {
113
+ element.focus();
114
+ return element.ownerDocument.activeElement === element;
115
+ }
116
+
117
+ /**
118
+ * Handle Tab in a menu with a header or footer: move to the next stop of the
119
+ * header's controls, the menu as one stop, then the footer's controls,
120
+ * wrapping at either end. The key is handled in full, so a document-level
121
+ * focus trap never reads it a second time. A row, or a row of an open
122
+ * flyout below this menu, counts as the menu's stop; with no other stop,
123
+ * focus stays in the menu (`isFromFlyout`: a flyout that has just closed
124
+ * itself hands the focus back to the menu's own stop).
125
+ */
126
+ export function moveRegionsTab(
127
+ event: React.KeyboardEvent,
128
+ menu: HTMLElement | null,
129
+ header: HTMLElement | null,
130
+ footer: HTMLElement | null,
131
+ isFromFlyout = false,
132
+ ): void {
133
+ if (menu == null) {
134
+ return;
135
+ }
136
+ event.preventDefault();
137
+ event.stopPropagation();
138
+ const active = document.activeElement;
139
+ // A named radio group is one stop, as the browser's own Tab order has it.
140
+ const cycle = collapseRadioGroups(
141
+ [...tabbablesIn(header), menu, ...tabbablesIn(footer)],
142
+ active,
143
+ event.shiftKey,
144
+ );
145
+ if (cycle.length === 1) {
146
+ // Nothing else to reach: focus stays in the menu. From a flyout that has
147
+ // just closed itself, that is the menu's own stop.
148
+ if (isFromFlyout) {
149
+ menu.focus();
150
+ }
151
+ return;
152
+ }
153
+ const menuIndex = cycle.indexOf(menu);
154
+ let index = menu.contains(active)
155
+ ? menuIndex
156
+ : cycle.findIndex(
157
+ element => element === active || element.contains(active),
158
+ );
159
+ if (index < 0) {
160
+ index = menuIndex;
161
+ }
162
+ const step = event.shiftKey ? -1 : 1;
163
+ const next = cycle[(index + step + cycle.length) % cycle.length];
164
+ if (!focusRegionControl(next)) {
165
+ menu.focus();
166
+ }
167
+ }