@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
@@ -0,0 +1,576 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DateInputAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head DateInput stories across field states, presentations,
6
+ * themes, direction, pointers, and viewport sizes
7
+ * @output Screenshots and fail-closed sensor receipts for the DateInput
8
+ * whole-component audit
9
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a
10
+ * DateInput audit pull request
11
+ */
12
+
13
+ import {createHash} from 'node:crypto';
14
+ import {execFileSync} from 'node:child_process';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import {expect, test, type Locator, type Page} from '@playwright/test';
18
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
19
+ import {
20
+ DEFAULT_STORYBOOK_DIR,
21
+ serveStorybook,
22
+ type StaticServer,
23
+ } from '@astryxdesign/a11y-spec/storybook';
24
+
25
+ const OUTPUT = path.resolve('date-input-audit-evidence');
26
+ const WIDE = {width: 1024, height: 768};
27
+ const NARROW = {width: 320, height: 720};
28
+
29
+ type Mode = 'light' | 'dark';
30
+ type Direction = 'ltr' | 'rtl';
31
+ type Theme = 'neutral' | 'butter';
32
+ type Pointer = 'fine' | 'coarse';
33
+ type Story =
34
+ | 'default'
35
+ | 'with-value'
36
+ | 'disabled'
37
+ | 'disabled-with-message'
38
+ | 'with-error-status'
39
+ | 'with-warning-status'
40
+ | 'with-success-status'
41
+ | 'clearable'
42
+ | 'open-calendar-audit'
43
+ | 'styling-parts';
44
+ type State =
45
+ | 'rest'
46
+ | 'hover'
47
+ | 'focus-toggle'
48
+ | 'focus-input'
49
+ | 'typed-invalid'
50
+ | 'clear-hover'
51
+ | 'open'
52
+ | 'sheet-open';
53
+ type Surface = 'popover' | 'native' | 'sheet' | 'mixed';
54
+
55
+ type AuditCase = {
56
+ key: string;
57
+ story: Story;
58
+ args: Record<string, string>;
59
+ surface: Surface;
60
+ mode: Mode;
61
+ direction: Direction;
62
+ theme: Theme;
63
+ state: State;
64
+ pointer: Pointer;
65
+ forcedColors: boolean;
66
+ viewport: {width: number; height: number};
67
+ };
68
+
69
+ type AuditReceipt = {
70
+ passed: boolean;
71
+ expected: Record<string, unknown>;
72
+ };
73
+
74
+ const cases: AuditCase[] = [];
75
+ function addPair(
76
+ key: string,
77
+ options: Partial<Omit<AuditCase, 'key' | 'mode'>> = {},
78
+ ) {
79
+ for (const mode of ['light', 'dark'] as const) {
80
+ cases.push({
81
+ key: `${key}-${mode}`,
82
+ story: options.story ?? 'with-value',
83
+ args: options.args ?? {},
84
+ surface: options.surface ?? 'popover',
85
+ mode,
86
+ direction: options.direction ?? 'ltr',
87
+ theme: options.theme ?? 'neutral',
88
+ state: options.state ?? 'rest',
89
+ pointer: options.pointer ?? 'fine',
90
+ forcedColors: options.forcedColors ?? false,
91
+ viewport: options.viewport ?? WIDE,
92
+ });
93
+ }
94
+ }
95
+
96
+ const BUSY = {isLoading: '!true'};
97
+
98
+ addPair('empty-rest', {story: 'default'});
99
+ addPair('valued-rest');
100
+ addPair('valued-hover', {state: 'hover'});
101
+ addPair('focus-toggle', {state: 'focus-toggle'});
102
+ addPair('focus-input', {state: 'focus-input'});
103
+ addPair('typed-invalid', {state: 'typed-invalid'});
104
+ addPair('open-calendar', {story: 'open-calendar-audit', state: 'open'});
105
+ addPair('size-sm', {args: {size: 'sm'}});
106
+ addPair('size-lg', {args: {size: 'lg'}});
107
+ addPair('busy-rest', {args: BUSY});
108
+ addPair('busy-hover', {args: BUSY, state: 'hover'});
109
+ addPair('busy-focus-input', {args: BUSY, state: 'focus-input'});
110
+ addPair('disabled-rest', {story: 'disabled'});
111
+ addPair('disabled-hover', {story: 'disabled', state: 'hover'});
112
+ addPair('disabled-focus-input', {story: 'disabled', state: 'focus-input'});
113
+ addPair('disabled-message-focus', {
114
+ story: 'disabled-with-message',
115
+ state: 'focus-toggle',
116
+ });
117
+ addPair('status-error', {story: 'with-error-status'});
118
+ addPair('status-warning', {story: 'with-warning-status'});
119
+ addPair('status-success', {story: 'with-success-status'});
120
+ addPair('clearable-rest', {story: 'clearable'});
121
+ addPair('clearable-hover-clear', {story: 'clearable', state: 'clear-hover'});
122
+ addPair('native-rest', {args: {presentation: 'native'}, surface: 'native'});
123
+ addPair('native-busy', {
124
+ args: {presentation: 'native', ...BUSY},
125
+ surface: 'native',
126
+ });
127
+ addPair('sheet-rest', {
128
+ args: {presentation: 'bottom-sheet'},
129
+ surface: 'sheet',
130
+ });
131
+ addPair('sheet-busy', {
132
+ args: {presentation: 'bottom-sheet', ...BUSY},
133
+ surface: 'sheet',
134
+ });
135
+ addPair('styling-parts', {story: 'styling-parts', surface: 'mixed'});
136
+ addPair('custom-theme-rest', {theme: 'butter'});
137
+ addPair('custom-theme-busy', {theme: 'butter', args: BUSY});
138
+ addPair('custom-theme-disabled', {theme: 'butter', story: 'disabled'});
139
+ addPair('custom-theme-open', {
140
+ theme: 'butter',
141
+ story: 'open-calendar-audit',
142
+ state: 'open',
143
+ });
144
+ addPair('rtl-clearable', {story: 'clearable', direction: 'rtl'});
145
+ addPair('rtl-open', {
146
+ story: 'open-calendar-audit',
147
+ state: 'open',
148
+ direction: 'rtl',
149
+ });
150
+ addPair('narrow-fine', {viewport: NARROW});
151
+ addPair('narrow-coarse-native', {
152
+ viewport: NARROW,
153
+ pointer: 'coarse',
154
+ surface: 'native',
155
+ });
156
+ addPair('narrow-coarse-sheet-open', {
157
+ viewport: NARROW,
158
+ pointer: 'coarse',
159
+ surface: 'sheet',
160
+ args: {presentation: 'adaptive-bottom-sheet'},
161
+ state: 'sheet-open',
162
+ });
163
+ addPair('forced-colors-focus-input', {
164
+ state: 'focus-input',
165
+ forcedColors: true,
166
+ });
167
+
168
+ /** Accessible name each story gives its field. */
169
+ const LABEL: Record<Story, string | null> = {
170
+ default: 'Date',
171
+ 'with-value': 'Event date',
172
+ disabled: 'Locked date',
173
+ 'disabled-with-message': 'Event date',
174
+ 'with-error-status': 'Event date',
175
+ 'with-warning-status': 'Meeting date',
176
+ 'with-success-status': 'Appointment date',
177
+ clearable: 'Event date',
178
+ 'open-calendar-audit': 'Appointment date',
179
+ 'styling-parts': null,
180
+ };
181
+
182
+ // Default mode, so a failing case does not skip the rest of the matrix.
183
+ test.describe.configure({retries: 0});
184
+ let server: StaticServer;
185
+ let checkoutSha: string;
186
+ let storybookSha: string;
187
+
188
+ function storyId(item: AuditCase) {
189
+ return `core-dateinput--${item.story}`;
190
+ }
191
+
192
+ function storyUrl(item: AuditCase) {
193
+ const args = Object.entries(item.args)
194
+ .map(([name, value]) => `${name}:${value}`)
195
+ .join(';');
196
+ return `${server.origin}/iframe.html?id=${storyId(item)}&viewMode=story${
197
+ args ? `&args=${args}` : ''
198
+ }&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
199
+ }
200
+
201
+ function isBusyCase(item: AuditCase) {
202
+ return item.args.isLoading === '!true';
203
+ }
204
+
205
+ function isDisabledCase(item: AuditCase) {
206
+ return item.story === 'disabled' || item.story === 'disabled-with-message';
207
+ }
208
+
209
+ async function settle(page: Page) {
210
+ await holdMotionStill(page);
211
+ await page.evaluate(async () => document.fonts.ready);
212
+ await page.waitForTimeout(40);
213
+ }
214
+
215
+ /** The element that carries the field's name and value on this surface. */
216
+ function fieldControl(page: Page, item: AuditCase): Locator {
217
+ return item.surface === 'native'
218
+ ? page.locator('#storybook-root input[type="date"]').first()
219
+ : page.locator('#storybook-root [role="combobox"]').first();
220
+ }
221
+
222
+ test.beforeAll(async () => {
223
+ // Never wipe: Playwright restarts the worker after a failing case and
224
+ // reruns this hook, which would delete frames already banked.
225
+ fs.mkdirSync(OUTPUT, {recursive: true});
226
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
227
+ encoding: 'utf8',
228
+ }).trim();
229
+ const expected = process.env.ASTRYX_HEAD_SHA;
230
+ if (expected && expected !== checkoutSha) {
231
+ throw new Error(
232
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
233
+ );
234
+ }
235
+ server = await serveStorybook(
236
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
237
+ );
238
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
239
+ if (!response.ok) {
240
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
241
+ }
242
+ storybookSha = (await response.text()).trim();
243
+ if (storybookSha !== checkoutSha) {
244
+ throw new Error(
245
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
246
+ );
247
+ }
248
+ });
249
+
250
+ test.afterAll(async () => {
251
+ await server?.close();
252
+ });
253
+
254
+ for (const item of cases) {
255
+ test(item.key, async ({browser}) => {
256
+ const context = await browser.newContext({
257
+ viewport: item.viewport,
258
+ colorScheme: item.mode,
259
+ reducedMotion: 'reduce',
260
+ hasTouch: item.pointer === 'coarse',
261
+ forcedColors: item.forcedColors ? 'active' : 'none',
262
+ });
263
+ const page = await context.newPage();
264
+ const pageErrors: string[] = [];
265
+ page.on('pageerror', error => pageErrors.push(String(error)));
266
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
267
+ await page.locator('#storybook-root').waitFor();
268
+ await expect(
269
+ page.locator('#storybook-root .astryx-date-input').first(),
270
+ ).toBeVisible();
271
+
272
+ const wrapper = page.locator('#storybook-root .astryx-date-input').first();
273
+ const control = fieldControl(page, item);
274
+ const label = LABEL[item.story];
275
+ if (item.surface !== 'mixed') {
276
+ await expect(control).toBeVisible();
277
+ if (label != null) {
278
+ await expect(control).toHaveAccessibleName(label);
279
+ }
280
+ }
281
+ await settle(page);
282
+
283
+ // The calendar toggle is the field's first button; its name follows the
284
+ // open state.
285
+ const toggle = wrapper.locator('button').first();
286
+ if (item.state === 'hover') {
287
+ await wrapper.hover();
288
+ } else if (item.state === 'focus-toggle') {
289
+ await page.locator('body').focus();
290
+ await page.keyboard.press('Tab');
291
+ if (item.story === 'disabled-with-message') {
292
+ // The reason tooltip opens after its show delay.
293
+ await page.waitForTimeout(800);
294
+ }
295
+ } else if (item.state === 'focus-input') {
296
+ await control.focus();
297
+ // Keyboard modality: a key press after programmatic focus.
298
+ await page.keyboard.press('Shift');
299
+ } else if (item.state === 'typed-invalid') {
300
+ await control.focus();
301
+ await page.keyboard.press('ControlOrMeta+a');
302
+ await page.keyboard.type('not a date');
303
+ } else if (item.state === 'clear-hover') {
304
+ await wrapper.getByRole('button', {name: 'Clear Event date'}).hover();
305
+ await page.waitForTimeout(800);
306
+ } else if (item.state === 'open') {
307
+ await expect(control).toHaveAttribute('aria-expanded', 'true');
308
+ await expect(page.getByRole('dialog').first()).toBeVisible();
309
+ } else if (item.state === 'sheet-open') {
310
+ await control.tap();
311
+ await expect(page.getByRole('dialog').first()).toBeVisible();
312
+ await page.waitForTimeout(120);
313
+ }
314
+ await page.evaluate(async () => document.fonts.ready);
315
+ await page.waitForTimeout(40);
316
+
317
+ const observed = await page.evaluate(() => ({
318
+ theme: document
319
+ .querySelector('[data-astryx-theme]')
320
+ ?.getAttribute('data-astryx-theme'),
321
+ mode: document.documentElement.getAttribute('data-theme'),
322
+ direction: getComputedStyle(
323
+ document.querySelector('.astryx-date-input') as HTMLElement,
324
+ ).direction,
325
+ viewport: {width: innerWidth, height: innerHeight},
326
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
327
+ coarsePointer: matchMedia('(pointer: coarse)').matches,
328
+ hoverCapable: matchMedia('(hover: hover)').matches,
329
+ forcedColors: matchMedia('(forced-colors: active)').matches,
330
+ horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
331
+ fonts: document.fonts.status,
332
+ activeElement: (() => {
333
+ const active = document.activeElement as HTMLElement | null;
334
+ if (active == null || active === document.body) {
335
+ return null;
336
+ }
337
+ return {
338
+ tag: active.tagName.toLowerCase(),
339
+ type: active.getAttribute('type'),
340
+ role: active.getAttribute('role'),
341
+ label: active.getAttribute('aria-label'),
342
+ focusVisible: active.matches(':focus-visible'),
343
+ outlineStyle: getComputedStyle(active).outlineStyle,
344
+ outlineColor: getComputedStyle(active).outlineColor,
345
+ };
346
+ })(),
347
+ // Top-layer popovers have no offsetParent, and BottomSheet is a native
348
+ // <dialog> without an explicit role, so read rendered visibility and
349
+ // both dialog forms.
350
+ tooltips: Array.from(document.querySelectorAll('[role="tooltip"]'))
351
+ .filter(node => node.checkVisibility())
352
+ .map(node => node.textContent?.replace(/\s+/g, ' ').trim()),
353
+ dialogs: Array.from(
354
+ new Set(document.querySelectorAll('[role="dialog"], dialog[open]')),
355
+ )
356
+ .filter(node => node.checkVisibility())
357
+ .map(
358
+ node =>
359
+ node.getAttribute('aria-label') ??
360
+ document
361
+ .getElementById(node.getAttribute('aria-labelledby') ?? '')
362
+ ?.textContent?.trim() ??
363
+ null,
364
+ ),
365
+ }));
366
+ const paint = await wrapper.evaluate(element => {
367
+ const style = getComputedStyle(element);
368
+ const box = element.getBoundingClientRect();
369
+ return {
370
+ hovered: element.matches(':hover'),
371
+ opacity: style.opacity,
372
+ boxShadow: style.boxShadow,
373
+ borderColor: style.borderTopColor,
374
+ backgroundColor: style.backgroundColor,
375
+ cursor: style.cursor,
376
+ themeSize: element.getAttribute('data-size'),
377
+ themeStatus: element.getAttribute('data-status'),
378
+ themeDisabled: element.getAttribute('data-disabled'),
379
+ spinners: element.querySelectorAll('[class*="astryx-spinner"]').length,
380
+ geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
381
+ };
382
+ });
383
+ const semantics =
384
+ item.surface === 'mixed'
385
+ ? null
386
+ : await control.evaluate(element => ({
387
+ tag: element.tagName.toLowerCase(),
388
+ type: element.getAttribute('type'),
389
+ role: element.getAttribute('role'),
390
+ disabledAttribute: element.hasAttribute('disabled'),
391
+ ariaDisabled: element.getAttribute('aria-disabled'),
392
+ ariaBusy: element.getAttribute('aria-busy'),
393
+ ariaInvalid: element.getAttribute('aria-invalid'),
394
+ ariaExpanded: element.getAttribute('aria-expanded'),
395
+ readOnly: (element as HTMLInputElement).readOnly,
396
+ focused: element === document.activeElement,
397
+ value: (element as HTMLInputElement).value,
398
+ alertText:
399
+ element
400
+ .closest('.astryx-date-input')
401
+ ?.querySelector('[role="alert"]')
402
+ ?.textContent?.trim() ?? null,
403
+ }));
404
+ const toggleState =
405
+ item.surface === 'popover'
406
+ ? await toggle.evaluate(element => ({
407
+ disabledAttribute: element.hasAttribute('disabled'),
408
+ ariaDisabled: element.getAttribute('aria-disabled'),
409
+ tabIndex: (element as HTMLButtonElement).tabIndex,
410
+ }))
411
+ : null;
412
+
413
+ const busy = isBusyCase(item);
414
+ const disabled = isDisabledCase(item);
415
+ let semanticsPass = true;
416
+ if (semantics != null) {
417
+ semanticsPass =
418
+ semantics.disabledAttribute === false &&
419
+ (busy ? semantics.ariaBusy === 'true' : semantics.ariaBusy === null) &&
420
+ (busy || disabled
421
+ ? semantics.ariaDisabled === 'true'
422
+ : semantics.ariaDisabled === null) &&
423
+ (item.surface === 'native'
424
+ ? semantics.tag === 'input' && semantics.type === 'date'
425
+ : semantics.role === 'combobox') &&
426
+ (toggleState == null || toggleState.disabledAttribute === false);
427
+ }
428
+
429
+ // Expectations authored from current authority before observation:
430
+ // spec:AST-085 FR1 (focusable, no native disabled), FR6 (disabled paint
431
+ // belongs to the disabled state; busy keeps its pending treatment; no
432
+ // hover affordance while disabled; keyboard focus indicator remains), and
433
+ // family:buttons FR4 (disabled and pending styling stay distinct).
434
+ let statePass = true;
435
+ if (busy) {
436
+ statePass = statePass && paint.opacity === '1' && paint.spinners >= 1;
437
+ }
438
+ if (disabled) {
439
+ statePass =
440
+ statePass &&
441
+ paint.opacity !== '1' &&
442
+ paint.themeDisabled === 'disabled';
443
+ }
444
+ if (item.state === 'hover') {
445
+ statePass = statePass && paint.hovered;
446
+ if (disabled || busy) {
447
+ statePass = statePass && paint.boxShadow === 'none';
448
+ }
449
+ } else if (item.state === 'focus-input') {
450
+ // Forced colors replaces box shadows; the receipt still records the
451
+ // forced border and outline colors for review.
452
+ statePass =
453
+ statePass &&
454
+ semantics?.focused === true &&
455
+ (item.forcedColors || paint.boxShadow !== 'none');
456
+ } else if (item.state === 'focus-toggle') {
457
+ statePass =
458
+ statePass &&
459
+ observed.activeElement?.tag === 'button' &&
460
+ observed.activeElement.focusVisible &&
461
+ observed.activeElement.outlineStyle !== 'none';
462
+ if (item.story === 'disabled-with-message') {
463
+ statePass =
464
+ statePass &&
465
+ observed.tooltips.includes('You need the Editor role to change this');
466
+ }
467
+ } else if (item.state === 'typed-invalid') {
468
+ statePass =
469
+ statePass &&
470
+ semantics?.value === 'not a date' &&
471
+ semantics.ariaInvalid === 'true' &&
472
+ (semantics.alertText ?? '').length > 0;
473
+ } else if (item.state === 'open') {
474
+ statePass =
475
+ statePass &&
476
+ semantics?.ariaExpanded === 'true' &&
477
+ observed.dialogs.length === 1;
478
+ } else if (item.state === 'sheet-open') {
479
+ statePass =
480
+ statePass &&
481
+ semantics?.ariaExpanded === 'true' &&
482
+ observed.dialogs.length >= 1;
483
+ }
484
+
485
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
486
+ const image = `${item.key}.png`;
487
+ const receipt = {
488
+ schemaVersion: 2,
489
+ expected: {
490
+ build: checkoutSha,
491
+ storyId: storyId(item),
492
+ args: item.args,
493
+ surface: item.surface,
494
+ mode: item.mode,
495
+ direction: item.direction,
496
+ theme: item.theme,
497
+ state: item.state,
498
+ pointer: item.pointer,
499
+ forcedColors: item.forcedColors,
500
+ viewport: item.viewport,
501
+ label,
502
+ busy,
503
+ disabled,
504
+ },
505
+ observed: {
506
+ build: storybookSha,
507
+ ...observed,
508
+ paint,
509
+ semantics,
510
+ toggleState,
511
+ pageErrors,
512
+ },
513
+ image: {
514
+ file: image,
515
+ sha256: createHash('sha256').update(png).digest('hex'),
516
+ width: png.readUInt32BE(16),
517
+ height: png.readUInt32BE(20),
518
+ },
519
+ checks: {semanticsPass, statePass},
520
+ passed:
521
+ storybookSha === checkoutSha &&
522
+ pageErrors.length === 0 &&
523
+ observed.theme === item.theme &&
524
+ observed.mode === item.mode &&
525
+ observed.direction === item.direction &&
526
+ observed.viewport.width === item.viewport.width &&
527
+ observed.viewport.height === item.viewport.height &&
528
+ observed.reducedMotion &&
529
+ observed.coarsePointer === (item.pointer === 'coarse') &&
530
+ observed.forcedColors === item.forcedColors &&
531
+ !observed.horizontalOverflow &&
532
+ observed.fonts === 'loaded' &&
533
+ semanticsPass &&
534
+ statePass,
535
+ };
536
+ fs.writeFileSync(path.join(OUTPUT, image), png);
537
+ fs.writeFileSync(
538
+ path.join(OUTPUT, `${item.key}.sensors.json`),
539
+ `${JSON.stringify(receipt, null, 2)}\n`,
540
+ );
541
+
542
+ await context.close();
543
+ });
544
+ }
545
+
546
+ test('manifest is complete', async () => {
547
+ const receiptFiles = fs
548
+ .readdirSync(OUTPUT)
549
+ .filter(file => file.endsWith('.sensors.json'));
550
+ const receipts = receiptFiles.map(
551
+ (file): AuditReceipt =>
552
+ JSON.parse(
553
+ fs.readFileSync(path.join(OUTPUT, file), 'utf8'),
554
+ ) as AuditReceipt,
555
+ );
556
+ const manifest = {
557
+ schemaVersion: 2,
558
+ component: 'core/DateInput',
559
+ build: checkoutSha,
560
+ frames: cases.map(item => ({
561
+ key: item.key,
562
+ image: `${item.key}.png`,
563
+ receipt: `${item.key}.sensors.json`,
564
+ })),
565
+ };
566
+ fs.writeFileSync(
567
+ path.join(OUTPUT, 'manifest.json'),
568
+ `${JSON.stringify(manifest, null, 2)}\n`,
569
+ );
570
+ expect(receiptFiles).toHaveLength(cases.length);
571
+ expect(
572
+ receipts
573
+ .filter(receipt => !receipt.passed)
574
+ .map(receipt => receipt.expected),
575
+ ).toEqual([]);
576
+ });
@@ -4,7 +4,9 @@
4
4
  * @file tokens.stylex.ts
5
5
  * @input StyleX variable and constant definitions
6
6
  * @output Exports DateInput layout constants and derived geometry
7
- * @position Lab component tokens consumed by DateInput.tsx, MonthScroller.tsx, Wheel.tsx, MonthYearWheels.tsx
7
+ * @position Core touch-picker geometry consumed by the DateInput touch surface
8
+ * (TouchDateField, MonthScroller, Wheel, MonthYearWheels) and by the
9
+ * DateTimeInput and TimeInput touch fields
8
10
  *
9
11
  * The picker's whole geometry derives from two numbers, which is what makes
10
12
  * the fixed height and the scroll snapping exact:
@@ -72,6 +72,7 @@ import {Item} from '../Item';
72
72
  import {useLayer} from '../Layer/useLayer';
73
73
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
74
74
  import {useListFocus} from '../hooks/useListFocus';
75
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
75
76
  import {useMenuHover} from '../hooks/useMenuHover';
76
77
  import {
77
78
  isMenuPressActivation,
@@ -682,6 +683,14 @@ export function DropdownMenuSubMenu(
682
683
  // closed from elsewhere is cancelled instead of firing into the hidden
683
684
  // menu (#6893).
684
685
  const isRootMenuOpen = useDropdownMenuRootOpen();
686
+ // The loading Spinner on the trigger is decorative; the submenu announces
687
+ // its busy start instead (spec:AST-086 FR8), once its level is shown: a
688
+ // closed menu stays mounted but hidden, and announces nothing.
689
+ useBusyAnnouncement(
690
+ hasSpinner && isRootMenuOpen,
691
+ t('@astryx.spinner.loading'),
692
+ triggerRef,
693
+ );
685
694
  const {triggerProps, contentProps, confirmHoverOpen} =
686
695
  useMenuHover<HTMLDivElement>({
687
696
  show: showLayer,
@@ -1033,7 +1042,7 @@ export function DropdownMenuSubMenu(
1033
1042
 
1034
1043
  const endAffordance = hasSpinner ? (
1035
1044
  <span {...stylex.props(triggerStyles.caret)}>
1036
- <Spinner size="sm" />
1045
+ <Spinner size="sm" aria-hidden="true" />
1037
1046
  </span>
1038
1047
  ) : (
1039
1048
  <span {...stylex.props(triggerStyles.caret)}>
@@ -47,6 +47,7 @@ import {Icon} from '../Icon';
47
47
  import {Spinner} from '../Spinner';
48
48
  import {VisuallyHidden} from '../VisuallyHidden';
49
49
  import {useAnnounce} from '../hooks/useAnnounce';
50
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
50
51
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
51
52
  import {useClickableContainer} from '../hooks/useClickableContainer';
52
53
  import {useTooltip} from '../Tooltip';
@@ -490,6 +491,20 @@ export function FileInput({
490
491
  const [, startTransition] = useTransition();
491
492
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
492
493
  const isBusy = isLoading || optimisticValue !== value;
494
+ // A pick is one user action, already announced as "file selected"; a busy
495
+ // period it starts (changeAction, or a caller setting isLoading from
496
+ // onChange) adds no second message (spec:AST-086 FR6). The mark lasts until
497
+ // the field is idle again. Other busy starts announce; the Spinner stays
498
+ // decorative (FR8).
499
+ const [isPickAnnounced, setIsPickAnnounced] = useState(false);
500
+ if (isPickAnnounced && !isBusy) {
501
+ setIsPickAnnounced(false);
502
+ }
503
+ useBusyAnnouncement(
504
+ isBusy && !isPickAnnounced,
505
+ t('@astryx.spinner.loading'),
506
+ containerRef,
507
+ );
493
508
 
494
509
  // Announce successful file selection to screen readers via a persistent
495
510
  // live region (forms-17). Validation errors are announced (assertively) by
@@ -580,6 +595,7 @@ export function FileInput({
580
595
  // status mounts, so only announce the attach here (do not
581
596
  // double-announce errors).
582
597
  if (errors.length === 0) {
598
+ setIsPickAnnounced(true);
583
599
  announce(
584
600
  valid.length === 1
585
601
  ? t('@astryx.fileInput.fileSelected', {fileName: valid[0].name})
@@ -741,7 +757,7 @@ export function FileInput({
741
757
 
742
758
  const renderDropzoneContent = () => {
743
759
  if (isBusy) {
744
- return <Spinner size="md" />;
760
+ return <Spinner size="md" aria-hidden="true" />;
745
761
  }
746
762
  if (hasFiles) {
747
763
  return (
@@ -772,7 +788,7 @@ export function FileInput({
772
788
  <span {...stylex.props(styles.fileNameText)}>
773
789
  {fileNames ?? displayPlaceholder}
774
790
  </span>
775
- <Spinner size="sm" />
791
+ <Spinner size="sm" aria-hidden="true" />
776
792
  </>
777
793
  );
778
794
  }