@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.
- package/dist/AspectRatio/AspectRatio.d.ts +6 -8
- package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
- package/dist/AspectRatio/AspectRatio.js +44 -3
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +5 -2
- package/dist/CheckboxInput/CheckboxInput.d.ts +1 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +3 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts +1 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +37 -5
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +5 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +20 -8
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +46 -3
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -0
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +21 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +15 -4
- package/dist/EmptyState/EmptyState.d.ts.map +1 -1
- package/dist/EmptyState/EmptyState.js +4 -10
- package/dist/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +8 -4
- package/dist/Grid/GridSpan.d.ts.map +1 -1
- package/dist/Grid/GridSpan.js +1 -0
- package/dist/List/List.d.ts +46 -19
- package/dist/List/List.d.ts.map +1 -1
- package/dist/List/List.js +28 -10
- package/dist/List/ListContext.d.ts +8 -3
- package/dist/List/ListContext.d.ts.map +1 -1
- package/dist/List/ListContext.js +7 -2
- package/dist/List/ListItem.d.ts +4 -3
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +19 -8
- package/dist/List/index.d.ts +1 -1
- package/dist/List/index.d.ts.map +1 -1
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +8 -1
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +5 -1
- package/dist/MetadataList/MetadataListContext.d.ts +1 -1
- package/dist/MetadataList/MetadataListContext.d.ts.map +1 -1
- package/dist/RadioList/RadioList.d.ts +1 -1
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +6 -2
- package/dist/ToggleButton/ToggleButton.d.ts +3 -2
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +12 -1
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +1 -0
- package/dist/TreeList/TreeListBranches.d.ts.map +1 -1
- package/dist/TreeList/TreeListBranches.js +2 -2
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +57 -9
- package/dist/astryx.css +16 -3
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +18 -13
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +15 -9
- package/package.json +3 -3
- package/src/AspectRatio/AspectRatio.test.tsx +44 -43
- package/src/AspectRatio/AspectRatio.tsx +38 -18
- package/src/AspectRatio/__tests__/AspectRatioFitLayers.a11y.browser.spec.ts +157 -0
- package/src/Avatar/Avatar.doc.mjs +5 -3
- package/src/AvatarGroup/AvatarGroup.doc.mjs +3 -2
- package/src/Breadcrumbs/BreadcrumbItem.tsx +4 -1
- package/src/Chat/__tests__/ChatMessageMetadata.a11y.chromium.spec.ts +3 -1
- package/src/CheckboxInput/CheckboxInput.test.tsx +34 -0
- package/src/CheckboxInput/CheckboxInput.tsx +3 -1
- package/src/CommandPalette/CommandPaletteInput.test.tsx +53 -4
- package/src/CommandPalette/CommandPaletteInput.tsx +33 -3
- package/src/CommandPalette/__tests__/CommandPaletteInputAudit.a11y.chromium.spec.ts +335 -0
- package/src/ContextMenu/ContextMenu.tsx +4 -1
- package/src/Dialog/Dialog.test.tsx +46 -0
- package/src/Dialog/Dialog.tsx +20 -7
- package/src/Dialog/__tests__/Dialog.a11y.known-failures.ts +0 -14
- package/src/DropdownMenu/DropdownMenu.tsx +45 -2
- package/src/DropdownMenu/DropdownMenuContext.tsx +24 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +185 -0
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +17 -3
- package/src/EmptyState/EmptyState.doc.mjs +3 -2
- package/src/EmptyState/EmptyState.spec.md +189 -0
- package/src/EmptyState/EmptyState.test.tsx +74 -3
- package/src/EmptyState/EmptyState.tsx +4 -8
- package/src/Field/Field.doc.mjs +14 -8
- package/src/Field/Field.spec.md +217 -0
- package/src/Field/Field.test.tsx +60 -2
- package/src/Field/Field.tsx +17 -8
- package/src/Grid/GridSpan.tsx +3 -0
- package/src/Item/Item.doc.mjs +1 -1
- package/src/Item/Item.tsx +1 -1
- package/src/List/List.doc.mjs +14 -1
- package/src/List/List.spec.md +129 -77
- package/src/List/List.test.tsx +144 -94
- package/src/List/List.tsx +74 -18
- package/src/List/ListContext.tsx +9 -3
- package/src/List/ListItem.tsx +46 -26
- package/src/List/index.ts +1 -1
- package/src/Markdown/Markdown.tsx +14 -1
- package/src/MetadataList/MetadataList.doc.mjs +2 -2
- package/src/MetadataList/MetadataList.test.tsx +38 -0
- package/src/MetadataList/MetadataList.tsx +8 -1
- package/src/MetadataList/MetadataListContext.tsx +1 -1
- package/src/RadioList/RadioList.fr10.test.tsx +45 -2
- package/src/RadioList/RadioList.tsx +4 -1
- package/src/Slider/Slider.spec.md +39 -18
- package/src/TabList/TabList.spec.md +57 -44
- package/src/TimeInput/TimeInput.fr10.test.tsx +15 -0
- package/src/ToggleButton/ToggleButton.test.tsx +95 -0
- package/src/ToggleButton/ToggleButton.tsx +12 -1
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/TreeList/TreeList.test.tsx +327 -6
- package/src/TreeList/TreeList.tsx +5 -1
- package/src/TreeList/TreeListBranches.tsx +6 -0
- package/src/TreeList/TreeListItem.tsx +88 -13
- package/src/docPropLiterals.test.ts +36 -10
- package/src/hooks/useMenuHover.ts +18 -13
- package/src/reset.css +0 -29
- package/src/theme/derivedVarRegistry.test.ts +7 -0
- package/src/utils/plainDate.test.ts +37 -1
- 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
|
-
{...
|
|
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
|
-
{
|
|
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={() => {}}>
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
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
|
-
{
|
|
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';
|