@astryxdesign/core 0.7.2-canary.caec7ca → 0.7.2-canary.cbb7fa3

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 (125) hide show
  1. package/dist/AspectRatio/AspectRatio.d.ts +6 -8
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +44 -3
  4. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  5. package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  8. package/dist/CheckboxInput/CheckboxInput.js +3 -1
  9. package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
  10. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  11. package/dist/CommandPalette/CommandPaletteInput.js +37 -5
  12. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  13. package/dist/ContextMenu/ContextMenu.js +5 -2
  14. package/dist/Dialog/Dialog.d.ts.map +1 -1
  15. package/dist/Dialog/Dialog.js +20 -8
  16. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  17. package/dist/DropdownMenu/DropdownMenu.js +46 -3
  18. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
  19. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  20. package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
  21. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
  23. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  24. package/dist/EmptyState/EmptyState.js +4 -10
  25. package/dist/Field/Field.d.ts +1 -1
  26. package/dist/Field/Field.d.ts.map +1 -1
  27. package/dist/Field/Field.js +8 -4
  28. package/dist/Grid/GridSpan.d.ts.map +1 -1
  29. package/dist/Grid/GridSpan.js +1 -0
  30. package/dist/List/List.d.ts +46 -19
  31. package/dist/List/List.d.ts.map +1 -1
  32. package/dist/List/List.js +28 -10
  33. package/dist/List/ListContext.d.ts +8 -3
  34. package/dist/List/ListContext.d.ts.map +1 -1
  35. package/dist/List/ListContext.js +7 -2
  36. package/dist/List/ListItem.d.ts +4 -3
  37. package/dist/List/ListItem.d.ts.map +1 -1
  38. package/dist/List/ListItem.js +19 -8
  39. package/dist/List/index.d.ts +1 -1
  40. package/dist/List/index.d.ts.map +1 -1
  41. package/dist/Markdown/Markdown.d.ts.map +1 -1
  42. package/dist/Markdown/Markdown.js +8 -1
  43. package/dist/MetadataList/MetadataList.d.ts.map +1 -1
  44. package/dist/MetadataList/MetadataList.js +5 -1
  45. package/dist/MetadataList/MetadataListContext.d.ts +1 -1
  46. package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
  47. package/dist/RadioList/RadioList.d.ts +1 -1
  48. package/dist/RadioList/RadioList.d.ts.map +1 -1
  49. package/dist/RadioList/RadioList.js +6 -2
  50. package/dist/ToggleButton/ToggleButton.d.ts +3 -2
  51. package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
  52. package/dist/ToggleButton/ToggleButton.js +12 -1
  53. package/dist/TreeList/TreeList.d.ts.map +1 -1
  54. package/dist/TreeList/TreeList.js +1 -0
  55. package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
  56. package/dist/TreeList/TreeListBranches.js +2 -2
  57. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  58. package/dist/TreeList/TreeListItem.js +57 -9
  59. package/dist/astryx.css +16 -3
  60. package/dist/hooks/useMenuHover.d.ts.map +1 -1
  61. package/dist/hooks/useMenuHover.js +18 -13
  62. package/dist/utils/plainDate.d.ts.map +1 -1
  63. package/dist/utils/plainDate.js +15 -9
  64. package/package.json +3 -3
  65. package/src/AspectRatio/AspectRatio.test.tsx +44 -43
  66. package/src/AspectRatio/AspectRatio.tsx +38 -18
  67. package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
  68. package/src/Avatar/Avatar.doc.mjs +5 -3
  69. package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
  70. package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
  71. package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
  72. package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
  73. package/src/CheckboxInput/CheckboxInput.tsx +3 -1
  74. package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
  75. package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
  76. package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
  77. package/src/ContextMenu/ContextMenu.tsx +4 -1
  78. package/src/Dialog/Dialog.test.tsx +46 -0
  79. package/src/Dialog/Dialog.tsx +20 -7
  80. package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
  81. package/src/DropdownMenu/DropdownMenu.tsx +45 -2
  82. package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
  83. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
  84. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
  85. package/src/EmptyState/EmptyState.doc.mjs +3 -2
  86. package/src/EmptyState/EmptyState.spec.md +189 -0
  87. package/src/EmptyState/EmptyState.test.tsx +74 -3
  88. package/src/EmptyState/EmptyState.tsx +4 -8
  89. package/src/Field/Field.doc.mjs +14 -8
  90. package/src/Field/Field.spec.md +217 -0
  91. package/src/Field/Field.test.tsx +60 -2
  92. package/src/Field/Field.tsx +17 -8
  93. package/src/Grid/GridSpan.tsx +3 -0
  94. package/src/Item/Item.doc.mjs +1 -1
  95. package/src/Item/Item.tsx +1 -1
  96. package/src/List/List.doc.mjs +14 -1
  97. package/src/List/List.spec.md +129 -77
  98. package/src/List/List.test.tsx +144 -94
  99. package/src/List/List.tsx +74 -18
  100. package/src/List/ListContext.tsx +9 -3
  101. package/src/List/ListItem.tsx +46 -26
  102. package/src/List/index.ts +1 -1
  103. package/src/Markdown/Markdown.tsx +14 -1
  104. package/src/MetadataList/MetadataList.doc.mjs +2 -2
  105. package/src/MetadataList/MetadataList.test.tsx +38 -0
  106. package/src/MetadataList/MetadataList.tsx +8 -1
  107. package/src/MetadataList/MetadataListContext.tsx +1 -1
  108. package/src/RadioList/RadioList.fr10.test.tsx +45 -2
  109. package/src/RadioList/RadioList.tsx +4 -1
  110. package/src/Slider/Slider.spec.md +39 -18
  111. package/src/TabList/TabList.spec.md +57 -44
  112. package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
  113. package/src/ToggleButton/ToggleButton.test.tsx +95 -0
  114. package/src/ToggleButton/ToggleButton.tsx +12 -1
  115. package/src/TreeList/TreeList.doc.mjs +1 -0
  116. package/src/TreeList/TreeList.test.tsx +327 -6
  117. package/src/TreeList/TreeList.tsx +5 -1
  118. package/src/TreeList/TreeListBranches.tsx +6 -0
  119. package/src/TreeList/TreeListItem.tsx +88 -13
  120. package/src/docPropLiterals.test.ts +36 -10
  121. package/src/hooks/useMenuHover.ts +18 -13
  122. package/src/reset.css +0 -29
  123. package/src/theme/derivedVarRegistry.test.ts +7 -0
  124. package/src/utils/plainDate.test.ts +37 -1
  125. package/src/utils/plainDate.ts +15 -5
