@astryxdesign/core 0.7.4-canary.faa934f → 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 (44) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  3. package/dist/CommandPalette/CommandPalette.js +6 -11
  4. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  5. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  6. package/dist/CommandPalette/CommandPaletteItem.d.ts +1 -0
  7. package/dist/CommandPalette/CommandPaletteItem.d.ts.map +1 -1
  8. package/dist/CommandPalette/CommandPaletteItem.js +5 -1
  9. package/dist/DateInput/DateInput.d.ts +6 -4
  10. package/dist/DateInput/DateInput.d.ts.map +1 -1
  11. package/dist/DateInput/DateInput.js +5 -1
  12. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  13. package/dist/DateInput/NativeDateField.js +5 -1
  14. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  15. package/dist/DateInput/TouchDateField.js +5 -1
  16. package/dist/DateInput/tokens.stylex.d.ts.map +1 -1
  17. package/dist/DateInput/tokens.stylex.js +3 -1
  18. package/dist/FileInput/FileInput.d.ts.map +1 -1
  19. package/dist/FileInput/FileInput.js +16 -2
  20. package/package.json +4 -4
  21. package/src/BottomSheet/BottomSheet.spec.md +1 -0
  22. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +25 -2
  23. package/src/BottomSheet/BottomSheetSwitcher.spec.md +7 -6
  24. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +221 -1
  25. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  26. package/src/CommandPalette/CommandPalette.doc.mjs +4 -1
  27. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  28. package/src/CommandPalette/CommandPalette.tsx +6 -11
  29. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  30. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  31. package/src/CommandPalette/CommandPaletteItem.doc.mjs +17 -5
  32. package/src/CommandPalette/CommandPaletteItem.test.tsx +65 -12
  33. package/src/CommandPalette/CommandPaletteItem.tsx +6 -1
  34. package/src/CommandPalette/__tests__/CommandPaletteItemAudit.a11y.chromium.spec.ts +250 -20
  35. package/src/DateInput/DateInput.doc.mjs +23 -5
  36. package/src/DateInput/DateInput.spec.md +52 -45
  37. package/src/DateInput/DateInput.tsx +13 -7
  38. package/src/DateInput/NativeDateField.tsx +6 -2
  39. package/src/DateInput/TouchDateField.tsx +6 -2
  40. package/src/DateInput/__tests__/DateInputAudit.a11y.chromium.spec.ts +576 -0
  41. package/src/DateInput/tokens.stylex.ts +3 -1
  42. package/src/FileInput/FileInput.tsx +18 -2
  43. package/src/Overlay/Overlay.spec.md +304 -0
  44. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +217 -1
