@astryxdesign/core 0.7.4-canary.f32572e → 0.7.4
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/CHANGELOG.md +40 -0
- package/dist/Chat/ChatMessageList.d.ts.map +1 -1
- package/dist/Chat/ChatMessageList.js +8 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +6 -0
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +6 -11
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +7 -1
- package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -0
- package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteItem.js +5 -1
- package/dist/DateInput/DateInput.d.ts +6 -4
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +5 -1
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +5 -1
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +5 -1
- package/dist/DateInput/tokens.stylex.d.ts.map +1 -1
- package/dist/DateInput/tokens.stylex.js +3 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +16 -2
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +9 -2
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.spec.md +1 -0
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +25 -2
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +7 -6
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +221 -1
- package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
- package/src/Chat/ChatMessageList.test.tsx +6 -2
- package/src/Chat/ChatMessageList.tsx +7 -1
- package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/CommandPalette/CommandPalette.doc.mjs +4 -1
- package/src/CommandPalette/CommandPalette.test.tsx +4 -2
- package/src/CommandPalette/CommandPalette.tsx +6 -11
- package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
- package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
- package/src/CommandPalette/CommandPaletteItem.doc.mjs +17 -5
- package/src/CommandPalette/CommandPaletteItem.test.tsx +65 -12
- package/src/CommandPalette/CommandPaletteItem.tsx +6 -1
- package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +250 -20
- package/src/DateInput/DateInput.doc.mjs +23 -5
- package/src/DateInput/DateInput.spec.md +52 -45
- package/src/DateInput/DateInput.tsx +13 -7
- package/src/DateInput/NativeDateField.tsx +6 -2
- package/src/DateInput/TouchDateField.tsx +6 -2
- package/src/DateInput/__tests__/DateInputAudit.a11y.chromium.spec.ts +576 -0
- package/src/DateInput/tokens.stylex.ts +3 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
- package/src/FileInput/FileInput.tsx +18 -2
- package/src/Overlay/Overlay.spec.md +304 -0
- package/src/Thumbnail/Thumbnail.test.tsx +6 -1
- package/src/Thumbnail/Thumbnail.tsx +12 -3
- package/src/__tests__/BusyOwnerAnnouncement.test.tsx +324 -2
|
@@ -377,7 +377,9 @@ describe('CommandPaletteInput popup context', () => {
|
|
|
377
377
|
</CommandPaletteContext>,
|
|
378
378
|
);
|
|
379
379
|
|
|
380
|
-
|
|
380
|
+
// The Spinner is decorative (spec:AST-086 FR8), so find it by its class.
|
|
381
|
+
const spinner = document.querySelector('.astryx-spinner') as HTMLElement;
|
|
382
|
+
expect(spinner).toHaveAttribute('aria-hidden', 'true');
|
|
381
383
|
const trailingContent = screen.getByTestId('trailing-content');
|
|
382
384
|
expect(trailingContent.parentElement?.firstElementChild).toContainElement(
|
|
383
385
|
spinner,
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import {useTranslator} from '../i18n';
|
|
37
37
|
|
|
38
38
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
39
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
39
40
|
const styles = stylex.create({
|
|
40
41
|
wrapper: {
|
|
41
42
|
display: 'flex',
|
|
@@ -190,6 +191,14 @@ export function CommandPaletteInput({
|
|
|
190
191
|
const hasListbox = ctx ? ctx.selectableItems.length > 0 : true;
|
|
191
192
|
const dialogContext = useDialogContext();
|
|
192
193
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
194
|
+
// The search Spinner is decorative; the input announces each busy start,
|
|
195
|
+
// including the load on opening or clearing the query. Inside the palette's
|
|
196
|
+
// native modal it does so from a region in the dialog (spec:AST-086 FR8, FR3).
|
|
197
|
+
useBusyAnnouncement(
|
|
198
|
+
ctx?.isBusy === true,
|
|
199
|
+
t('@astryx.commandPalette.loading'),
|
|
200
|
+
inputRef,
|
|
201
|
+
);
|
|
193
202
|
const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
|
|
194
203
|
|
|
195
204
|
// Text-entry controls match :focus-visible after pointer focus in Chromium.
|
|
@@ -335,7 +344,7 @@ export function CommandPaletteInput({
|
|
|
335
344
|
<span {...stylex.props(styles.end)}>
|
|
336
345
|
{ctx?.isBusy && (
|
|
337
346
|
<span {...stylex.props(styles.icon, styles.spinner)}>
|
|
338
|
-
<Spinner size="sm" />
|
|
347
|
+
<Spinner size="sm" aria-hidden="true" />
|
|
339
348
|
</span>
|
|
340
349
|
)}
|
|
341
350
|
{hasEndContent && endContent}
|
|
@@ -21,7 +21,12 @@ export const docs = {
|
|
|
21
21
|
{
|
|
22
22
|
guidance: true,
|
|
23
23
|
description:
|
|
24
|
-
'
|
|
24
|
+
'Inside CommandPalette, each item is an option in the listbox popup of the WAI-ARIA combobox pattern: keyboard focus stays in the query field, arrow keys move the highlight through aria-activedescendant, and Enter selects the highlighted item.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
guidance: false,
|
|
28
|
+
description:
|
|
29
|
+
'Nest buttons, links, or other interactive controls inside an item. Option semantics make descendants presentational, so use non-interactive content such as icons, descriptions, and shortcut hints, and activate the option itself.',
|
|
25
30
|
},
|
|
26
31
|
{
|
|
27
32
|
guidance: false,
|
|
@@ -45,7 +50,12 @@ export const docs = {
|
|
|
45
50
|
],
|
|
46
51
|
},
|
|
47
52
|
theming: {
|
|
48
|
-
targets: [
|
|
53
|
+
targets: [
|
|
54
|
+
{
|
|
55
|
+
className: 'astryx-command-palette-item',
|
|
56
|
+
states: ['highlighted', 'selected', 'disabled'],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
49
59
|
},
|
|
50
60
|
props: [
|
|
51
61
|
{
|
|
@@ -79,7 +89,7 @@ export const docs = {
|
|
|
79
89
|
name: 'isSelected',
|
|
80
90
|
type: 'boolean',
|
|
81
91
|
description:
|
|
82
|
-
'Whether this item represents the selected value in picker mode.',
|
|
92
|
+
'Whether this item represents the selected value in picker mode. Derived from the CommandPalette value when omitted inside CommandPalette.',
|
|
83
93
|
default: 'false',
|
|
84
94
|
},
|
|
85
95
|
{
|
|
@@ -117,7 +127,8 @@ export const docsZh = {
|
|
|
117
127
|
'通过指针激活此条目后调用。CommandPalette 中的键盘选择通过根组件的 onValueChange 报告。',
|
|
118
128
|
isHighlighted:
|
|
119
129
|
'此条目是否为当前键盘结果。在 CommandPalette 内省略时从上下文派生。',
|
|
120
|
-
isSelected:
|
|
130
|
+
isSelected:
|
|
131
|
+
'此条目是否代表选择器模式中的已选值。在 CommandPalette 内省略时从面板的值派生。',
|
|
121
132
|
isDisabled: '条目是否不可用并禁止指针激活。',
|
|
122
133
|
xstyle: 'StyleX 布局自定义样式。必须是 stylex.create() 的值。',
|
|
123
134
|
},
|
|
@@ -135,7 +146,8 @@ export const docsDense = {
|
|
|
135
146
|
onSelect:
|
|
136
147
|
'called after pointer activation; palette keyboard selection uses root onValueChange',
|
|
137
148
|
isHighlighted: 'active keyboard result; derived from context when omitted',
|
|
138
|
-
isSelected:
|
|
149
|
+
isSelected:
|
|
150
|
+
'selected value in picker mode; derived from palette value when omitted',
|
|
139
151
|
isDisabled: 'unavailable; suppresses pointer activation',
|
|
140
152
|
xstyle: 'StyleX layout styles; must be stylex.create() value',
|
|
141
153
|
},
|
|
@@ -195,17 +195,6 @@ describe('CommandPaletteItem', () => {
|
|
|
195
195
|
expect(item).toHaveAttribute('aria-selected', 'true');
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
-
it('paints context-derived highlight when the item is not selected', () => {
|
|
199
|
-
renderWithContext(
|
|
200
|
-
<CommandPaletteItem value="test">Item</CommandPaletteItem>,
|
|
201
|
-
{highlightedIndex: 0},
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
expect(getComputedStyle(screen.getByRole('option')).backgroundColor).toBe(
|
|
205
|
-
'var(--color-overlay-hover)',
|
|
206
|
-
);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
198
|
it('lets explicit visual state override context-derived state', () => {
|
|
210
199
|
renderWithContext(
|
|
211
200
|
<CommandPaletteItem value="test" isHighlighted={false} isSelected={false}>
|
|
@@ -216,7 +205,8 @@ describe('CommandPaletteItem', () => {
|
|
|
216
205
|
|
|
217
206
|
const item = screen.getByRole('option');
|
|
218
207
|
expect(item).toHaveAttribute('aria-selected', 'false');
|
|
219
|
-
expect(
|
|
208
|
+
expect(item).not.toHaveAttribute('data-selected');
|
|
209
|
+
expect(item).not.toHaveAttribute('data-highlighted');
|
|
220
210
|
});
|
|
221
211
|
|
|
222
212
|
it('protects owned option semantics from native passthrough props', () => {
|
|
@@ -354,6 +344,69 @@ describe('CommandPaletteItem', () => {
|
|
|
354
344
|
expect(item).toHaveStyle({marginBlockStart: '8px'});
|
|
355
345
|
});
|
|
356
346
|
|
|
347
|
+
it('reflects selected and disabled state on its theme target', () => {
|
|
348
|
+
render(
|
|
349
|
+
<>
|
|
350
|
+
<CommandPaletteItem value="rest">Rest</CommandPaletteItem>
|
|
351
|
+
<CommandPaletteItem value="selected" isSelected>
|
|
352
|
+
Selected
|
|
353
|
+
</CommandPaletteItem>
|
|
354
|
+
<CommandPaletteItem value="disabled" isDisabled>
|
|
355
|
+
Disabled
|
|
356
|
+
</CommandPaletteItem>
|
|
357
|
+
</>,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const rest = screen.getByRole('option', {name: 'Rest'});
|
|
361
|
+
expect(rest).not.toHaveAttribute('data-selected');
|
|
362
|
+
expect(rest).not.toHaveAttribute('data-disabled');
|
|
363
|
+
expect(screen.getByRole('option', {name: 'Selected'})).toHaveAttribute(
|
|
364
|
+
'data-selected',
|
|
365
|
+
'selected',
|
|
366
|
+
);
|
|
367
|
+
expect(screen.getByRole('option', {name: 'Disabled'})).toHaveAttribute(
|
|
368
|
+
'data-disabled',
|
|
369
|
+
'disabled',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('reflects context-derived selection on its theme target', () => {
|
|
374
|
+
renderWithContext(
|
|
375
|
+
<CommandPaletteItem value="test">Item</CommandPaletteItem>,
|
|
376
|
+
{value: 'test'},
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(screen.getByRole('option')).toHaveAttribute(
|
|
380
|
+
'data-selected',
|
|
381
|
+
'selected',
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('reflects explicit and context-derived highlight on its theme target', () => {
|
|
386
|
+
renderWithContext(
|
|
387
|
+
<>
|
|
388
|
+
<CommandPaletteItem value="test">
|
|
389
|
+
Context highlighted
|
|
390
|
+
</CommandPaletteItem>
|
|
391
|
+
<CommandPaletteItem value="other" isHighlighted>
|
|
392
|
+
Explicitly highlighted
|
|
393
|
+
</CommandPaletteItem>
|
|
394
|
+
<CommandPaletteItem value="rest">Rest</CommandPaletteItem>
|
|
395
|
+
</>,
|
|
396
|
+
{highlightedIndex: 0},
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(
|
|
400
|
+
screen.getByRole('option', {name: 'Context highlighted'}),
|
|
401
|
+
).toHaveAttribute('data-highlighted', 'highlighted');
|
|
402
|
+
expect(
|
|
403
|
+
screen.getByRole('option', {name: 'Explicitly highlighted'}),
|
|
404
|
+
).toHaveAttribute('data-highlighted', 'highlighted');
|
|
405
|
+
expect(screen.getByRole('option', {name: 'Rest'})).not.toHaveAttribute(
|
|
406
|
+
'data-highlighted',
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
357
410
|
it('sets data-value attribute', () => {
|
|
358
411
|
render(<CommandPaletteItem value="my-value">Item</CommandPaletteItem>);
|
|
359
412
|
expect(screen.getByRole('option')).toHaveAttribute(
|
|
@@ -88,6 +88,7 @@ export interface CommandPaletteItemProps extends Omit<
|
|
|
88
88
|
isHighlighted?: boolean;
|
|
89
89
|
/**
|
|
90
90
|
* Whether this item is currently selected (picker mode).
|
|
91
|
+
* When omitted inside CommandPalette, derived from the palette value.
|
|
91
92
|
* @default false
|
|
92
93
|
*/
|
|
93
94
|
isSelected?: boolean;
|
|
@@ -202,7 +203,11 @@ export function CommandPaletteItem({
|
|
|
202
203
|
}
|
|
203
204
|
onMouseEnter={onMouseEnterProp}
|
|
204
205
|
{...mergeProps(
|
|
205
|
-
themeProps('command-palette-item'
|
|
206
|
+
themeProps('command-palette-item', {
|
|
207
|
+
highlighted: isHighlighted ? 'highlighted' : null,
|
|
208
|
+
selected: isSelected ? 'selected' : null,
|
|
209
|
+
disabled: isDisabled ? 'disabled' : null,
|
|
210
|
+
}),
|
|
206
211
|
stylex.props(
|
|
207
212
|
styles.item,
|
|
208
213
|
!isDisabled && interactionOverlayStyles.backgroundColor,
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file CommandPaletteItemAudit.a11y.chromium.spec.ts
|
|
5
|
-
* @input Exact-head CommandPaletteItem stories
|
|
5
|
+
* @input Exact-head CommandPaletteItem stories, plus the inline grouped palette in
|
|
6
|
+
* the CommandPalette audit matrix, across states, themes, direction, pointers,
|
|
7
|
+
* and viewport sizes
|
|
6
8
|
* @output Screenshots and fail-closed sensor receipts for the CommandPaletteItem whole-component audit
|
|
7
9
|
* @position Component-owned Night Watch browser evidence; CI runs it only for a CommandPaletteItem audit pull request
|
|
8
10
|
*/
|
|
@@ -11,7 +13,13 @@ import {createHash} from 'node:crypto';
|
|
|
11
13
|
import {execFileSync} from 'node:child_process';
|
|
12
14
|
import * as fs from 'node:fs';
|
|
13
15
|
import * as path from 'node:path';
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
expect,
|
|
18
|
+
test,
|
|
19
|
+
type CDPSession,
|
|
20
|
+
type Locator,
|
|
21
|
+
type Page,
|
|
22
|
+
} from '@playwright/test';
|
|
15
23
|
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
16
24
|
import {
|
|
17
25
|
DEFAULT_STORYBOOK_DIR,
|
|
@@ -26,8 +34,17 @@ const NARROW = {width: 320, height: 720};
|
|
|
26
34
|
type Mode = 'light' | 'dark';
|
|
27
35
|
type Direction = 'ltr' | 'rtl';
|
|
28
36
|
type Theme = 'neutral' | 'butter';
|
|
29
|
-
type
|
|
30
|
-
type
|
|
37
|
+
type Pointer = 'fine' | 'coarse';
|
|
38
|
+
type Story = 'states' | 'combined-and-long' | 'palette';
|
|
39
|
+
type State =
|
|
40
|
+
| 'rest'
|
|
41
|
+
| 'hover'
|
|
42
|
+
| 'pressed'
|
|
43
|
+
| 'pressed-highlighted'
|
|
44
|
+
| 'disabled-hover'
|
|
45
|
+
| 'touch-press'
|
|
46
|
+
| 'keyboard-highlight'
|
|
47
|
+
| 'keyboard-selected-highlight';
|
|
31
48
|
|
|
32
49
|
type AuditCase = {
|
|
33
50
|
key: string;
|
|
@@ -36,6 +53,8 @@ type AuditCase = {
|
|
|
36
53
|
direction: Direction;
|
|
37
54
|
theme: Theme;
|
|
38
55
|
state: State;
|
|
56
|
+
pointer: Pointer;
|
|
57
|
+
forcedColors: boolean;
|
|
39
58
|
viewport: {width: number; height: number};
|
|
40
59
|
};
|
|
41
60
|
|
|
@@ -57,6 +76,8 @@ function addPair(
|
|
|
57
76
|
direction: options.direction ?? 'ltr',
|
|
58
77
|
theme: options.theme ?? 'neutral',
|
|
59
78
|
state: options.state ?? 'rest',
|
|
79
|
+
pointer: options.pointer ?? 'fine',
|
|
80
|
+
forcedColors: options.forcedColors ?? false,
|
|
60
81
|
viewport: options.viewport ?? WIDE,
|
|
61
82
|
});
|
|
62
83
|
}
|
|
@@ -64,9 +85,19 @@ function addPair(
|
|
|
64
85
|
|
|
65
86
|
addPair('states-rest');
|
|
66
87
|
addPair('states-hover', {state: 'hover'});
|
|
88
|
+
addPair('states-pressed', {state: 'pressed'});
|
|
89
|
+
addPair('states-pressed-highlighted', {state: 'pressed-highlighted'});
|
|
90
|
+
addPair('states-disabled-hover', {state: 'disabled-hover'});
|
|
67
91
|
addPair('states-custom-theme', {theme: 'butter'});
|
|
68
92
|
addPair('states-rtl', {direction: 'rtl'});
|
|
69
93
|
addPair('states-narrow', {viewport: NARROW});
|
|
94
|
+
addPair('states-coarse', {viewport: NARROW, pointer: 'coarse'});
|
|
95
|
+
addPair('states-touch-press', {
|
|
96
|
+
viewport: NARROW,
|
|
97
|
+
pointer: 'coarse',
|
|
98
|
+
state: 'touch-press',
|
|
99
|
+
});
|
|
100
|
+
addPair('states-forced-colors', {forcedColors: true});
|
|
70
101
|
addPair('combined-rest', {story: 'combined-and-long'});
|
|
71
102
|
addPair('combined-custom-theme', {
|
|
72
103
|
story: 'combined-and-long',
|
|
@@ -76,6 +107,45 @@ addPair('combined-narrow', {
|
|
|
76
107
|
story: 'combined-and-long',
|
|
77
108
|
viewport: NARROW,
|
|
78
109
|
});
|
|
110
|
+
addPair('palette-keyboard-highlight', {
|
|
111
|
+
story: 'palette',
|
|
112
|
+
state: 'keyboard-highlight',
|
|
113
|
+
});
|
|
114
|
+
addPair('palette-keyboard-selected-highlight', {
|
|
115
|
+
story: 'palette',
|
|
116
|
+
state: 'keyboard-selected-highlight',
|
|
117
|
+
});
|
|
118
|
+
addPair('palette-keyboard-selected-highlight-custom-theme', {
|
|
119
|
+
story: 'palette',
|
|
120
|
+
state: 'keyboard-selected-highlight',
|
|
121
|
+
theme: 'butter',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
addPair('palette-keyboard-highlight-forced-colors', {
|
|
125
|
+
story: 'palette',
|
|
126
|
+
state: 'keyboard-highlight',
|
|
127
|
+
forcedColors: true,
|
|
128
|
+
});
|
|
129
|
+
addPair('palette-keyboard-selected-highlight-forced-colors', {
|
|
130
|
+
story: 'palette',
|
|
131
|
+
state: 'keyboard-selected-highlight',
|
|
132
|
+
forcedColors: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Option each pointer state acts on, by its accessible text. */
|
|
136
|
+
const POINTER_TARGET: Partial<Record<State, string>> = {
|
|
137
|
+
hover: 'Open file',
|
|
138
|
+
pressed: 'Open file',
|
|
139
|
+
'pressed-highlighted': 'Search files',
|
|
140
|
+
'disabled-hover': 'Archived command',
|
|
141
|
+
'touch-press': 'Open file',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Option the palette keyboard states move the active descendant to. */
|
|
145
|
+
const KEYBOARD_TARGET: Partial<Record<State, string>> = {
|
|
146
|
+
'keyboard-highlight': 'Home',
|
|
147
|
+
'keyboard-selected-highlight': 'Settings',
|
|
148
|
+
};
|
|
79
149
|
|
|
80
150
|
test.describe.configure({mode: 'serial', retries: 0});
|
|
81
151
|
let server: StaticServer;
|
|
@@ -83,7 +153,9 @@ let checkoutSha: string;
|
|
|
83
153
|
let storybookSha: string;
|
|
84
154
|
|
|
85
155
|
function storyId(item: AuditCase) {
|
|
86
|
-
return
|
|
156
|
+
return item.story === 'palette'
|
|
157
|
+
? 'core-commandpalette--audit-matrix'
|
|
158
|
+
: `core-commandpaletteitem--${item.story}`;
|
|
87
159
|
}
|
|
88
160
|
|
|
89
161
|
function storyUrl(item: AuditCase) {
|
|
@@ -96,6 +168,38 @@ async function settle(page: Page) {
|
|
|
96
168
|
await page.waitForTimeout(40);
|
|
97
169
|
}
|
|
98
170
|
|
|
171
|
+
async function centerOf(locator: Locator) {
|
|
172
|
+
const box = await locator.boundingBox();
|
|
173
|
+
if (box == null) {
|
|
174
|
+
throw new Error('target option has no rendered bounds');
|
|
175
|
+
}
|
|
176
|
+
return {x: box.x + box.width / 2, y: box.y + box.height / 2};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function highlightedName(input: Locator) {
|
|
180
|
+
return input.evaluate(element => {
|
|
181
|
+
const id = element.getAttribute('aria-activedescendant');
|
|
182
|
+
const option = id ? document.getElementById(id) : null;
|
|
183
|
+
return option?.textContent?.replace(/\s+/g, ' ').trim() ?? null;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** PageUp moves the palette to its first result; ArrowDown walks forward. */
|
|
188
|
+
async function highlightFromKeyboard(
|
|
189
|
+
page: Page,
|
|
190
|
+
input: Locator,
|
|
191
|
+
target: string,
|
|
192
|
+
) {
|
|
193
|
+
await input.focus();
|
|
194
|
+
await page.keyboard.press('PageUp');
|
|
195
|
+
for (let step = 0; step < 4; step++) {
|
|
196
|
+
if ((await highlightedName(input)) === target) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await page.keyboard.press('ArrowDown');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
99
203
|
test.beforeAll(async () => {
|
|
100
204
|
fs.rmSync(OUTPUT, {recursive: true, force: true});
|
|
101
205
|
fs.mkdirSync(OUTPUT, {recursive: true});
|
|
@@ -133,6 +237,8 @@ for (const item of cases) {
|
|
|
133
237
|
viewport: item.viewport,
|
|
134
238
|
colorScheme: item.mode,
|
|
135
239
|
reducedMotion: 'reduce',
|
|
240
|
+
hasTouch: item.pointer === 'coarse',
|
|
241
|
+
forcedColors: item.forcedColors ? 'active' : 'none',
|
|
136
242
|
});
|
|
137
243
|
const page = await context.newPage();
|
|
138
244
|
const pageErrors: string[] = [];
|
|
@@ -140,13 +246,20 @@ for (const item of cases) {
|
|
|
140
246
|
await page.goto(storyUrl(item), {waitUntil: 'load'});
|
|
141
247
|
await page.locator('#storybook-root').waitFor();
|
|
142
248
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const list =
|
|
249
|
+
const isPalette = item.story === 'palette';
|
|
250
|
+
const subject = isPalette
|
|
251
|
+
? page.getByRole('region', {name: 'Grouped command palette'})
|
|
252
|
+
: page.locator('#storybook-root');
|
|
253
|
+
const list = isPalette
|
|
254
|
+
? subject.getByRole('listbox')
|
|
255
|
+
: subject.getByRole('listbox', {
|
|
256
|
+
name:
|
|
257
|
+
item.story === 'states'
|
|
258
|
+
? 'Command item states'
|
|
259
|
+
: 'Combined and long command item states',
|
|
260
|
+
});
|
|
148
261
|
const options = list.getByRole('option');
|
|
149
|
-
const expectedCount = item.story === '
|
|
262
|
+
const expectedCount = item.story === 'combined-and-long' ? 2 : 4;
|
|
150
263
|
const expectedDisabled = item.story === 'states' ? 1 : 0;
|
|
151
264
|
await expect(list).toBeVisible();
|
|
152
265
|
await expect(options).toHaveCount(expectedCount);
|
|
@@ -156,12 +269,51 @@ for (const item of cases) {
|
|
|
156
269
|
await expect(
|
|
157
270
|
list.locator('[role="option"][aria-disabled="true"]'),
|
|
158
271
|
).toHaveCount(expectedDisabled);
|
|
159
|
-
await expect(
|
|
272
|
+
await expect(
|
|
273
|
+
list.locator('[role="option"] :is(button, a, [tabindex])'),
|
|
274
|
+
).toHaveCount(0);
|
|
275
|
+
|
|
276
|
+
const pointerTarget = POINTER_TARGET[item.state];
|
|
277
|
+
const keyboardTarget = KEYBOARD_TARGET[item.state];
|
|
278
|
+
const target = pointerTarget ?? keyboardTarget ?? null;
|
|
279
|
+
const targetOption =
|
|
280
|
+
target == null ? null : options.filter({hasText: target}).first();
|
|
281
|
+
let cdp: CDPSession | null = null;
|
|
160
282
|
|
|
161
|
-
if (item.state === 'hover') {
|
|
162
|
-
await options.first().hover();
|
|
163
|
-
}
|
|
164
283
|
await settle(page);
|
|
284
|
+
if (item.state === 'hover' || item.state === 'disabled-hover') {
|
|
285
|
+
await targetOption?.hover();
|
|
286
|
+
} else if (
|
|
287
|
+
item.state === 'pressed' ||
|
|
288
|
+
item.state === 'pressed-highlighted'
|
|
289
|
+
) {
|
|
290
|
+
if (targetOption == null) {
|
|
291
|
+
throw new Error(`${item.key}: no press target`);
|
|
292
|
+
}
|
|
293
|
+
await targetOption.hover();
|
|
294
|
+
await page.mouse.down();
|
|
295
|
+
} else if (item.state === 'touch-press') {
|
|
296
|
+
if (targetOption == null) {
|
|
297
|
+
throw new Error(`${item.key}: no touch target`);
|
|
298
|
+
}
|
|
299
|
+
const point = await centerOf(targetOption);
|
|
300
|
+
cdp = await context.newCDPSession(page);
|
|
301
|
+
await cdp.send('Input.dispatchTouchEvent', {
|
|
302
|
+
type: 'touchStart',
|
|
303
|
+
touchPoints: [{x: point.x, y: point.y, id: 1}],
|
|
304
|
+
});
|
|
305
|
+
// The touch press controller believes a stationary press after its
|
|
306
|
+
// onset delay; hold past it before reading the painted state.
|
|
307
|
+
await page.waitForTimeout(320);
|
|
308
|
+
} else if (keyboardTarget != null) {
|
|
309
|
+
await highlightFromKeyboard(
|
|
310
|
+
page,
|
|
311
|
+
subject.getByRole('combobox'),
|
|
312
|
+
keyboardTarget,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
316
|
+
await page.waitForTimeout(40);
|
|
165
317
|
|
|
166
318
|
const observed = await page.evaluate(() => ({
|
|
167
319
|
theme: document
|
|
@@ -173,20 +325,41 @@ for (const item of cases) {
|
|
|
173
325
|
).direction,
|
|
174
326
|
viewport: {width: innerWidth, height: innerHeight},
|
|
175
327
|
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
328
|
+
coarsePointer: matchMedia('(pointer: coarse)').matches,
|
|
329
|
+
forcedColors: matchMedia('(forced-colors: active)').matches,
|
|
176
330
|
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
|
177
331
|
fonts: document.fonts.status,
|
|
178
332
|
}));
|
|
333
|
+
const listBackdrop = await list.evaluate(element => {
|
|
334
|
+
let node: Element | null = element;
|
|
335
|
+
while (node) {
|
|
336
|
+
const color = getComputedStyle(node).backgroundColor;
|
|
337
|
+
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
|
|
338
|
+
return color;
|
|
339
|
+
}
|
|
340
|
+
node = node.parentElement;
|
|
341
|
+
}
|
|
342
|
+
return getComputedStyle(document.body).backgroundColor;
|
|
343
|
+
});
|
|
179
344
|
const optionStates = await options.evaluateAll(nodes =>
|
|
180
345
|
nodes.map(node => {
|
|
181
346
|
const style = getComputedStyle(node);
|
|
182
347
|
const box = node.getBoundingClientRect();
|
|
183
348
|
return {
|
|
184
349
|
name: node.textContent?.replace(/\s+/g, ' ').trim(),
|
|
350
|
+
id: node.id || null,
|
|
185
351
|
selected: node.getAttribute('aria-selected'),
|
|
186
352
|
disabled: node.getAttribute('aria-disabled'),
|
|
353
|
+
themeSelected: node.getAttribute('data-selected'),
|
|
354
|
+
themeDisabled: node.getAttribute('data-disabled'),
|
|
355
|
+
active: node.matches(':active'),
|
|
356
|
+
hovered: node.matches(':hover'),
|
|
357
|
+
press: node.getAttribute('data-astryx-press'),
|
|
187
358
|
backgroundColor: style.backgroundColor,
|
|
359
|
+
backgroundImage: style.backgroundImage,
|
|
188
360
|
color: style.color,
|
|
189
361
|
opacity: style.opacity,
|
|
362
|
+
cursor: style.cursor,
|
|
190
363
|
geometry: {
|
|
191
364
|
x: box.x,
|
|
192
365
|
y: box.y,
|
|
@@ -196,6 +369,18 @@ for (const item of cases) {
|
|
|
196
369
|
};
|
|
197
370
|
}),
|
|
198
371
|
);
|
|
372
|
+
const combobox = isPalette
|
|
373
|
+
? await subject.getByRole('combobox').evaluate(element => ({
|
|
374
|
+
focused: element === document.activeElement,
|
|
375
|
+
activeDescendant: element.getAttribute('aria-activedescendant'),
|
|
376
|
+
}))
|
|
377
|
+
: null;
|
|
378
|
+
|
|
379
|
+
const targetState =
|
|
380
|
+
target == null
|
|
381
|
+
? null
|
|
382
|
+
: (optionStates.find(option => option.name?.startsWith(target)) ??
|
|
383
|
+
null);
|
|
199
384
|
const semanticsPass =
|
|
200
385
|
optionStates.length === expectedCount &&
|
|
201
386
|
optionStates.filter(option => option.selected === 'true').length === 1 &&
|
|
@@ -204,11 +389,48 @@ for (const item of cases) {
|
|
|
204
389
|
optionStates.every(
|
|
205
390
|
option => option.geometry.width > 0 && option.geometry.height > 0,
|
|
206
391
|
);
|
|
392
|
+
let statePass = true;
|
|
393
|
+
if (item.state === 'hover') {
|
|
394
|
+
statePass = targetState?.hovered === true;
|
|
395
|
+
} else if (item.state === 'disabled-hover') {
|
|
396
|
+
statePass =
|
|
397
|
+
targetState?.hovered === true &&
|
|
398
|
+
targetState.disabled === 'true' &&
|
|
399
|
+
targetState.backgroundColor === 'rgba(0, 0, 0, 0)' &&
|
|
400
|
+
targetState.cursor === 'default';
|
|
401
|
+
} else if (
|
|
402
|
+
item.state === 'pressed' ||
|
|
403
|
+
item.state === 'pressed-highlighted'
|
|
404
|
+
) {
|
|
405
|
+
statePass = targetState?.active === true;
|
|
406
|
+
} else if (item.state === 'touch-press') {
|
|
407
|
+
statePass = targetState?.press === 'on';
|
|
408
|
+
} else if (keyboardTarget != null) {
|
|
409
|
+
statePass =
|
|
410
|
+
combobox?.focused === true &&
|
|
411
|
+
targetState?.id != null &&
|
|
412
|
+
combobox.activeDescendant === targetState.id &&
|
|
413
|
+
(item.state === 'keyboard-selected-highlight'
|
|
414
|
+
? targetState.selected === 'true'
|
|
415
|
+
: targetState.selected === 'false');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const png = isPalette
|
|
419
|
+
? await subject.screenshot({animations: 'disabled'})
|
|
420
|
+
: await page.screenshot({animations: 'disabled', fullPage: true});
|
|
421
|
+
if (item.state === 'pressed' || item.state === 'pressed-highlighted') {
|
|
422
|
+
await page.mouse.up();
|
|
423
|
+
}
|
|
424
|
+
if (cdp != null) {
|
|
425
|
+
await cdp.send('Input.dispatchTouchEvent', {
|
|
426
|
+
type: 'touchEnd',
|
|
427
|
+
touchPoints: [],
|
|
428
|
+
});
|
|
429
|
+
}
|
|
207
430
|
|
|
208
|
-
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
209
431
|
const image = `${item.key}.png`;
|
|
210
432
|
const receipt = {
|
|
211
|
-
schemaVersion:
|
|
433
|
+
schemaVersion: 2,
|
|
212
434
|
expected: {
|
|
213
435
|
build: checkoutSha,
|
|
214
436
|
storyId: storyId(item),
|
|
@@ -216,6 +438,9 @@ for (const item of cases) {
|
|
|
216
438
|
direction: item.direction,
|
|
217
439
|
theme: item.theme,
|
|
218
440
|
state: item.state,
|
|
441
|
+
target,
|
|
442
|
+
pointer: item.pointer,
|
|
443
|
+
forcedColors: item.forcedColors,
|
|
219
444
|
viewport: item.viewport,
|
|
220
445
|
optionCount: expectedCount,
|
|
221
446
|
disabledCount: expectedDisabled,
|
|
@@ -226,6 +451,8 @@ for (const item of cases) {
|
|
|
226
451
|
...observed,
|
|
227
452
|
listRole: await list.getAttribute('role'),
|
|
228
453
|
listName: await list.getAttribute('aria-label'),
|
|
454
|
+
listBackdrop,
|
|
455
|
+
combobox,
|
|
229
456
|
optionStates,
|
|
230
457
|
pageErrors,
|
|
231
458
|
},
|
|
@@ -235,7 +462,7 @@ for (const item of cases) {
|
|
|
235
462
|
width: png.readUInt32BE(16),
|
|
236
463
|
height: png.readUInt32BE(20),
|
|
237
464
|
},
|
|
238
|
-
checks: {semanticsPass},
|
|
465
|
+
checks: {semanticsPass, statePass},
|
|
239
466
|
passed:
|
|
240
467
|
storybookSha === checkoutSha &&
|
|
241
468
|
pageErrors.length === 0 &&
|
|
@@ -245,9 +472,12 @@ for (const item of cases) {
|
|
|
245
472
|
observed.viewport.width === item.viewport.width &&
|
|
246
473
|
observed.viewport.height === item.viewport.height &&
|
|
247
474
|
observed.reducedMotion &&
|
|
475
|
+
observed.coarsePointer === (item.pointer === 'coarse') &&
|
|
476
|
+
observed.forcedColors === item.forcedColors &&
|
|
248
477
|
!observed.horizontalOverflow &&
|
|
249
478
|
observed.fonts === 'loaded' &&
|
|
250
|
-
semanticsPass
|
|
479
|
+
semanticsPass &&
|
|
480
|
+
statePass,
|
|
251
481
|
};
|
|
252
482
|
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
253
483
|
fs.writeFileSync(
|
|
@@ -270,7 +500,7 @@ test('manifest is complete', async () => {
|
|
|
270
500
|
) as AuditReceipt,
|
|
271
501
|
);
|
|
272
502
|
const manifest = {
|
|
273
|
-
schemaVersion:
|
|
503
|
+
schemaVersion: 2,
|
|
274
504
|
component: 'core/CommandPaletteItem',
|
|
275
505
|
build: checkoutSha,
|
|
276
506
|
frames: cases.map(item => ({
|