@astryxdesign/core 0.7.2-canary.52c26fb → 0.7.2-canary.58279bc
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/CommandPalette/CommandPaletteInput.d.ts +1 -1
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +37 -5
- package/package.json +3 -3
- 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
|
@@ -68,7 +68,7 @@ export interface CommandPaletteInputProps extends Omit<BaseProps<HTMLInputElemen
|
|
|
68
68
|
* </CommandPalette>
|
|
69
69
|
* ```
|
|
70
70
|
*/
|
|
71
|
-
export declare function CommandPaletteInput({ value: controlledValue, onValueChange, placeholder: placeholderFromProps, label, hasAutoFocus, endContent, onChange, onKeyDown, ref, xstyle, className, style, ...props }: CommandPaletteInputProps): import("react").JSX.Element;
|
|
71
|
+
export declare function CommandPaletteInput({ value: controlledValue, onValueChange, placeholder: placeholderFromProps, label, hasAutoFocus, endContent, onChange, onKeyDown, onFocus, onBlur, ref, xstyle, className, style, ...props }: CommandPaletteInputProps): import("react").JSX.Element;
|
|
72
72
|
export declare namespace CommandPaletteInput {
|
|
73
73
|
var displayName: string;
|
|
74
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CommandPaletteInput.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteInput.tsx"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"CommandPaletteInput.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteInput.tsx"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAa/E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAsE5C,MAAM,WAAW,wBAAyB,SAAQ,IAAI,CACpD,SAAS,CAAC,gBAAgB,CAAC,EAC3B,UAAU,CACX;IACC,iEAAiE;IACjE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAElC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IAEvB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,KAAK,CAAC,kBAAkB,CAAC,gBAAgB,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EAAE,eAAe,EACtB,aAAa,EACb,WAAW,EAAE,oBAAoB,EACjC,KAAK,EACL,YAAmB,EACnB,UAAU,EACV,QAAQ,EACR,SAAS,EACT,OAAO,EACP,MAAM,EACN,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,KAAK,EACT,EAAE,wBAAwB,+BAmK1B;yBAnLe,mBAAmB"}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - /apps/storybook/stories/CommandPaletteInput.stories.tsx
|
|
14
14
|
* - /packages/cli/assets/templates/blocks/components/CommandPalette/ (showcase blocks)
|
|
15
15
|
*/
|
|
16
|
-
import { useCallback, useEffect, useRef } from 'react';
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
17
|
import * as stylex from '@stylexjs/stylex';
|
|
18
18
|
import { Icon } from "../Icon/index.js";
|
|
19
19
|
import { Spinner } from "../Spinner/index.js";
|
|
@@ -23,6 +23,8 @@ import { colorVars, typeScaleVars, spacingVars, typographyVars } from "../theme/
|
|
|
23
23
|
import { useCommandPaletteContext } from "./CommandPaletteContext.js";
|
|
24
24
|
import { useDialogContext } from "../Dialog/DialogContext.js";
|
|
25
25
|
import { themeProps } from "../utils/themeProps.js";
|
|
26
|
+
import { focusOutlineProps } from "../utils/focusOutline.stylex.js";
|
|
27
|
+
import { getInteractionModality, useInteractionModalityTracking } from "../utils/interactionModality.js";
|
|
26
28
|
import { useTranslator } from "../i18n/index.js";
|
|
27
29
|
import { useMergedRefs } from "../hooks/useMergedRefs.js";
|
|
28
30
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -35,6 +37,20 @@ const styles = {
|
|
|
35
37
|
k8WAf4: "x8o8v82",
|
|
36
38
|
kmuXW: "x2lah0s",
|
|
37
39
|
$$css: true
|
|
40
|
+
},
|
|
41
|
+
input: {
|
|
42
|
+
kUk6DE: "x98rzlu",
|
|
43
|
+
k7Eaqz: "xeuugli",
|
|
44
|
+
kMzoRj: "xc342km",
|
|
45
|
+
ksu8eU: "xng3xce",
|
|
46
|
+
kWkggS: "xjbqb8w",
|
|
47
|
+
kMwMTN: "x1tgivj0",
|
|
48
|
+
kMv6JI: "x9ynric",
|
|
49
|
+
kGuDYH: "xjm74w1 xf9zqsd",
|
|
50
|
+
kLWn49: "xw6l6zx",
|
|
51
|
+
kmVPX3: "x1717udv",
|
|
52
|
+
k8Qsv1: "xeyghm5",
|
|
53
|
+
$$css: true
|
|
38
54
|
}
|
|
39
55
|
};
|
|
40
56
|
/**
|
|
@@ -65,6 +81,8 @@ export function CommandPaletteInput({
|
|
|
65
81
|
endContent,
|
|
66
82
|
onChange,
|
|
67
83
|
onKeyDown,
|
|
84
|
+
onFocus,
|
|
85
|
+
onBlur,
|
|
68
86
|
ref,
|
|
69
87
|
xstyle,
|
|
70
88
|
className,
|
|
@@ -79,6 +97,20 @@ export function CommandPaletteInput({
|
|
|
79
97
|
const hasListbox = ctx ? ctx.selectableItems.length > 0 : true;
|
|
80
98
|
const dialogContext = useDialogContext();
|
|
81
99
|
const inputRef = useRef(null);
|
|
100
|
+
const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
|
|
101
|
+
|
|
102
|
+
// Text-entry controls match :focus-visible after pointer focus in Chromium.
|
|
103
|
+
// Keep the shared selector, but narrow its ring to focus acquired through
|
|
104
|
+
// keyboard navigation so a click or pointer-opened palette stays unringed.
|
|
105
|
+
useInteractionModalityTracking();
|
|
106
|
+
const handleFocus = useCallback(event => {
|
|
107
|
+
setIsKeyboardFocus(getInteractionModality() === 'keyboard');
|
|
108
|
+
onFocus?.(event);
|
|
109
|
+
}, [onFocus]);
|
|
110
|
+
const handleBlur = useCallback(event => {
|
|
111
|
+
setIsKeyboardFocus(false);
|
|
112
|
+
onBlur?.(event);
|
|
113
|
+
}, [onBlur]);
|
|
82
114
|
|
|
83
115
|
// Use context values as fallback
|
|
84
116
|
const value = controlledValue ?? ctx?.search;
|
|
@@ -134,9 +166,7 @@ export function CommandPaletteInput({
|
|
|
134
166
|
}), /*#__PURE__*/_jsx("input", {
|
|
135
167
|
...props,
|
|
136
168
|
ref: useMergedRefs(ref, inputRef),
|
|
137
|
-
...
|
|
138
|
-
className: "x98rzlu xeuugli xc342km xng3xce x1a2a7pz xjbqb8w x1tgivj0 x9ynric xjm74w1 xf9zqsd xw6l6zx x1717udv xeyghm5"
|
|
139
|
-
},
|
|
169
|
+
...(isKeyboardFocus ? focusOutlineProps.focusVisible(styles.input) : focusOutlineProps.suppressed(styles.input)),
|
|
140
170
|
type: "text",
|
|
141
171
|
role: ctx ? 'combobox' : standaloneRole,
|
|
142
172
|
"aria-expanded": ctx ? ctx.isOpen && hasListbox : callerSets('aria-expanded') ? props['aria-expanded'] : callerOwnsPopup || undefined,
|
|
@@ -153,7 +183,9 @@ export function CommandPaletteInput({
|
|
|
153
183
|
handleValueChange?.(e.target.value);
|
|
154
184
|
onChange?.(e);
|
|
155
185
|
},
|
|
156
|
-
onKeyDown: handleKeyDown
|
|
186
|
+
onKeyDown: handleKeyDown,
|
|
187
|
+
onFocus: handleFocus,
|
|
188
|
+
onBlur: handleBlur
|
|
157
189
|
}), (ctx?.isBusy || hasEndContent) && /*#__PURE__*/_jsxs("span", {
|
|
158
190
|
...{
|
|
159
191
|
className: "x78zum5 x6s0dn4 xzye2dw x2lah0s"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.2-canary.
|
|
3
|
+
"version": "0.7.2-canary.58279bc",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.2-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.2-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.2-canary.58279bc",
|
|
796
|
+
"@astryxdesign/cli": "0.7.2-canary.58279bc",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -8,12 +8,19 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {createRef} from 'react';
|
|
11
|
-
import {describe, it, expect, vi} from 'vitest';
|
|
12
|
-
import {render, screen, fireEvent} from '@testing-library/react';
|
|
11
|
+
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
12
|
+
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
13
|
+
import * as stylex from '@stylexjs/stylex';
|
|
13
14
|
import {CommandPaletteInput} from './CommandPaletteInput';
|
|
14
15
|
import {CommandPaletteContext} from './CommandPaletteContext';
|
|
15
16
|
import {Dialog} from '../Dialog';
|
|
16
17
|
import type {CommandPaletteContextValue} from './CommandPaletteContext';
|
|
18
|
+
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
19
|
+
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
__resetInteractionModalityForTest();
|
|
23
|
+
});
|
|
17
24
|
|
|
18
25
|
function makeContext(
|
|
19
26
|
overrides: Partial<CommandPaletteContextValue> = {},
|
|
@@ -279,6 +286,45 @@ describe('CommandPaletteInput', () => {
|
|
|
279
286
|
},
|
|
280
287
|
);
|
|
281
288
|
|
|
289
|
+
it('draws the shared ring only when keyboard navigation acquired focus', () => {
|
|
290
|
+
render(<CommandPaletteInput hasAutoFocus={false} />);
|
|
291
|
+
const input = screen.getByRole('searchbox');
|
|
292
|
+
const ringClasses = stylex
|
|
293
|
+
.props(focusOutlineStyles.focusVisible)
|
|
294
|
+
.className!.split(' ');
|
|
295
|
+
const isRinged = () =>
|
|
296
|
+
ringClasses.every(className => input.classList.contains(className));
|
|
297
|
+
|
|
298
|
+
fireEvent.pointerDown(input);
|
|
299
|
+
fireEvent.focus(input);
|
|
300
|
+
expect(isRinged()).toBe(false);
|
|
301
|
+
|
|
302
|
+
fireEvent.blur(input);
|
|
303
|
+
fireEvent.keyDown(input, {key: 'Tab'});
|
|
304
|
+
fireEvent.focus(input);
|
|
305
|
+
expect(isRinged()).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('preserves caller focus and blur handlers while tracking modality', () => {
|
|
309
|
+
const onFocus = vi.fn();
|
|
310
|
+
const onBlur = vi.fn();
|
|
311
|
+
render(
|
|
312
|
+
<CommandPaletteInput
|
|
313
|
+
hasAutoFocus={false}
|
|
314
|
+
onFocus={onFocus}
|
|
315
|
+
onBlur={onBlur}
|
|
316
|
+
/>,
|
|
317
|
+
);
|
|
318
|
+
const input = screen.getByRole('searchbox');
|
|
319
|
+
|
|
320
|
+
fireEvent.keyDown(input, {key: 'Tab'});
|
|
321
|
+
fireEvent.focus(input);
|
|
322
|
+
fireEvent.blur(input);
|
|
323
|
+
|
|
324
|
+
expect(onFocus).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(onBlur).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
282
328
|
it('does not schedule focus when hasAutoFocus is false', async () => {
|
|
283
329
|
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
|
|
284
330
|
render(<CommandPaletteInput hasAutoFocus={false} />);
|
|
@@ -481,8 +527,11 @@ describe('CommandPaletteInput popup context', () => {
|
|
|
481
527
|
</CommandPaletteContext>,
|
|
482
528
|
);
|
|
483
529
|
|
|
484
|
-
await
|
|
485
|
-
|
|
530
|
+
await act(async () => {
|
|
531
|
+
await new Promise<void>(resolve =>
|
|
532
|
+
requestAnimationFrame(() => resolve()),
|
|
533
|
+
);
|
|
486
534
|
});
|
|
535
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
487
536
|
});
|
|
488
537
|
});
|
|
@@ -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
|
+
});
|