@astryxdesign/core 0.7.3 → 0.7.4-canary.10b832e

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 (131) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +18 -6
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +268 -58
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
  5. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
  7. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
  8. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
  10. package/dist/Carousel/Carousel.d.ts.map +1 -1
  11. package/dist/Carousel/Carousel.js +7 -11
  12. package/dist/Chat/ChatMessageList.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageList.js +8 -1
  14. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  15. package/dist/Chat/useTriggerMenu.js +46 -7
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  18. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  19. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  20. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  21. package/dist/ContextMenu/ContextMenu.js +12 -0
  22. package/dist/DateInput/DateInput.d.ts.map +1 -1
  23. package/dist/DateInput/DateInput.js +6 -1
  24. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  25. package/dist/DateInput/NativeDateField.js +6 -1
  26. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  27. package/dist/DateInput/TouchDateField.js +9 -1
  28. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  29. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  30. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  31. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  32. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  33. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  34. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  35. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  36. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  37. package/dist/MultiSelector/MultiSelector.js +6 -1
  38. package/dist/Selector/Selector.d.ts.map +1 -1
  39. package/dist/Selector/Selector.js +6 -1
  40. package/dist/TextArea/TextArea.d.ts.map +1 -1
  41. package/dist/TextArea/TextArea.js +6 -1
  42. package/dist/TextInput/TextInput.d.ts.map +1 -1
  43. package/dist/TextInput/TextInput.js +6 -1
  44. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  45. package/dist/Thumbnail/Thumbnail.js +9 -2
  46. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  47. package/dist/TimeInput/TimeInput.js +6 -1
  48. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  49. package/dist/TimeInput/TouchTimeField.js +9 -1
  50. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  51. package/dist/Tokenizer/Tokenizer.js +9 -1
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +7 -1
  54. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  55. package/dist/Typeahead/Typeahead.js +9 -1
  56. package/dist/astryx.css +2 -0
  57. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  58. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  59. package/dist/hooks/useBusyAnnouncement.js +87 -0
  60. package/dist/hooks/useMenuPress.d.ts +2 -2
  61. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  62. package/dist/hooks/useMenuPress.js +5 -1
  63. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  64. package/dist/hooks/useTypeahead.js +7 -2
  65. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  66. package/dist/i18n/generated-locales/en.generated.js +1 -0
  67. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  68. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  69. package/locales/en.json +6 -2
  70. package/locales/pseudo.json +3 -0
  71. package/package.json +4 -4
  72. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  73. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  74. package/src/BottomSheet/BottomSheet.tsx +260 -47
  75. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  76. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  77. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  78. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  79. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  80. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  81. package/src/Carousel/Carousel.test.tsx +8 -12
  82. package/src/Carousel/Carousel.tsx +6 -13
  83. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  84. package/src/Chat/ChatMessageList.test.tsx +6 -2
  85. package/src/Chat/ChatMessageList.tsx +7 -1
  86. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  87. package/src/Chat/useTriggerMenu.tsx +55 -6
  88. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  89. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  90. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  91. package/src/ContextMenu/ContextMenu.tsx +13 -1
  92. package/src/DateInput/DateInput.tsx +5 -1
  93. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  94. package/src/DateInput/NativeDateField.test.tsx +10 -2
  95. package/src/DateInput/NativeDateField.tsx +5 -1
  96. package/src/DateInput/TouchDateField.tsx +14 -2
  97. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  98. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  99. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  100. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  101. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  102. package/src/MultiSelector/MultiSelector.tsx +5 -1
  103. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  104. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  105. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  106. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  107. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  108. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  109. package/src/Selector/Selector.test.tsx +22 -5
  110. package/src/Selector/Selector.tsx +5 -1
  111. package/src/TextArea/TextArea.test.tsx +9 -1
  112. package/src/TextArea/TextArea.tsx +5 -1
  113. package/src/TextInput/TextInput.tsx +5 -1
  114. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  115. package/src/Thumbnail/Thumbnail.tsx +12 -3
  116. package/src/TimeInput/TimeInput.tsx +5 -1
  117. package/src/TimeInput/TouchTimeField.tsx +12 -2
  118. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  119. package/src/Tokenizer/Tokenizer.tsx +10 -1
  120. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  121. package/src/Typeahead/Typeahead.test.tsx +32 -41
  122. package/src/Typeahead/Typeahead.tsx +10 -1
  123. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +576 -0
  124. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  125. package/src/hooks/useBusyAnnouncement.ts +100 -0
  126. package/src/hooks/useMenuPress.test.tsx +27 -1
  127. package/src/hooks/useMenuPress.ts +12 -3
  128. package/src/hooks/useTypeahead.test.tsx +42 -1
  129. package/src/hooks/useTypeahead.ts +7 -2
  130. package/src/i18n/generated-locales/en.generated.ts +1 -0
  131. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -0,0 +1,576 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file BusyOwnerAnnouncement.test.tsx