@@ -13,7 +13,7 @@
13
13
  * - /packages/cli/assets/templates/blocks/components/CommandPalette/ (showcase blocks)
14
14
  */
15
15
 
16
- import {useCallback, useEffect, useRef, type ReactNode} from 'react';
16
+ import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
17
17
  import * as stylex from '@stylexjs/stylex';
18
18
  import {Icon} from '../Icon';
19
19
  import {Spinner} from '../Spinner';
@@ -28,6 +28,11 @@ import {useCommandPaletteContext} from './CommandPaletteContext';
28
28
  import {useDialogContext} from '../Dialog/DialogContext';
29
29
  import type {BaseProps} from '../BaseProps';
30
30
  import {themeProps} from '../utils/themeProps';
31
+ import {focusOutlineProps} from '../utils/focusOutline.stylex';
32
+ import {
33
+ getInteractionModality,
34
+ useInteractionModalityTracking,
35
+ } from '../utils/interactionModality';
31
36
  import {useTranslator} from '../i18n';
32
37
 
33
38
  import {useMergedRefs} from '../hooks/useMergedRefs';
@@ -71,7 +76,6 @@ const styles = stylex.create({
71
76
  minWidth: 0,
72
77
  borderWidth: 0,
73
78
  borderStyle: 'none',
74
- outline: 'none',
75
79
  backgroundColor: 'transparent',
76
80
  color: colorVars['--color-text-primary'],
77
81
  fontFamily: typographyVars['--font-family-body'],
@@ -169,6 +173,8 @@ export function CommandPaletteInput({
169
173
  endContent,
170
174
  onChange,
171
175
  onKeyDown,
176
+ onFocus,
177
+ onBlur,
172
178
  ref,
173
179
  xstyle,
174
180
  className,
@@ -184,6 +190,26 @@ export function CommandPaletteInput({
184
190
  const hasListbox = ctx ? ctx.selectableItems.length > 0 : true;
185
191
  const dialogContext = useDialogContext();
186
192
  const inputRef = useRef<HTMLInputElement>(null);
193
+ const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
194
+
195
+ // Text-entry controls match :focus-visible after pointer focus in Chromium.
196
+ // Keep the shared selector, but narrow its ring to focus acquired through
197
+ // keyboard navigation so a click or pointer-opened palette stays unringed.
198
+ useInteractionModalityTracking();
199
+ const handleFocus = useCallback(
200
+ (event: React.FocusEvent<HTMLInputElement>) => {
201
+ setIsKeyboardFocus(getInteractionModality() === 'keyboard');
202
+ onFocus?.(event);
203
+ },
204
+ [onFocus],
205
+ );
206
+ const handleBlur = useCallback(
207
+ (event: React.FocusEvent<HTMLInputElement>) => {
208
+ setIsKeyboardFocus(false);
209
+ onBlur?.(event);
210
+ },
211
+ [onBlur],
212
+ );
187
213
 
188
214
  // Use context values as fallback
189
215
  const value = controlledValue ?? ctx?.search;
@@ -259,7 +285,9 @@ export function CommandPaletteInput({
259
285
  <input
260
286
  {...props}
261
287
  ref={useMergedRefs(ref, inputRef)}
262
- {...stylex.props(styles.input)}
288
+ {...(isKeyboardFocus
289
+ ? focusOutlineProps.focusVisible(styles.input)
290
+ : focusOutlineProps.suppressed(styles.input))}
263
291
  type="text"
264
292
  role={ctx ? 'combobox' : standaloneRole}
265
293
  aria-expanded={
@@ -300,6 +328,8 @@ export function CommandPaletteInput({
300
328
  onChange?.(e);
301
329
  }}
302
330
  onKeyDown={handleKeyDown}
331
+ onFocus={handleFocus}
332
+ onBlur={handleBlur}
303
333
  />
304
334
  {(ctx?.isBusy || hasEndContent) && (
305
335
  <span {...stylex.props(styles.end)}>
@@ -0,0 +1,335 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file CommandPaletteInputAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head CommandPaletteInput stories across semantic, theme, direction, viewport, and focus states
6
+ * @output Screenshots and fail-closed sensor receipts for the CommandPaletteInput whole-component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a CommandPaletteInput audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Locator, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('command-palette-input-audit-evidence');
23
+ const WIDE = {width: 1024, height: 768};
24
+ const NARROW = {width: 320, height: 720};
25
+
26
+ type Mode = 'light' | 'dark';
27
+ type Direction = 'ltr' | 'rtl';
28
+ type Theme = 'neutral' | 'butter';
29
+ type Story = 'standalone' | 'popup-context' | 'aggregate-matrix';
30
+ type State = 'rest' | 'keyboard-focus' | 'pointer-focus' | 'context-matrix';
31
+
32
+ type AuditReceipt = {
33
+ passed: boolean;
34
+ expected: Record<string, unknown>;
35
+ };
36
+
37
+ type AuditCase = {
38
+ key: string;
39
+ story: Story;
40
+ mode: Mode;
41
+ direction: Direction;
42
+ theme: Theme;
43
+ state: State;
44
+ viewport: {width: number; height: number};
45
+ };
46
+
47
+ const cases: AuditCase[] = [];
48
+ function addPair(
49
+ key: string,
50
+ options: Partial<Omit<AuditCase, 'key' | 'mode'>> = {},
51
+ ) {
52
+ for (const mode of ['light', 'dark'] as const) {
53
+ cases.push({
54
+ key: `${key}-${mode}`,
55
+ story: options.story ?? 'standalone',
56
+ mode,
57
+ direction: options.direction ?? 'ltr',
58
+ theme: options.theme ?? 'neutral',
59
+ state: options.state ?? 'rest',
60
+ viewport: options.viewport ?? WIDE,
61
+ });
62
+ }
63
+ }
64
+
65
+ addPair('standalone-rest');
66
+ addPair('standalone-keyboard-focus', {state: 'keyboard-focus'});
67
+ addPair('standalone-pointer-focus', {state: 'pointer-focus'});
68
+ addPair('standalone-keyboard-focus-custom-theme', {
69
+ state: 'keyboard-focus',
70
+ theme: 'butter',
71
+ });
72
+ addPair('standalone-keyboard-focus-rtl', {
73
+ state: 'keyboard-focus',
74
+ direction: 'rtl',
75
+ });
76
+ addPair('standalone-keyboard-focus-narrow', {
77
+ state: 'keyboard-focus',
78
+ viewport: NARROW,
79
+ });
80
+ addPair('popup-rest', {story: 'popup-context'});
81
+ addPair('popup-keyboard-focus', {
82
+ story: 'popup-context',
83
+ state: 'keyboard-focus',
84
+ });
85
+ addPair('popup-pointer-focus', {
86
+ story: 'popup-context',
87
+ state: 'pointer-focus',
88
+ });
89
+ addPair('context-matrix', {
90
+ story: 'aggregate-matrix',
91
+ state: 'context-matrix',
92
+ });
93
+
94
+ test.describe.configure({mode: 'serial', retries: 0});
95
+ let server: StaticServer;
96
+ let checkoutSha: string;
97
+ let storybookSha: string;
98
+
99
+ function storyId(item: AuditCase) {
100
+ return item.story === 'aggregate-matrix'
101
+ ? 'core-commandpalette--audit-matrix'
102
+ : `core-commandpaletteinput--${item.story}`;
103
+ }
104
+
105
+ function storyUrl(item: AuditCase) {
106
+ return `${server.origin}/iframe.html?id=${storyId(item)}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
107
+ }
108
+
109
+ async function settle(page: Page) {
110
+ await holdMotionStill(page);
111
+ await page.evaluate(async () => document.fonts.ready);
112
+ await page.waitForTimeout(40);
113
+ }
114
+
115
+ async function focusFromKeyboard(page: Page, input: Locator) {
116
+ await page.keyboard.press('Tab');
117
+ if (!(await input.evaluate(element => element === document.activeElement))) {
118
+ await input.focus();
119
+ }
120
+ await expect(input).toBeFocused();
121
+ expect(
122
+ await input.evaluate(element => element.matches(':focus-visible')),
123
+ ).toBe(true);
124
+ }
125
+
126
+ async function focusFromPointer(input: Locator) {
127
+ // PopupContext is already focused by Dialog's open-time focus management.
128
+ // Move focus away first so the click acquires focus and runs the component's
129
+ // pointer-modality gate rather than merely clicking an existing keyboard focus.
130
+ await input.blur();
131
+ await expect(input).not.toBeFocused();
132
+ await input.click();
133
+ await expect(input).toBeFocused();
134
+ // CSS Selectors 4 deliberately keeps text-entry controls focus-visible after
135
+ // a pointer click. The component's shared modality gate must still withhold
136
+ // its keyboard-only ring.
137
+ expect(
138
+ await input.evaluate(element => element.matches(':focus-visible')),
139
+ ).toBe(true);
140
+ }
141
+
142
+ test.beforeAll(async () => {
143
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
144
+ fs.mkdirSync(OUTPUT, {recursive: true});
145
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
146
+ encoding: 'utf8',
147
+ }).trim();
148
+ const expected = process.env.ASTRYX_HEAD_SHA;
149
+ if (expected && expected !== checkoutSha) {
150
+ throw new Error(
151
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
152
+ );
153
+ }
154
+ server = await serveStorybook(
155
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
156
+ );
157
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
158
+ if (!response.ok) {
159
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
160
+ }
161
+ storybookSha = (await response.text()).trim();
162
+ if (storybookSha !== checkoutSha) {
163
+ throw new Error(
164
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
165
+ );
166
+ }
167
+ });
168
+
169
+ test.afterAll(async () => {
170
+ await server?.close();
171
+ });
172
+
173
+ for (const item of cases) {
174
+ test(item.key, async ({browser}) => {
175
+ const context = await browser.newContext({
176
+ viewport: item.viewport,
177
+ colorScheme: item.mode,
178
+ reducedMotion: 'reduce',
179
+ });
180
+ const page = await context.newPage();
181
+ const pageErrors: string[] = [];
182
+ page.on('pageerror', error => pageErrors.push(String(error)));
183
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
184
+ await page.locator('#storybook-root').waitFor();
185
+
186
+ let input: Locator;
187
+ if (item.story === 'aggregate-matrix') {
188
+ const matrix = page.locator('[data-command-palette-audit-matrix]');
189
+ await expect(matrix).toBeVisible();
190
+ await expect(matrix.getByRole('combobox')).toHaveCount(3);
191
+ await expect(matrix.locator('.astryx-spinner')).toHaveCount(1);
192
+ await expect(matrix.getByRole('option')).not.toHaveCount(0);
193
+ input = matrix
194
+ .getByRole('region', {name: 'Grouped command palette'})
195
+ .getByRole('combobox');
196
+ } else if (item.story === 'popup-context') {
197
+ input = page.getByRole('combobox', {name: 'Search commands'});
198
+ await expect(input).toHaveAttribute('aria-expanded', 'true');
199
+ await expect(input).toHaveAttribute('aria-controls', /.+/);
200
+ } else {
201
+ input = page.getByRole('searchbox', {name: 'Search documentation'});
202
+ await expect(input).not.toHaveAttribute('aria-expanded');
203
+ await expect(input).not.toHaveAttribute('aria-controls');
204
+ await expect(page.getByTestId('input-end-content')).toBeVisible();
205
+ }
206
+
207
+ await expect(input).toBeVisible();
208
+ if (item.state === 'keyboard-focus') {
209
+ await focusFromKeyboard(page, input);
210
+ } else if (item.state === 'pointer-focus') {
211
+ await focusFromPointer(input);
212
+ }
213
+ await settle(page);
214
+
215
+ const inputBox = await input.boundingBox();
216
+ if (inputBox == null) {
217
+ throw new Error(`${item.key}: the visible input has no geometry`);
218
+ }
219
+ const observed = await page.evaluate(() => ({
220
+ theme: document
221
+ .querySelector('[data-astryx-theme]')
222
+ ?.getAttribute('data-astryx-theme'),
223
+ mode: document.documentElement.getAttribute('data-theme'),
224
+ direction: getComputedStyle(
225
+ document.querySelector('.astryx-command-palette-input') as HTMLElement,
226
+ ).direction,
227
+ viewport: {width: innerWidth, height: innerHeight},
228
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
229
+ horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
230
+ fonts: document.fonts.status,
231
+ }));
232
+ const focus = await input.evaluate(element => {
233
+ const style = getComputedStyle(element);
234
+ return {
235
+ focused: element === document.activeElement,
236
+ focusVisible: element.matches(':focus-visible'),
237
+ outlineWidth: style.outlineWidth,
238
+ outlineStyle: style.outlineStyle,
239
+ outlineColor: style.outlineColor,
240
+ outlineOffset: style.outlineOffset,
241
+ };
242
+ });
243
+ const ringVisible =
244
+ focus.outlineStyle !== 'none' &&
245
+ Number.parseFloat(focus.outlineWidth) >= 2;
246
+ const focusIndicatorPass =
247
+ item.state === 'keyboard-focus'
248
+ ? focus.focused && focus.focusVisible && ringVisible
249
+ : item.state === 'pointer-focus'
250
+ ? focus.focused && focus.focusVisible && !ringVisible
251
+ : true;
252
+
253
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
254
+ const image = `${item.key}.png`;
255
+ const receipt = {
256
+ schemaVersion: 1,
257
+ expected: {
258
+ build: checkoutSha,
259
+ storyId: storyId(item),
260
+ mode: item.mode,
261
+ direction: item.direction,
262
+ theme: item.theme,
263
+ state: item.state,
264
+ viewport: item.viewport,
265
+ },
266
+ observed: {
267
+ build: storybookSha,
268
+ ...observed,
269
+ role: await input.getAttribute('role'),
270
+ name: await input.getAttribute('aria-label'),
271
+ geometry: inputBox,
272
+ focus,
273
+ pageErrors,
274
+ },
275
+ image: {
276
+ file: image,
277
+ sha256: createHash('sha256').update(png).digest('hex'),
278
+ width: png.readUInt32BE(16),
279
+ height: png.readUInt32BE(20),
280
+ },
281
+ checks: {focusIndicatorPass},
282
+ passed:
283
+ storybookSha === checkoutSha &&
284
+ pageErrors.length === 0 &&
285
+ observed.theme === item.theme &&
286
+ observed.mode === item.mode &&
287
+ observed.direction === item.direction &&
288
+ observed.viewport.width === item.viewport.width &&
289
+ observed.viewport.height === item.viewport.height &&
290
+ observed.reducedMotion &&
291
+ !observed.horizontalOverflow &&
292
+ observed.fonts === 'loaded' &&
293
+ focusIndicatorPass,
294
+ };
295
+ fs.writeFileSync(path.join(OUTPUT, image), png);
296
+ fs.writeFileSync(
297
+ path.join(OUTPUT, `${item.key}.sensors.json`),
298
+ `${JSON.stringify(receipt, null, 2)}\n`,
299
+ );
300
+
301
+ await context.close();
302
+ });
303
+ }
304
+
305
+ test('manifest is complete', async () => {
306
+ const receiptFiles = fs
307
+ .readdirSync(OUTPUT)
308
+ .filter(file => file.endsWith('.sensors.json'));
309
+ const receipts = receiptFiles.map(
310
+ (file): AuditReceipt =>
311
+ JSON.parse(
312
+ fs.readFileSync(path.join(OUTPUT, file), 'utf8'),
313
+ ) as AuditReceipt,
314
+ );
315
+ const manifest = {
316
+ schemaVersion: 1,
317
+ component: 'core/CommandPaletteInput',
318
+ build: checkoutSha,
319
+ frames: cases.map(item => ({
320
+ key: item.key,
321
+ image: `${item.key}.png`,
322
+ receipt: `${item.key}.sensors.json`,
323
+ })),
324
+ };
325
+ fs.writeFileSync(
326
+ path.join(OUTPUT, 'manifest.json'),
327
+ `${JSON.stringify(manifest, null, 2)}\n`,
328
+ );
329
+ expect(receiptFiles).toHaveLength(cases.length);
330
+ expect(
331
+ receipts
332
+ .filter(receipt => !receipt.passed)
333
+ .map(receipt => receipt.expected),
334
+ ).toEqual([]);
335
+ });
@@ -56,6 +56,7 @@ import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionLi
56
56
  import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems';
57
57
  import {
58
58
  DropdownMenuContext,
59
+ DropdownMenuRootOpenContext,
59
60
  type DropdownMenuContextValue,
60
61
  } from '../DropdownMenu/DropdownMenuContext';
61
62
  import {
@@ -701,7 +702,9 @@ export function ContextMenu({
701
702
  style,
702
703
  )}>
703
704
  <DropdownMenuContext value={contextValue}>
704
- {resolvedMenuContent}
705
+ <DropdownMenuRootOpenContext value={isOpen}>
706
+ {resolvedMenuContent}
707
+ </DropdownMenuRootOpenContext>
705
708
  </DropdownMenuContext>
706
709
  </div>
707
710
  );
@@ -110,6 +110,52 @@ beforeEach(() => {
110
110
  });
111
111
 
112
112
  describe('Dialog', () => {
113
+ it('restores focus to the opener when content mounts in the open commit', () => {
114
+ function Harness({
115
+ open,
116
+ onOpenChange,
117
+ }: {
118
+ open: boolean;
119
+ onOpenChange: (v: boolean) => void;
120
+ }) {
121
+ return (
122
+ <>
123
+ <button type="button" onClick={() => onOpenChange(true)}>
124
+ Open
125
+ </button>
126
+ <Dialog isOpen={open} onOpenChange={onOpenChange}>
127
+ {open ? (
128
+ <div>
129
+ <DialogHeader title="Review" />
130
+ <p>Body</p>
131
+ </div>
132
+ ) : null}
133
+ </Dialog>
134
+ </>
135
+ );
136
+ }
137
+
138
+ let open = false;
139
+ const setOpen = (v: boolean) => {
140
+ open = v;
141
+ };
142
+ const harness = render(<Harness open={open} onOpenChange={setOpen} />);
143
+ const opener = screen.getByRole('button', {name: 'Open'});
144
+ opener.focus();
145
+
146
+ fireEvent.click(opener);
147
+ harness.rerender(<Harness open={open} onOpenChange={setOpen} />);
148
+
149
+ // The freshly mounted header title is the focused screen reader anchor
150
+ // while the dialog is open...
151
+ expect(document.activeElement).toBe(screen.getByText('Review'));
152
+
153
+ // ...but closing restores the element that opened the dialog, not the
154
+ // (now unmounted) title.
155
+ harness.rerender(<Harness open={false} onOpenChange={setOpen} />);
156
+ expect(document.activeElement).toBe(opener);
157
+ });
158
+
113
159
  it('renders when isOpen is true', () => {
114
160
  render(
115
161
  <Dialog isOpen={true} onOpenChange={() => {}}>
@@ -32,6 +32,7 @@ import {
32
32
  useCallback,
33
33
  useEffect,
34
34
  useId,
35
+ useLayoutEffect,
35
36
  useMemo,
36
37
  useRef,
37
38
  type ReactNode,
@@ -505,12 +506,26 @@ export function Dialog({
505
506
 
506
507
  const mergedDialogRef = useMergedRefs(ref, attachDialog);
507
508
 
508
- // Capture the element that was focused when the dialog opened,
509
- // for directional animation origin and focus restoration on close.
509
+ // Capture the element that was focused when the dialog opened, for
510
+ // directional animation origin and focus restoration on close.
510
511
  const triggerElementRef = useRef<HTMLElement | null>(null);
511
512
 
512
513
  useInteractionModalityTracking();
513
514
 
515
+ // This runs in a layout effect so the capture lands before any descendant's
516
+ // passive mount effect: all layout effects run before any passive effect,
517
+ // while a child's passive effect runs before this parent's passive effect.
518
+ // Content that mounts in the open commit — e.g. a DialogHeader focusing its
519
+ // own title on mount — would otherwise move document.activeElement inside
520
+ // the dialog before the capture ran, and closing would then restore focus
521
+ // to a detached node, stranding the user's focus on <body>.
522
+ useLayoutEffect(() => {
523
+ if (isInline || !isOpen) {
524
+ return;
525
+ }
526
+ triggerElementRef.current = document.activeElement as HTMLElement | null;
527
+ }, [isOpen, isInline]);
528
+
514
529
  // Derive dismissal behavior from purpose
515
530
  const allowEscape = purpose !== 'required';
516
531
  const allowBackdropClick = purpose === 'info';
@@ -526,11 +541,9 @@ export function Dialog({
526
541
  }
527
542
 
528
543
  if (isOpen) {
529
- // Capture the currently focused element as the trigger — used for
530
- // directional animation origin and focus restoration on close.
531
- triggerElementRef.current = document.activeElement as HTMLElement | null;
532
-
533
- // Set directional CSS custom properties before opening
544
+ // Set directional CSS custom properties before opening. The trigger was
545
+ // captured by the layout effect above, before any descendant effect
546
+ // could move focus.
534
547
  const trigger = triggerElementRef.current;
535
548
  if (trigger && trigger !== document.body) {
536
549
  const dir = getDialogDirection(trigger);
@@ -25,18 +25,4 @@ export const DIALOG_MODAL_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
25
25
  reason:
26
26
  'This migration records the existing focus-order violation without changing the overlapping Dialog implementation path.',
27
27
  },
28
- {
29
- expectation: 'modal-dialog.focus.restored',
30
- binding: 'Dialog',
31
- state: 'conditional-content-focus-restoration',
32
- evidenceLayer: 'real-browser',
33
- failureEquals:
34
- 'closing the modal dialog did not return focus to its still-available invoker',
35
- standardsReference:
36
- 'Astryx component:Dialog FR4 — closing a native Dialog must return focus to its still-connected invoker when it can receive focus.',
37
- userImpact:
38
- 'When focus-moving content mounts only while the dialog is open, closing can leave the user at the document body instead of the control that opened the task.',
39
- reason:
40
- 'This migration records the existing restoration violation without changing the overlapping Dialog implementation path.',
41
- },
42
28
  ];
@@ -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';
@@ -987,6 +988,43 @@ function DropdownMenuPopover({
987
988
  // above it ends the gesture in flight through this ref.
988
989
  const cancelMenuPressRef = useRef<() => void>(() => {});
989
990
 
991
+ // Submenu flyouts are `popover="manual"` elements nested inside the menu's
992
+ // `popover="auto"` element, so the browser's light dismiss of the menu never
993
+ // reaches them: without a sweep they keep their open state and their
994
+ // top-layer entry while the menu is hidden, and re-anchor to the viewport
995
+ // corner when the menu reopens (#6893). Closing them through the popover
996
+ // API lets useLayer's toggle handling reset their state. Only flyouts that
997
+ // are actually open are touched: `:popover-open` ships wherever
998
+ // `hidePopover` does (they are one API), and the display fallback keeps its
999
+ // own open signal (`display: block`) so it is guarded the same way.
1000
+ const hideOpenFlyouts = useCallback(() => {
1001
+ const menuPopover = contentRef.current;
1002
+ if (!menuPopover) {
1003
+ return;
1004
+ }
1005
+ // Innermost first: hiding a flyout may unmount the flyouts nested inside
1006
+ // it, and a detached element no longer has the toggle listener that
1007
+ // resets its open state.
1008
+ const flyouts = [
1009
+ ...menuPopover.querySelectorAll<HTMLElement>('[popover]'),
1010
+ ].reverse();
1011
+ for (const flyout of flyouts) {
1012
+ if (typeof flyout.hidePopover === 'function') {
1013
+ if (flyout.matches(':popover-open')) {
1014
+ flyout.hidePopover();
1015
+ }
1016
+ } else if (flyout.style.display === 'block') {
1017
+ flyout.style.display = 'none';
1018
+ const toggle = new Event('toggle', {bubbles: false});
1019
+ Object.defineProperty(toggle, 'newState', {
1020
+ value: 'closed',
1021
+ configurable: true,
1022
+ });
1023
+ flyout.dispatchEvent(toggle);
1024
+ }
1025
+ }
1026
+ }, []);
1027
+
990
1028
  // Focus lands somewhere stated when the menu closes. A press outside that
991
1029
  // landed on a focusable control has already moved focus there; leave it.
992
1030
  // Otherwise — a row acted, Escape, a press on nothing — focus returns to
@@ -994,6 +1032,9 @@ function DropdownMenuPopover({
994
1032
  // keyboard (the modality tracker decides; the suppressed style hides the
995
1033
  // pointer case, which Safari would otherwise paint after a touch pick).
996
1034
  const handleLayerHide = useCallback(() => {
1035
+ // Whatever closed the menu — light dismiss, the dismissal stack, Escape
1036
+ // or a controlled close — its flyouts close with it.
1037
+ hideOpenFlyouts();
997
1038
  if (suppressControlledRollbackHideRef.current) {
998
1039
  suppressControlledRollbackHideRef.current = false;
999
1040
  return;
@@ -1024,7 +1065,7 @@ function DropdownMenuPopover({
1024
1065
  // also reaches a control the caller renders, which the browser would
1025
1066
  // otherwise ring (Chromium after a mouse pick, WebKit after a touch).
1026
1067
  focusForLastInput(trigger, {preventScroll: true});
1027
- }, [isControlled, onOpenChange, prepareFocusReturn]);
1068
+ }, [hideOpenFlyouts, isControlled, onOpenChange, prepareFocusReturn]);
1028
1069
 
1029
1070
  // Defer item focus until the layer has committed open, so focus restore
1030
1071
  // captures the trigger instead of the first menu item.
@@ -1694,7 +1735,9 @@ function DropdownMenuPopover({
1694
1735
  )}>
1695
1736
  <MenuRegionsContext value={hasRegions}>
1696
1737
  <DropdownMenuContext value={contextValue}>
1697
- {menuContent}
1738
+ <DropdownMenuRootOpenContext value={isOpen}>
1739
+ {menuContent}
1740
+ </DropdownMenuRootOpenContext>
1698
1741
  </DropdownMenuContext>
1699
1742
  </MenuRegionsContext>
1700
1743
  </div>,
@@ -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';