@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  3. package/dist/Chat/ChatMessageList.js +8 -1
  4. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  5. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  6. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  7. package/dist/CommandPalette/CommandPalette.js +6 -11
  8. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  9. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  10. package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -0
  11. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  12. package/dist/CommandPalette/CommandPaletteItem.js +5 -1
  13. package/dist/DateInput/DateInput.d.ts +6 -4
  14. package/dist/DateInput/DateInput.d.ts.map +1 -1
  15. package/dist/DateInput/DateInput.js +5 -1
  16. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  17. package/dist/DateInput/NativeDateField.js +5 -1
  18. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  19. package/dist/DateInput/TouchDateField.js +5 -1
  20. package/dist/DateInput/tokens.stylex.d.ts.map +1 -1
  21. package/dist/DateInput/tokens.stylex.js +3 -1
  22. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  24. package/dist/FileInput/FileInput.d.ts.map +1 -1
  25. package/dist/FileInput/FileInput.js +16 -2
  26. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  27. package/dist/Thumbnail/Thumbnail.js +9 -2
  28. package/package.json +4 -4
  29. package/src/BottomSheet/BottomSheet.spec.md +1 -0
  30. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +25 -2
  31. package/src/BottomSheet/BottomSheetSwitcher.spec.md +7 -6
  32. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +221 -1
  33. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  34. package/src/Chat/ChatMessageList.test.tsx +6 -2
  35. package/src/Chat/ChatMessageList.tsx +7 -1
  36. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  37. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  38. package/src/CommandPalette/CommandPalette.doc.mjs +4 -1
  39. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  40. package/src/CommandPalette/CommandPalette.tsx +6 -11
  41. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  42. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  43. package/src/CommandPalette/CommandPaletteItem.doc.mjs +17 -5
  44. package/src/CommandPalette/CommandPaletteItem.test.tsx +65 -12
  45. package/src/CommandPalette/CommandPaletteItem.tsx +6 -1
  46. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +250 -20
  47. package/src/DateInput/DateInput.doc.mjs +23 -5
  48. package/src/DateInput/DateInput.spec.md +52 -45
  49. package/src/DateInput/DateInput.tsx +13 -7
  50. package/src/DateInput/NativeDateField.tsx +6 -2
  51. package/src/DateInput/TouchDateField.tsx +6 -2
  52. package/src/DateInput/__tests__/DateInputAudit.a11y.chromium.spec.ts +576 -0
  53. package/src/DateInput/tokens.stylex.ts +3 -1
  54. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  55. package/src/FileInput/FileInput.tsx +18 -2
  56. package/src/Overlay/Overlay.spec.md +304 -0
  57. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  58. package/src/Thumbnail/Thumbnail.tsx +12 -3
  59. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +324 -2
@@ -377,7 +377,9 @@ describe('CommandPaletteInput popup context', () => {
377
377
  </CommandPaletteContext>,
378
378
  );
379
379
 
380
- const spinner = screen.getByRole('status');
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
- 'Use non-interactive child content such as icons, descriptions, and shortcut hints; activate the option itself instead of nesting buttons or links.',
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: [{className: 'astryx-command-palette-item'}],
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: 'selected value in picker mode',
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(getComputedStyle(item).backgroundColor).toBe('rgba(0, 0, 0, 0)');
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 across semantics, states, themes, direction, and viewport sizes
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 {expect, test, type Page} from '@playwright/test';
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 Story = 'states' | 'combined-and-long';
30
- type State = 'rest' | 'hover';
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 `core-commandpaletteitem--${item.story}`;
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 listName =
144
- item.story === 'states'
145
- ? 'Command item states'
146
- : 'Combined and long command item states';
147
- const list = page.getByRole('listbox', {name: listName});
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 === 'states' ? 4 : 2;
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(list.locator('button, a, [tabindex]')).toHaveCount(0);
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: 1,
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: 1,
503
+ schemaVersion: 2,
274
504
  component: 'core/CommandPaletteItem',
275
505
  build: checkoutSha,
276
506
  frames: cases.map(item => ({