5
+ * @input Uses vitest, @testing-library/react, and the busy controls under test
6
+ * @output Pins spec:AST-086 FR8 and FR6 for controls that show a busy Spinner
7
+ * @position Shared contract test: the composed Spinner is silent and the
8
+ * owning control announces each busy start once through the shared channel
9
+ */
10
+
11
+ import {StrictMode, useEffect, useRef, type ReactElement} from 'react';
12
+ import {
13
+ afterAll,
14
+ afterEach,
15
+ beforeAll,
16
+ beforeEach,
17
+ describe,
18
+ expect,
19
+ it,
20
+ vi,
21
+ } from 'vitest';
22
+ import {act, fireEvent, render, screen, within} from '@testing-library/react';
23
+ import {TextInput} from '../TextInput';
24
+ import {TextArea} from '../TextArea';
25
+ import {Selector} from '../Selector';
26
+ import {MultiSelector} from '../MultiSelector';
27
+ import {ComplexSelector} from '../ComplexSelector';
28
+ import {DateInput} from '../DateInput';
29
+ import {DateRangeInput} from '../DateRangeInput';
30
+ import {DateTimeInput} from '../DateTimeInput';
31
+ import {TimeInput} from '../TimeInput';
32
+ import {CheckboxInput} from '../CheckboxInput';
33
+ import {Thumbnail} from '../Thumbnail';
34
+ import {
35
+ DropdownMenu,
36
+ DropdownMenuItem,
37
+ DropdownMenuSubMenu,
38
+ } from '../DropdownMenu';
39
+ import {ChatMessageList} from '../Chat';
40
+ import {BaseTypeahead, Typeahead} from '../Typeahead';
41
+ import type {SearchableItem, SearchSource} from '../Typeahead';
42
+ import type {ISOTimeString} from '../utils';
43
+ import {Tokenizer} from '../Tokenizer';
44
+
45
+ const announce = vi.fn();
46
+ vi.mock('../hooks/useAnnounce', async importOriginal => ({
47
+ ...(await importOriginal<Record<string, unknown>>()),
48
+ useAnnounce: () => announce,
49
+ }));
50
+
51
+ const OPTIONS = [
52
+ {label: 'Apple', value: 'apple'},
53
+ {label: 'Banana', value: 'banana'},
54
+ ];
55
+
56
+ const OWNERS: ReadonlyArray<
57
+ readonly [string, (isLoading: boolean) => ReactElement]
58
+ > = [
59
+ [
60
+ 'TextInput',
61
+ isLoading => (
62
+ <TextInput
63
+ label="Email"
64
+ value=""
65
+ onChange={() => {}}
66
+ isLoading={isLoading}
67
+ />
68
+ ),
69
+ ],
70
+ [
71
+ 'TextArea',
72
+ isLoading => (
73
+ <TextArea
74
+ label="Notes"
75
+ value=""
76
+ onChange={() => {}}
77
+ isLoading={isLoading}
78
+ />
79
+ ),
80
+ ],
81
+ [
82
+ 'Selector',
83
+ isLoading => (
84
+ <Selector
85
+ label="Fruit"
86
+ options={OPTIONS}
87
+ onChange={() => {}}
88
+ isLoading={isLoading}
89
+ />
90
+ ),
91
+ ],
92
+ [
93
+ 'MultiSelector',
94
+ isLoading => (
95
+ <MultiSelector
96
+ label="Fruits"
97
+ options={OPTIONS}
98
+ value={[]}
99
+ onChange={() => {}}
100
+ isLoading={isLoading}
101
+ />
102
+ ),
103
+ ],
104
+ ...(['popover', 'native', 'bottom-sheet'] as const).map(
105
+ presentation =>
106
+ [
107
+ `DateInput (${presentation})`,
108
+ (isLoading: boolean) => (
109
+ <DateInput
110
+ label="Date"
111
+ presentation={presentation}
112
+ onChange={() => {}}
113
+ isLoading={isLoading}
114
+ />
115
+ ),
116
+ ] as const,
117
+ ),
118
+ ...(['popover', 'native', 'bottom-sheet'] as const).flatMap(presentation => [
119
+ [
120
+ `DateTimeInput (${presentation})`,
121
+ (isLoading: boolean) => (
122
+ <DateTimeInput
123
+ label="When"
124
+ presentation={presentation}
125
+ onChange={() => {}}
126
+ isLoading={isLoading}
127
+ />
128
+ ),
129
+ ] as const,
130
+ [
131
+ `TimeInput (${presentation})`,
132
+ (isLoading: boolean) => (
133
+ <TimeInput
134
+ label="Time"
135
+ presentation={presentation}
136
+ onChange={() => {}}
137
+ isLoading={isLoading}
138
+ />
139
+ ),
140
+ ] as const,
141
+ ]),
142
+ [
143
+ 'CheckboxInput',
144
+ isLoading => (
145
+ <CheckboxInput
146
+ label="Subscribe"
147
+ value={false}
148
+ onChange={() => {}}
149
+ isLoading={isLoading}
150
+ />
151
+ ),
152
+ ],
153
+ [
154
+ 'Thumbnail (uploading)',
155
+ isLoading => (
156
+ <Thumbnail src="/local.jpg" alt="Upload" isLoading={isLoading} />
157
+ ),
158
+ ],
159
+ [
160
+ 'DateRangeInput',
161
+ isLoading => (
162
+ <DateRangeInput
163
+ label="Dates"
164
+ value={null}
165
+ onChange={() => {}}
166
+ isLoading={isLoading}
167
+ />
168
+ ),
169
+ ],
170
+ [
171
+ 'ComplexSelector',
172
+ isLoading => (
173
+ <ComplexSelector label="Fruit blend" value="Apple" isLoading={isLoading}>
174
+ {() => <div>Options</div>}
175
+ </ComplexSelector>
176
+ ),
177
+ ],
178
+ ];
179
+
180
+ /** A search source whose searches stay in flight until the test settles them. */
181
+ function pendingSource(): SearchSource<SearchableItem> {
182
+ return {
183
+ search: async () => new Promise<SearchableItem[]>(() => {}),
184
+ bootstrap: () => [],
185
+ };
186
+ }
187
+
188
+ /** Owners whose busy state is a search in flight, started by typing. */
189
+ const SEARCH_OWNERS: ReadonlyArray<
190
+ readonly [string, (source: SearchSource<SearchableItem>) => ReactElement]
191
+ > = [
192
+ [
193
+ 'Typeahead',
194
+ source => (
195
+ <Typeahead
196
+ label="User"
197
+ searchSource={source}
198
+ value={null}
199
+ onChange={() => {}}
200
+ debounceMs={0}
201
+ />
202
+ ),
203
+ ],
204
+ [
205
+ 'Tokenizer',
206
+ source => (
207
+ <Tokenizer
208
+ label="Users"
209
+ searchSource={source}
210
+ value={[]}
211
+ onChange={() => {}}
212
+ debounceMs={0}
213
+ />
214
+ ),
215
+ ],
216
+ [
217
+ 'BaseTypeahead',
218
+ source => (
219
+ <BaseTypeahead
220
+ searchSource={source}
221
+ value={null}
222
+ onChange={() => {}}
223
+ debounceMs={0}
224
+ />
225
+ ),
226
+ ],
227
+ ];
228
+
229
+ // The bottom-sheet DateInput measures its sheet; jsdom has no ResizeObserver.
230
+ beforeAll(() => {
231
+ vi.stubGlobal(
232
+ 'ResizeObserver',
233
+ class {
234
+ observe() {}
235
+ unobserve() {}
236
+ disconnect() {}
237
+ },
238
+ );
239
+ });
240
+ afterAll(() => {
241
+ vi.unstubAllGlobals();
242
+ });
243
+
244
+ /** Calls that announced the busy start, ignoring each control's other messages. */
245
+ function busyAnnouncements() {
246
+ return announce.mock.calls.filter(([message]) => message === 'Loading');
247
+ }
248
+
249
+ /** The hook picks its route a frame after the busy start; wait for it. */
250
+ async function nextFrame() {
251
+ await act(
252
+ async () =>
253
+ void (await new Promise(resolve => requestAnimationFrame(resolve))),
254
+ );
255
+ }
256
+
257
+ function strict(element: ReactElement) {
258
+ return <StrictMode>{element}</StrictMode>;
259
+ }
260
+
261
+ beforeEach(() => {
262
+ announce.mockClear();
263
+ });
264
+
265
+ describe.each(OWNERS)('%s busy state (spec:AST-086)', (_name, renderOwner) => {
266
+ it('keeps its Spinner silent: hidden from assistive technology, no live role exposed (FR8)', () => {
267
+ const {container} = render(renderOwner(true));
268
+ const spinner = container.querySelector('.astryx-spinner');
269
+ expect(spinner).not.toBeNull();
270
+ // Hidden itself, or inside a decorative hidden indicator (CheckboxInput).
271
+ expect(spinner!.closest('[aria-hidden="true"]')).not.toBeNull();
272
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
273
+ });
274
+
275
+ it('announces its busy start once through the polite channel, even under StrictMode (FR6, FR8)', async () => {
276
+ const {rerender} = render(strict(renderOwner(true)));
277
+ await nextFrame();
278
+ rerender(strict(renderOwner(true)));
279
+ await nextFrame();
280
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
281
+ });
282
+
283
+ it('announces nothing while idle, and again for each new busy start (FR6)', async () => {
284
+ const {rerender} = render(renderOwner(false));
285
+ await nextFrame();
286
+ expect(busyAnnouncements()).toEqual([]);
287
+ rerender(renderOwner(true));
288
+ await nextFrame();
289
+ rerender(renderOwner(false));
290
+ rerender(renderOwner(true));
291
+ await nextFrame();
292
+ expect(busyAnnouncements()).toEqual([
293
+ ['Loading', 'polite'],
294
+ ['Loading', 'polite'],
295
+ ]);
296
+ });
297
+ });
298
+
299
+ describe.each(OWNERS)(
300
+ '%s busy inside an open native modal (spec:AST-086 Platform support)',
301
+ (_name, renderOwner) => {
302
+ // jsdom has no showModal(), so an open dialog never matches `:modal`;
303
+ // stand in for a dialog opened modally.
304
+ beforeEach(() => {
305
+ const matches = Element.prototype.matches;
306
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
307
+ this: Element,
308
+ selector: string,
309
+ ) {
310
+ return selector === ':modal'
311
+ ? this instanceof HTMLDialogElement && this.open
312
+ : matches.call(this, selector);
313
+ });
314
+ });
315
+ afterEach(() => {
316
+ vi.restoreAllMocks();
317
+ });
318
+
319
+ it('announces its busy start through a persistent region inside the dialog, not the body-level channel', async () => {
320
+ const {rerender} = render(<dialog open>{renderOwner(false)}</dialog>);
321
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
322
+ rerender(<dialog open>{renderOwner(true)}</dialog>);
323
+ await nextFrame();
324
+ const region = dialog.querySelector('[data-astryx-modal-busy-region]');
325
+ expect(region).not.toBeNull();
326
+ expect(region).toHaveAttribute('role', 'status');
327
+ expect(region).toHaveAttribute('aria-live', 'polite');
328
+ // Inserted empty, then filled on the next frame (FR3).
329
+ expect(region).toHaveTextContent('');
330
+ await nextFrame();
331
+ expect(region).toHaveTextContent('Loading');
332
+ expect(busyAnnouncements()).toEqual([]);
333
+ });
334
+
335
+ it('announces inside the dialog when it is already busy as its dialog opens', async () => {
336
+ // The dialog opens in its own Effect, after the field's Effect has run,
337
+ // the way Dialog calls showModal().
338
+ function OpeningDialog() {
339
+ const ref = useRef<HTMLDialogElement>(null);
340
+ useEffect(() => {
341
+ ref.current?.setAttribute('open', '');
342
+ }, []);
343
+ return <dialog ref={ref}>{renderOwner(true)}</dialog>;
344
+ }
345
+ render(<OpeningDialog />);
346
+ await nextFrame();
347
+ await nextFrame();
348
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
349
+ expect(
350
+ dialog.querySelector('[data-astryx-modal-busy-region]'),
351
+ ).toHaveTextContent('Loading');
352
+ expect(busyAnnouncements()).toEqual([]);
353
+ });
354
+ },
355
+ );
356
+
357
+ describe.each(SEARCH_OWNERS)(
358
+ '%s search busy state (spec:AST-086)',
359
+ (_name, renderOwner) => {
360
+ it('keeps its search Spinner named but not live, and announces the search start once (FR6, FR8)', async () => {
361
+ const {container} = render(strict(renderOwner(pendingSource())));
362
+ await nextFrame();
363
+ expect(busyAnnouncements()).toEqual([]);
364
+ act(() => {
365
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
366
+ });
367
+ await nextFrame();
368
+ // Named but not live (family:input-fields FR7, FR8).
369
+ const spinner = container.querySelector('.astryx-spinner');
370
+ expect(spinner).toHaveAttribute('role', 'img');
371
+ expect(screen.getByRole('img', {name: 'Loading'})).toBe(spinner);
372
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
373
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
374
+ });
375
+
376
+ it('announces each new search start again (FR6)', async () => {
377
+ render(renderOwner(pendingSource()));
378
+ const input = screen.getByRole('combobox');
379
+ act(() => {
380
+ fireEvent.change(input, {target: {value: 'Al'}});
381
+ });
382
+ await nextFrame();
383
+ // Clearing the query ends the search; typing again starts a new one.
384
+ act(() => {
385
+ fireEvent.change(input, {target: {value: ''}});
386
+ });
387
+ await nextFrame();
388
+ act(() => {
389
+ fireEvent.change(input, {target: {value: 'Be'}});
390
+ });
391
+ await nextFrame();
392
+ expect(busyAnnouncements()).toEqual([
393
+ ['Loading', 'polite'],
394
+ ['Loading', 'polite'],
395
+ ]);
396
+ });
397
+
398
+ it('announces the search start inside an open modal dialog, not the body channel', async () => {
399
+ const matches = Element.prototype.matches;
400
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
401
+ this: Element,
402
+ selector: string,
403
+ ) {
404
+ return selector === ':modal'
405
+ ? this instanceof HTMLDialogElement && this.open
406
+ : matches.call(this, selector);
407
+ });
408
+ render(<dialog open>{renderOwner(pendingSource())}</dialog>);
409
+ act(() => {
410
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
411
+ });
412
+ await nextFrame();
413
+ await nextFrame();
414
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
415
+ expect(
416
+ dialog.querySelector('[data-astryx-modal-busy-region]'),
417
+ ).toHaveTextContent('Loading');
418
+ expect(busyAnnouncements()).toEqual([]);
419
+ vi.restoreAllMocks();
420
+ });
421
+ },
422
+ );
423
+
424
+ /**
425
+ * Bottom-sheet fields whose sheet stays open while a changeAction is pending:
426
+ * the sheet is a modal dialog, so the busy start must be announced inside it.
427
+ */
428
+ const SHEET_OWNERS: ReadonlyArray<
429
+ readonly [
430
+ string,
431
+ (changeAction: () => Promise<void>) => ReactElement,
432
+ () => void,
433
+ ]
434
+ > = [
435
+ [
436
+ 'TimeInput',
437
+ changeAction => (
438
+ <TimeInput
439
+ label="Time"
440
+ presentation="bottom-sheet"
441
+ value={'09:00' as ISOTimeString}
442
+ onChange={() => {}}
443
+ changeAction={changeAction}
444
+ />
445
+ ),
446
+ () => {
447
+ fireEvent.click(screen.getByRole('button', {name: /Time/}));
448
+ },
449
+ ],
450
+ ];
451
+
452
+ describe.each(SHEET_OWNERS)(
453
+ '%s busy start from inside its open sheet (spec:AST-086 Platform support)',
454
+ (_name, renderOwner, openSheet) => {
455
+ beforeEach(() => {
456
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
457
+ this: HTMLDialogElement,
458
+ ) {
459
+ this.setAttribute('open', '');
460
+ });
461
+ HTMLDialogElement.prototype.close = vi.fn(function (
462
+ this: HTMLDialogElement,
463
+ ) {
464
+ this.removeAttribute('open');
465
+ });
466
+ const matches = Element.prototype.matches;
467
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
468
+ this: Element,
469
+ selector: string,
470
+ ) {
471
+ return selector === ':modal'
472
+ ? this instanceof HTMLDialogElement && this.open
473
+ : matches.call(this, selector);
474
+ });
475
+ });
476
+ afterEach(() => {
477
+ vi.restoreAllMocks();
478
+ });
479
+
480
+ it('announces the busy start in a region inside the open sheet, not behind it', async () => {
481
+ render(renderOwner(async () => new Promise<void>(() => {})));
482
+ openSheet();
483
+ const sheet = document.querySelector('dialog[open]');
484
+ expect(sheet).not.toBeNull();
485
+ act(() => {
486
+ fireEvent.click(within(sheet as HTMLElement).getAllByRole('option')[1]);
487
+ });
488
+ await nextFrame();
489
+ await nextFrame();
490
+ expect(
491
+ sheet!.querySelector('[data-astryx-modal-busy-region]'),
492
+ ).toHaveTextContent('Loading');
493
+ expect(busyAnnouncements()).toEqual([]);
494
+ });
495
+ },
496
+ );
497
+
498
+ describe('DropdownMenuSubMenu loading state (spec:AST-086 FR8)', () => {
499
+ function Menu({isLoading}: {isLoading: boolean}) {
500
+ return (
501
+ <DropdownMenu button={{label: 'Actions'}}>
502
+ <DropdownMenuSubMenu label="Move to" hasSpinner={isLoading}>
503
+ <DropdownMenuItem label="Inbox" onClick={() => {}} />
504
+ </DropdownMenuSubMenu>
505
+ </DropdownMenu>
506
+ );
507
+ }
508
+
509
+ it('announces nothing while its menu is closed', async () => {
510
+ render(strict(<Menu isLoading />));
511
+ await nextFrame();
512
+ expect(busyAnnouncements()).toEqual([]);
513
+ });
514
+
515
+ it('announces the busy start when its menu opens while loading', async () => {
516
+ render(strict(<Menu isLoading />));
517
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
518
+ await nextFrame();
519
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
520
+ });
521
+
522
+ it('keeps the trigger Spinner silent and announces each busy start once', async () => {
523
+ const {rerender} = render(strict(<Menu isLoading={false} />));
524
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
525
+ await nextFrame();
526
+ expect(busyAnnouncements()).toEqual([]);
527
+ rerender(strict(<Menu isLoading />));
528
+ await nextFrame();
529
+ const spinner = document.querySelector('.astryx-spinner');
530
+ expect(spinner).toHaveAttribute('aria-hidden', 'true');
531
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
532
+ rerender(strict(<Menu isLoading={false} />));
533
+ rerender(strict(<Menu isLoading />));
534
+ await nextFrame();
535
+ expect(busyAnnouncements()).toEqual([
536
+ ['Loading', 'polite'],
537
+ ['Loading', 'polite'],
538
+ ]);
539
+ });
540
+ });
541
+
542
+ describe('ChatMessageList top loader (spec:AST-086 FR4, FR8)', () => {
543
+ it('keeps the loader inside the log silent and announces loading once through the channel', async () => {
544
+ let notify!: IntersectionObserverCallback;
545
+ vi.stubGlobal(
546
+ 'IntersectionObserver',
547
+ class {
548
+ constructor(callback: IntersectionObserverCallback) {
549
+ notify = callback;
550
+ }
551
+ observe() {}
552
+ disconnect() {}
553
+ },
554
+ );
555
+ render(
556
+ strict(
557
+ <ChatMessageList
558
+ scrollToTopAction={async () => new Promise<void>(() => {})}>
559
+ <div>Earlier message</div>
560
+ </ChatMessageList>,
561
+ ),
562
+ );
563
+ act(() => {
564
+ notify(
565
+ [{isIntersecting: true} as IntersectionObserverEntry],
566
+ {} as IntersectionObserver,
567
+ );
568
+ });
569
+ await nextFrame();
570
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
571
+ 'aria-hidden',
572
+ 'true',
573
+ );
574
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
575
+ });
576
+ });
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MenuPressShadowRoot.a11y.chromium.spec.ts
5
+ * @input The `a11y/Menu press in a shadow root` story in a built Storybook and
6
+ * real mouse input.
7
+ * @output Real-engine proof that a menu rendered inside a shadow root acts on
8
+ * the row under a released pointer (module:DropdownMenu/useMenuPress FR1).
9
+ * @position Run with `pnpm test:a11y-contract`. Only a real engine resolves a
10
+ * point inside a shadow root to the shadow host at the document level.
11
+ */
12
+
13
+ import {expect, test} from '@playwright/test';
14
+ import {
15
+ DEFAULT_STORYBOOK_DIR,
16
+ serveStorybook,
17
+ type StaticServer,
18
+ } from '@astryxdesign/a11y-spec/storybook';
19
+
20
+ let storybook: StaticServer;
21
+
22
+ test.beforeAll(async () => {
23
+ storybook = await serveStorybook(
24
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
25
+ );
26
+ });
27
+
28
+ test.afterAll(async () => {
29
+ await storybook?.close();
30
+ });
31
+
32
+ const STORY = 'a11y-menu-press-in-a-shadow-root--selector-in-shadow-root';
33
+
34
+ test('a mouse click on an option inside a shadow root selects it', async ({
35
+ page,
36
+ }) => {
37
+ await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`);
38
+ // Playwright's role locators pierce open shadow roots.
39
+ await page.getByRole('combobox', {name: 'Fruit'}).click();
40
+ const option = page.getByRole('option', {name: 'Banana'});
41
+ await expect(option).toBeVisible();
42
+ const box = await option.boundingBox();
43
+ if (box == null) {
44
+ throw new Error('the Banana option has no layout box');
45
+ }
46
+
47
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
48
+
49
+ await expect(page.getByTestId('selected-value')).toHaveText('Banana');
50
+ });