@@ -0,0 +1,461 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BottomSheetNativeModal.a11y.browser.spec.ts
5
+ * @input The `Core/BottomSheetSwitcher` stories in a built Storybook, and
6
+ * real Chromium/WebKit wheel, keyboard, and pointer input.
7
+ * @output Browser evidence for a flow whose every sheet owns a native modal
8
+ * dialog: the flow's one page scroll lock (the shared hook, as Dialog)
9
+ * holding while two sheet dialogs are open through a handoff and under a
10
+ * Core Dialog opened from a step, never released between steps nor when
11
+ * that Dialog closes, and the page's offset restored after the exit; Tab landing inside the entering sheet's dialog
12
+ * as soon as both dialogs are open and on Tab stops for as long as the
13
+ * retained dialog is open beneath it (its fade included), never the page
14
+ * once the handoff is done, by the browser's own containment, with the
15
+ * retained dialog carrying `inert` and `aria-hidden` on every step it is
16
+ * open; Escape and
17
+ * the backdrop dismissing by purpose with focus returned to the opener;
18
+ * and one steady scrim through a handoff.
19
+ * @position Cross-browser proof; jsdom presents no dialog and contains no
20
+ * focus, so BottomSheetSwitcher.test.tsx can only assert the calls. WebKit
21
+ * here is desktop WebKit under Playwright, not iOS Safari.
22
+ */
23
+
24
+ import {expect, test, type Page} from '@playwright/test';
25
+ import {
26
+ DEFAULT_STORYBOOK_DIR,
27
+ serveStorybook,
28
+ type StaticServer,
29
+ } from '@astryxdesign/a11y-spec/storybook';
30
+
31
+ let storybook: StaticServer;
32
+ test.beforeAll(async () => {
33
+ storybook = await serveStorybook(
34
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
35
+ );
36
+ });
37
+ test.afterAll(async () => {
38
+ await storybook?.close();
39
+ });
40
+
41
+ test.use({viewport: {width: 390, height: 844}, hasTouch: true});
42
+
43
+ const OPENER = 'Set up notifications';
44
+
45
+ /**
46
+ * Open a switcher story with the flow closed and the page made scrollable:
47
+ * a tall spacer below the story, scrolled part way down, so a lock that
48
+ * fails would show as a change in `scrollY`.
49
+ */
50
+ async function mountScrollable(page: Page, story = 'hug-content') {
51
+ await page.goto(
52
+ `${storybook.origin}/iframe.html?id=core-bottomsheetswitcher--${story}&viewMode=story`,
53
+ );
54
+ // The story's play function opens the flow; start from closed.
55
+ await expect(page.locator('dialog[open]')).toHaveCount(1);
56
+ await page.keyboard.press('Escape');
57
+ await expect(page.locator('dialog[open]')).toHaveCount(0);
58
+ await page.evaluate(() => {
59
+ const spacer = document.createElement('div');
60
+ spacer.id = 'spacer';
61
+ spacer.style.height = '3000px';
62
+ document.body.appendChild(spacer);
63
+ const counter = window as {__scrollEvents?: number};
64
+ counter.__scrollEvents = 0;
65
+ window.addEventListener('scroll', () => {
66
+ counter.__scrollEvents = (counter.__scrollEvents ?? 0) + 1;
67
+ });
68
+ window.scrollTo(0, 300);
69
+ });
70
+ expect(await page.evaluate(() => window.scrollY)).toBe(300);
71
+ }
72
+
73
+ /**
74
+ * The shared lock's pin on <body>, and the page's offset as the lock sees
75
+ * it: while pinned, `scrollY` reads 0 and the saved offset is `top`; after
76
+ * release, `scrollY` is the restored offset. `scrollEvents` counts window
77
+ * scroll events since `resetScrollCounter`: a lock released and re-acquired
78
+ * between steps restores the offset and pins again, which fires one.
79
+ */
80
+ async function lockState(page: Page): Promise<{
81
+ position: string;
82
+ top: string;
83
+ scrollY: number;
84
+ scrollEvents: number;
85
+ }> {
86
+ return page.evaluate(() => ({
87
+ position: document.body.style.position,
88
+ top: document.body.style.top,
89
+ scrollY: window.scrollY,
90
+ scrollEvents: (window as {__scrollEvents?: number}).__scrollEvents ?? 0,
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Zero the counter once the events already owed have arrived: the spacer
96
+ * scroll in `mountScrollable` and the lock's own pin (300 → 0) each fire one
97
+ * window `scroll` event, and WebKit delivers them late on a cold run.
98
+ */
99
+ async function resetScrollCounter(page: Page) {
100
+ await page
101
+ .waitForFunction(
102
+ () => ((window as {__scrollEvents?: number}).__scrollEvents ?? 0) >= 2,
103
+ undefined,
104
+ {timeout: 3000},
105
+ )
106
+ .catch(() => {});
107
+ await page.evaluate(() => {
108
+ (window as {__scrollEvents?: number}).__scrollEvents = 0;
109
+ });
110
+ }
111
+
112
+ async function wheelDown(page: Page) {
113
+ // Over the top of the viewport: the backdrop when a modal sheet is open,
114
+ // the page otherwise.
115
+ await page.mouse.move(195, 80);
116
+ await page.mouse.wheel(0, 400);
117
+ await page.waitForTimeout(250);
118
+ }
119
+
120
+ /** A finger drag over the backdrop; Chromium only (CDP), WebKit has no API. */
121
+ async function touchScrollDown(page: Page, browserName: string) {
122
+ if (browserName !== 'chromium') {
123
+ return;
124
+ }
125
+ const session = await page.context().newCDPSession(page);
126
+ await session.send('Input.synthesizeScrollGesture', {
127
+ x: 195,
128
+ y: 120,
129
+ yDistance: -300,
130
+ gestureSourceType: 'touch',
131
+ speed: 800,
132
+ });
133
+ await session.detach();
134
+ await page.waitForTimeout(250);
135
+ }
136
+
137
+ /**
138
+ * Open the flow from its opener without scrolling the page: a pointer click
139
+ * would first scroll the opener into view, and the lock tests need the page
140
+ * to stay where it was scrolled. The opener is focused first, as a real
141
+ * activation would leave it, so focus return has somewhere to go.
142
+ */
143
+ async function openFlow(page: Page) {
144
+ await page.evaluate(name => {
145
+ const opener = Array.from(document.querySelectorAll('button')).find(
146
+ button => button.textContent?.trim() === name,
147
+ );
148
+ opener?.focus({preventScroll: true});
149
+ opener?.click();
150
+ }, OPENER);
151
+ await expect(page.getByRole('dialog', {name: OPENER})).toBeVisible();
152
+ await page.waitForTimeout(500);
153
+ }
154
+
155
+ test('the flow holds one page scroll lock through a handoff and under a Core Dialog opened from a step, never released between steps, and restores the offset after the exit', async ({
156
+ page,
157
+ browserName,
158
+ }) => {
159
+ await mountScrollable(page, 'dialog-from-step');
160
+ await openFlow(page);
161
+ let state = await lockState(page);
162
+ expect(state.position).toBe('fixed');
163
+ expect(state.top).toBe('-300px');
164
+ await resetScrollCounter(page);
165
+
166
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
167
+ await expect(page.locator('dialog[open]')).toHaveCount(2);
168
+ state = await lockState(page);
169
+ expect(state.position).toBe('fixed');
170
+ expect(state.top).toBe('-300px');
171
+ // Wheel and finger over the backdrop with both dialogs open: the pinned
172
+ // page does not move.
173
+ await wheelDown(page);
174
+ await touchScrollDown(page, browserName);
175
+ state = await lockState(page);
176
+ expect(state.position).toBe('fixed');
177
+ expect(state.top).toBe('-300px');
178
+ expect(state.scrollY).toBe(0);
179
+ await expect(page.locator('dialog[open]')).toHaveCount(1, {timeout: 3000});
180
+ // One lock for the flow: the handoff released nothing, so the page never
181
+ // scrolled back and forth between steps.
182
+ state = await lockState(page);
183
+ expect(state.position).toBe('fixed');
184
+ expect(state.scrollEvents).toBe(0);
185
+
186
+ // A Core Dialog opened from the step, as a builder would, takes the same
187
+ // shared lock a second time; closing it must give back only its own hold,
188
+ // leaving the flow's pin in place with the saved offset untouched.
189
+ await page.getByRole('button', {name: 'Reset', exact: true}).click();
190
+ const resetDialog = page.getByRole('dialog', {name: 'Reset frequency?'});
191
+ await expect(resetDialog).toBeVisible();
192
+ await wheelDown(page);
193
+ state = await lockState(page);
194
+ expect(state.position).toBe('fixed');
195
+ expect(state.top).toBe('-300px');
196
+ expect(state.scrollY).toBe(0);
197
+ await page.getByRole('button', {name: 'Keep', exact: true}).click();
198
+ await expect(resetDialog).toBeHidden();
199
+ await page.waitForTimeout(400);
200
+ state = await lockState(page);
201
+ expect(state.position).toBe('fixed');
202
+ expect(state.top).toBe('-300px');
203
+ expect(state.scrollY).toBe(0);
204
+ expect(state.scrollEvents).toBe(0);
205
+ await wheelDown(page);
206
+ expect(await page.evaluate(() => window.scrollY)).toBe(0);
207
+ // The Dialog returned focus into the step, so Escape closes the flow.
208
+ await expect(page.locator('dialog[open]')).toHaveCount(1);
209
+
210
+ await page.keyboard.press('Escape');
211
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
212
+ state = await lockState(page);
213
+ expect(state.position).toBe('');
214
+ expect(state.top).toBe('');
215
+ expect(state.scrollY).toBe(300);
216
+ // The page scrolls again.
217
+ await wheelDown(page);
218
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(300);
219
+ });
220
+
221
+ /**
222
+ * One synchronous read per step: how many sheet dialogs are open, where focus
223
+ * is (and whether it moved since the last read and sits outside any inert
224
+ * subtree), and whether the retained dialog (by accessible name) still
225
+ * carries `inert` and `aria-hidden`. Read together so a close between two
226
+ * reads cannot make a stale attribute look like a regression, or hide one.
227
+ */
228
+ async function handoffStep(
229
+ page: Page,
230
+ retainedName: string,
231
+ ): Promise<{
232
+ openCount: number;
233
+ /** Page-clock ms since the second dialog opened; -1 before that. */
234
+ sinceBothOpen: number;
235
+ where: string;
236
+ moved: boolean;
237
+ insideInert: boolean;
238
+ retained: {open: boolean; inert: boolean; ariaHidden: string | null} | null;
239
+ }> {
240
+ return page.evaluate(name => {
241
+ const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));
242
+ const clock = window as {__bothOpenAt?: number};
243
+ const sinceBothOpen =
244
+ clock.__bothOpenAt == null
245
+ ? -1
246
+ : Math.round(performance.now() - clock.__bothOpenAt);
247
+ const active = document.activeElement;
248
+ const tracker = window as {__lastActive?: Element | null};
249
+ const moved = active !== tracker.__lastActive;
250
+ tracker.__lastActive = active;
251
+ const insideInert = active?.closest('[inert]') != null;
252
+ const activeDialog = active?.closest('dialog') ?? null;
253
+ const where =
254
+ active == null || active === document.body
255
+ ? 'body'
256
+ : activeDialog == null
257
+ ? 'page'
258
+ : (activeDialog.getAttribute('aria-label') ?? '?');
259
+ const retainedDialog = Array.from(document.querySelectorAll('dialog')).find(
260
+ dialog => dialog.getAttribute('aria-label') === name,
261
+ );
262
+ return {
263
+ openCount: openDialogs.length,
264
+ sinceBothOpen,
265
+ where,
266
+ moved,
267
+ insideInert,
268
+ retained:
269
+ retainedDialog == null
270
+ ? null
271
+ : {
272
+ open: retainedDialog.hasAttribute('open'),
273
+ inert: retainedDialog.hasAttribute('inert'),
274
+ ariaHidden: retainedDialog.getAttribute('aria-hidden'),
275
+ },
276
+ };
277
+ }, retainedName);
278
+ }
279
+
280
+ test('focus is inside the entering sheet as soon as both dialogs are open, and Tab reaches stops inside it for as long as the retained dialog is open, which stays inert and hidden through its fade, and inside the flow after the handoff', async ({
281
+ page,
282
+ }) => {
283
+ await mountScrollable(page);
284
+ await openFlow(page);
285
+ // Stamp the page clock the moment the second dialog opens, so the late
286
+ // window below is measured from the handoff itself, not from when the
287
+ // test runner got around to reading it on a slow run.
288
+ await page.evaluate(() => {
289
+ const clock = window as {__bothOpenAt?: number};
290
+ clock.__bothOpenAt = undefined;
291
+ const observer = new MutationObserver(() => {
292
+ if (
293
+ clock.__bothOpenAt == null &&
294
+ document.querySelectorAll('dialog[open]').length >= 2
295
+ ) {
296
+ clock.__bothOpenAt = performance.now();
297
+ observer.disconnect();
298
+ }
299
+ });
300
+ observer.observe(document.body, {
301
+ subtree: true,
302
+ attributes: true,
303
+ attributeFilter: ['open'],
304
+ });
305
+ });
306
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
307
+ // Both dialogs are open for the handoff's ~650ms: the retained one holds
308
+ // still, then fades from ~345ms, then closes. Step with Tab until it has
309
+ // closed, reading open count, focus, and the retained dialog's attributes
310
+ // in one evaluate per step: focus must land in the entering sheet's dialog
311
+ // ("Notification frequency"), never in the retained one ("Set up
312
+ // notifications") or the page, and the retained dialog must carry `inert`
313
+ // and `aria-hidden` on every step it is still open, the fade included.
314
+ await expect(page.locator('dialog[open]')).toHaveCount(2);
315
+ // The entering sheet takes focus with its presentation, before any Tab:
316
+ // the moment both dialogs are open, focus is inside "Notification
317
+ // frequency" and outside every inert subtree. An entering sheet made
318
+ // inert while it enters would leave focus on <body> for its first ~300ms.
319
+ const atOpen = await handoffStep(page, OPENER);
320
+ expect(atOpen.openCount).toBe(2);
321
+ expect(atOpen.where, 'focus as soon as both dialogs are open').toBe(
322
+ 'Notification frequency',
323
+ );
324
+ expect(atOpen.insideInert, 'focus inside an inert subtree at open').toBe(
325
+ false,
326
+ );
327
+ expect(atOpen.sinceBothOpen).toBeGreaterThanOrEqual(0);
328
+ let midHandoffPresses = 0;
329
+ let lateHandoffPresses = 0;
330
+ let midHandoffStopsInside = 0;
331
+ let lateHandoffStopsInside = 0;
332
+ for (let index = 0; index < 40; index += 1) {
333
+ await page.keyboard.press('Tab');
334
+ const step = await handoffStep(page, OPENER);
335
+ if (step.openCount < 2 || step.retained?.open !== true) {
336
+ break;
337
+ }
338
+ midHandoffPresses += 1;
339
+ // "Late" is inside the retained dialog's fade (from ~345ms after both
340
+ // opened), on the page's own clock.
341
+ const isLate = step.sinceBothOpen > 400;
342
+ if (isLate) {
343
+ lateHandoffPresses += 1;
344
+ }
345
+ const label = `mid-handoff, Tab ${index + 1} at ${step.sinceBothOpen}ms`;
346
+ expect(step.where, label).not.toBe('page');
347
+ expect(step.where, label).not.toBe(OPENER);
348
+ expect(['Notification frequency', 'body'], label).toContain(step.where);
349
+ // A stop inside the entering sheet is a Tab that moved focus onto a
350
+ // focusable element there, outside any inert subtree; focus merely left
351
+ // where the presentation put it, inside a dialog that went inert, is not.
352
+ expect(step.insideInert, `${label}: focus inside an inert subtree`).toBe(
353
+ false,
354
+ );
355
+ if (step.where === 'Notification frequency' && step.moved) {
356
+ midHandoffStopsInside += 1;
357
+ if (isLate) {
358
+ lateHandoffStopsInside += 1;
359
+ }
360
+ }
361
+ expect(step.retained.inert, `${label}: retained dialog inert`).toBe(true);
362
+ expect(step.retained.ariaHidden, `${label}: retained aria-hidden`).toBe(
363
+ 'true',
364
+ );
365
+ await page.waitForTimeout(40);
366
+ }
367
+ // Presses landed both before and during the retained dialog's fade, else
368
+ // the test proved nothing about the handoff; and at least one of them
369
+ // stopped inside the entering sheet, else an entering sheet that can never
370
+ // take focus (every stop on the browser-UI `body` stop) would pass.
371
+ expect(midHandoffPresses).toBeGreaterThan(2);
372
+ expect(lateHandoffPresses).toBeGreaterThan(0);
373
+ expect(midHandoffStopsInside).toBeGreaterThan(0);
374
+ // ...and at least one of those stops landed inside the retained dialog's
375
+ // fade (from ~345ms), so an entering sheet that went inert only while the
376
+ // retained one fades would not pass.
377
+ expect(lateHandoffStopsInside).toBeGreaterThan(0);
378
+
379
+ await expect(page.locator('dialog[open]')).toHaveCount(1, {timeout: 3000});
380
+ let stopsInside = 0;
381
+ for (let index = 0; index < 8; index += 1) {
382
+ await page.keyboard.press('Tab');
383
+ const step = await handoffStep(page, OPENER);
384
+ expect(step.where, `after the handoff, Tab ${index + 1}`).not.toBe('page');
385
+ expect(['Notification frequency', 'body']).toContain(step.where);
386
+ expect(step.insideInert, `after the handoff, Tab ${index + 1}`).toBe(false);
387
+ if (step.where === 'Notification frequency' && step.moved) {
388
+ stopsInside += 1;
389
+ }
390
+ }
391
+ expect(stopsInside).toBeGreaterThan(0);
392
+ });
393
+
394
+ test('Escape and a backdrop tap dismiss an info flow and return focus to the opener', async ({
395
+ page,
396
+ }) => {
397
+ await mountScrollable(page);
398
+ const opener = page.getByRole('button', {name: OPENER});
399
+ await openFlow(page);
400
+ await page.getByRole('button', {name: 'Continue', exact: true}).click();
401
+ await page.waitForTimeout(900);
402
+
403
+ await page.keyboard.press('Escape');
404
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
405
+ await expect(opener).toBeFocused();
406
+
407
+ await openFlow(page);
408
+ await page.mouse.click(195, 60);
409
+ await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000});
410
+ await expect(opener).toBeFocused();
411
+ });
412
+
413
+ test('one steady scrim through a handoff: no dip and no double dim', async ({
414
+ page,
415
+ }) => {
416
+ await mountScrollable(page);
417
+ await openFlow(page);
418
+ const samples = await page.evaluate(
419
+ async () =>
420
+ new Promise<number[]>(resolve => {
421
+ const dims: number[] = [];
422
+ const start = performance.now();
423
+ const tick = () => {
424
+ let combined = 1;
425
+ for (const dialog of document.querySelectorAll('dialog[open]')) {
426
+ if (!dialog.matches(':modal')) {
427
+ continue;
428
+ }
429
+ const style = getComputedStyle(dialog, '::backdrop');
430
+ const match = /rgba?\(([^)]+)\)/.exec(style.backgroundColor);
431
+ const parts = match
432
+ ? match[1].split(/[,/]\s*|\s+/).filter(Boolean)
433
+ : [];
434
+ const alpha = parts.length === 4 ? Number(parts[3]) : 1;
435
+ combined *= 1 - alpha * Number(style.opacity);
436
+ }
437
+ dims.push(Number((1 - combined).toFixed(3)));
438
+ if (performance.now() - start < 1100) {
439
+ requestAnimationFrame(tick);
440
+ } else {
441
+ resolve(dims);
442
+ }
443
+ };
444
+ Array.from(
445
+ document.querySelectorAll<HTMLButtonElement>(
446
+ 'dialog[open]:not([inert]) button',
447
+ ),
448
+ )
449
+ .find(button => button.textContent?.trim() === 'Continue')
450
+ ?.click();
451
+ requestAnimationFrame(tick);
452
+ }),
453
+ );
454
+ expect(samples.length).toBeGreaterThan(20);
455
+ const steady = samples[0];
456
+ expect(steady).toBeGreaterThan(0.3);
457
+ for (const dim of samples) {
458
+ expect(dim).toBeGreaterThanOrEqual(steady * 0.9);
459
+ expect(dim).toBeLessThanOrEqual(steady * 1.05);
460
+ }
461
+ });
@@ -225,7 +225,10 @@ export const docs = {
225
225
  {className: 'astryx-command-palette-group'},
226
226
  {className: 'astryx-command-palette-group-heading'},
227
227
  {className: 'astryx-command-palette-input'},
228
- {className: 'astryx-command-palette-item'},
228
+ {
229
+ className: 'astryx-command-palette-item',
230
+ states: ['highlighted', 'selected', 'disabled'],
231
+ },
229
232
  {className: 'astryx-command-palette-list'},
230
233
  ],
231
234
  },
@@ -729,7 +729,7 @@ describe('CommandPalette', () => {
729
729
  });
730
730
  });
731
731
 
732
- it('does not announce on initial open (bootstrap)', async () => {
732
+ it('announces no result count on initial open (bootstrap)', async () => {
733
733
  render(
734
734
  <CommandPalette
735
735
  isOpen={true}
@@ -742,7 +742,9 @@ describe('CommandPalette', () => {
742
742
  await act(async () => {
743
743
  await new Promise(resolve => setTimeout(resolve, 50));
744
744
  });
745
- expect(politeRegion()?.textContent ?? '').toBe('');
745
+ // The bootstrap's busy start may say "Loading" (spec:AST-086 FR3, FR8);
746
+ // its results are never counted aloud.
747
+ expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
746
748
  });
747
749
  });
748
750
  });
@@ -318,11 +318,11 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
318
318
  const searchVersionRef = useRef(0);
319
319
  const emptyStateRef = useRef<HTMLDivElement>(null);
320
320
 
321
- // Announce search status to screen readers through the shared polite live
321
+ // Announce search results to screen readers through the shared polite live
322
322
  // region (comboboxes-7 announce path, mirroring Selector / BaseTypeahead).
323
- // The busy spinner and the empty states are otherwise purely visual, so a
324
- // screen-reader user typing a query would hear nothing when loading starts
325
- // or results disappear.
323
+ // The empty states are otherwise purely visual, so a screen-reader user
324
+ // typing a query would hear nothing when results disappear. The busy start
325
+ // is announced by CommandPaletteInput.
326
326
  const announce = useAnnounce();
327
327
 
328
328
  const value = controlledValue ?? internalValue;
@@ -407,13 +407,8 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
407
407
  startTransition(async () => {
408
408
  const isBootstrap = query === '';
409
409
 
410
- // Loading started for a user query: tell screen-reader users the
411
- // spinner appeared. The polite region coalesces rapid updates, so for
412
- // fast sources the result-count announcement below simply replaces
413
- // this instead of stacking one "Loading" per keystroke.
414
- if (!isBootstrap) {
415
- announce(t('@astryx.commandPalette.loading'));
416
- }
410
+ // The busy start ("Loading") is announced by CommandPaletteInput,
411
+ // from a region inside the palette's dialog (spec:AST-086 FR8, FR3).
417
412
 
418
413
  // Client-filter previous results for instant narrowing while fetch is in flight
419
414
  if (!isBootstrap && searchResults.length > 0) {
@@ -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
  },