@astryxdesign/core 0.7.3 → 0.7.4-canary.1a552f2

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 (143) 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/CommandPalette/CommandPalette.d.ts.map +1 -1
  19. package/dist/CommandPalette/CommandPalette.js +6 -11
  20. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  21. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  22. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  23. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  24. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  25. package/dist/ContextMenu/ContextMenu.js +12 -0
  26. package/dist/DateInput/DateInput.d.ts.map +1 -1
  27. package/dist/DateInput/DateInput.js +6 -1
  28. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  29. package/dist/DateInput/NativeDateField.js +6 -1
  30. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  31. package/dist/DateInput/TouchDateField.js +9 -1
  32. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  33. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  34. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  35. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  36. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  37. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  38. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  40. package/dist/FileInput/FileInput.d.ts.map +1 -1
  41. package/dist/FileInput/FileInput.js +16 -2
  42. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  43. package/dist/MultiSelector/MultiSelector.js +6 -1
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +6 -1
  46. package/dist/TextArea/TextArea.d.ts.map +1 -1
  47. package/dist/TextArea/TextArea.js +6 -1
  48. package/dist/TextInput/TextInput.d.ts.map +1 -1
  49. package/dist/TextInput/TextInput.js +6 -1
  50. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  51. package/dist/Thumbnail/Thumbnail.js +9 -2
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +6 -1
  54. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  55. package/dist/TimeInput/TouchTimeField.js +9 -1
  56. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  57. package/dist/Tokenizer/Tokenizer.js +9 -1
  58. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  59. package/dist/Typeahead/BaseTypeahead.js +7 -1
  60. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  61. package/dist/Typeahead/Typeahead.js +9 -1
  62. package/dist/astryx.css +2 -0
  63. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  64. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  65. package/dist/hooks/useBusyAnnouncement.js +87 -0
  66. package/dist/hooks/useMenuPress.d.ts +2 -2
  67. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  68. package/dist/hooks/useMenuPress.js +5 -1
  69. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  70. package/dist/hooks/useTypeahead.js +7 -2
  71. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  72. package/dist/i18n/generated-locales/en.generated.js +1 -0
  73. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  74. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  75. package/locales/en.json +6 -2
  76. package/locales/pseudo.json +3 -0
  77. package/package.json +4 -4
  78. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  79. package/src/BottomSheet/BottomSheet.spec.md +3 -5
  80. package/src/BottomSheet/BottomSheet.tsx +260 -47
  81. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +29 -4
  82. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  83. package/src/BottomSheet/BottomSheetSwitcher.spec.md +35 -41
  84. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +1051 -49
  85. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  86. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  87. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  88. package/src/Carousel/Carousel.test.tsx +8 -12
  89. package/src/Carousel/Carousel.tsx +6 -13
  90. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  91. package/src/Chat/ChatMessageList.test.tsx +6 -2
  92. package/src/Chat/ChatMessageList.tsx +7 -1
  93. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  94. package/src/Chat/useTriggerMenu.tsx +55 -6
  95. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  96. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  97. package/src/CommandPalette/CommandPalette.tsx +6 -11
  98. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  99. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  100. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  101. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  102. package/src/ContextMenu/ContextMenu.tsx +13 -1
  103. package/src/DateInput/DateInput.tsx +5 -1
  104. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  105. package/src/DateInput/NativeDateField.test.tsx +10 -2
  106. package/src/DateInput/NativeDateField.tsx +5 -1
  107. package/src/DateInput/TouchDateField.tsx +14 -2
  108. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  109. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  110. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  112. package/src/FileInput/FileInput.tsx +18 -2
  113. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  114. package/src/MultiSelector/MultiSelector.tsx +5 -1
  115. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  116. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  117. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  118. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  119. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  120. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  121. package/src/Selector/Selector.test.tsx +22 -5
  122. package/src/Selector/Selector.tsx +5 -1
  123. package/src/TextArea/TextArea.test.tsx +9 -1
  124. package/src/TextArea/TextArea.tsx +5 -1
  125. package/src/TextInput/TextInput.tsx +5 -1
  126. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  127. package/src/Thumbnail/Thumbnail.tsx +12 -3
  128. package/src/TimeInput/TimeInput.tsx +5 -1
  129. package/src/TimeInput/TouchTimeField.tsx +12 -2
  130. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  131. package/src/Tokenizer/Tokenizer.tsx +10 -1
  132. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  133. package/src/Typeahead/Typeahead.test.tsx +32 -41
  134. package/src/Typeahead/Typeahead.tsx +10 -1
  135. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +792 -0
  136. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  137. package/src/hooks/useBusyAnnouncement.ts +100 -0
  138. package/src/hooks/useMenuPress.test.tsx +27 -1
  139. package/src/hooks/useMenuPress.ts +12 -3
  140. package/src/hooks/useTypeahead.test.tsx +42 -1
  141. package/src/hooks/useTypeahead.ts +7 -2
  142. package/src/i18n/generated-locales/en.generated.ts +1 -0
  143. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -0,0 +1,792 @@
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 {
12
+ StrictMode,
13
+ useEffect,
14
+ useRef,
15
+ useState,
16
+ type ReactElement,
17
+ } from 'react';
18
+ import {
19
+ afterAll,
20
+ afterEach,
21
+ beforeAll,
22
+ beforeEach,
23
+ describe,
24
+ expect,
25
+ it,
26
+ vi,
27
+ } from 'vitest';
28
+ import {act, fireEvent, render, screen, within} from '@testing-library/react';
29
+ import {TextInput} from '../TextInput';
30
+ import {TextArea} from '../TextArea';
31
+ import {Selector} from '../Selector';
32
+ import {MultiSelector} from '../MultiSelector';
33
+ import {ComplexSelector} from '../ComplexSelector';
34
+ import {DateInput} from '../DateInput';
35
+ import {DateRangeInput} from '../DateRangeInput';
36
+ import {DateTimeInput} from '../DateTimeInput';
37
+ import {TimeInput} from '../TimeInput';
38
+ import {CheckboxInput} from '../CheckboxInput';
39
+ import {FileInput} from '../FileInput';
40
+ import {CommandPalette} from '../CommandPalette';
41
+ import {Thumbnail} from '../Thumbnail';
42
+ import {
43
+ DropdownMenu,
44
+ DropdownMenuItem,
45
+ DropdownMenuSubMenu,
46
+ } from '../DropdownMenu';
47
+ import {ChatMessageList} from '../Chat';
48
+ import {BaseTypeahead, Typeahead} from '../Typeahead';
49
+ import type {SearchableItem, SearchSource} from '../Typeahead';
50
+ import type {ISOTimeString} from '../utils';
51
+ import {Tokenizer} from '../Tokenizer';
52
+
53
+ const announce = vi.fn();
54
+ vi.mock('../hooks/useAnnounce', async importOriginal => ({
55
+ ...(await importOriginal<Record<string, unknown>>()),
56
+ useAnnounce: () => announce,
57
+ }));
58
+
59
+ const OPTIONS = [
60
+ {label: 'Apple', value: 'apple'},
61
+ {label: 'Banana', value: 'banana'},
62
+ ];
63
+
64
+ const OWNERS: ReadonlyArray<
65
+ readonly [string, (isLoading: boolean) => ReactElement]
66
+ > = [
67
+ [
68
+ 'TextInput',
69
+ isLoading => (
70
+ <TextInput
71
+ label="Email"
72
+ value=""
73
+ onChange={() => {}}
74
+ isLoading={isLoading}
75
+ />
76
+ ),
77
+ ],
78
+ [
79
+ 'TextArea',
80
+ isLoading => (
81
+ <TextArea
82
+ label="Notes"
83
+ value=""
84
+ onChange={() => {}}
85
+ isLoading={isLoading}
86
+ />
87
+ ),
88
+ ],
89
+ [
90
+ 'Selector',
91
+ isLoading => (
92
+ <Selector
93
+ label="Fruit"
94
+ options={OPTIONS}
95
+ onChange={() => {}}
96
+ isLoading={isLoading}
97
+ />
98
+ ),
99
+ ],
100
+ [
101
+ 'MultiSelector',
102
+ isLoading => (
103
+ <MultiSelector
104
+ label="Fruits"
105
+ options={OPTIONS}
106
+ value={[]}
107
+ onChange={() => {}}
108
+ isLoading={isLoading}
109
+ />
110
+ ),
111
+ ],
112
+ ...(['popover', 'native', 'bottom-sheet'] as const).map(
113
+ presentation =>
114
+ [
115
+ `DateInput (${presentation})`,
116
+ (isLoading: boolean) => (
117
+ <DateInput
118
+ label="Date"
119
+ presentation={presentation}
120
+ onChange={() => {}}
121
+ isLoading={isLoading}
122
+ />
123
+ ),
124
+ ] as const,
125
+ ),
126
+ ...(['popover', 'native', 'bottom-sheet'] as const).flatMap(presentation => [
127
+ [
128
+ `DateTimeInput (${presentation})`,
129
+ (isLoading: boolean) => (
130
+ <DateTimeInput
131
+ label="When"
132
+ presentation={presentation}
133
+ onChange={() => {}}
134
+ isLoading={isLoading}
135
+ />
136
+ ),
137
+ ] as const,
138
+ [
139
+ `TimeInput (${presentation})`,
140
+ (isLoading: boolean) => (
141
+ <TimeInput
142
+ label="Time"
143
+ presentation={presentation}
144
+ onChange={() => {}}
145
+ isLoading={isLoading}
146
+ />
147
+ ),
148
+ ] as const,
149
+ ]),
150
+ [
151
+ 'CheckboxInput',
152
+ isLoading => (
153
+ <CheckboxInput
154
+ label="Subscribe"
155
+ value={false}
156
+ onChange={() => {}}
157
+ isLoading={isLoading}
158
+ />
159
+ ),
160
+ ],
161
+ [
162
+ 'Thumbnail (uploading)',
163
+ isLoading => (
164
+ <Thumbnail src="/local.jpg" alt="Upload" isLoading={isLoading} />
165
+ ),
166
+ ],
167
+ ...(['dropzone', 'input'] as const).map(
168
+ mode =>
169
+ [
170
+ `FileInput (${mode})`,
171
+ (isLoading: boolean) => (
172
+ <FileInput
173
+ label="Attachment"
174
+ mode={mode}
175
+ value={null}
176
+ onChange={() => {}}
177
+ isLoading={isLoading}
178
+ />
179
+ ),
180
+ ] as const,
181
+ ),
182
+ [
183
+ 'DateRangeInput',
184
+ isLoading => (
185
+ <DateRangeInput
186
+ label="Dates"
187
+ value={null}
188
+ onChange={() => {}}
189
+ isLoading={isLoading}
190
+ />
191
+ ),
192
+ ],
193
+ [
194
+ 'ComplexSelector',
195
+ isLoading => (
196
+ <ComplexSelector label="Fruit blend" value="Apple" isLoading={isLoading}>
197
+ {() => <div>Options</div>}
198
+ </ComplexSelector>
199
+ ),
200
+ ],
201
+ ];
202
+
203
+ /** A search source whose searches stay in flight until the test settles them. */
204
+ function pendingSource(): SearchSource<SearchableItem> {
205
+ return {
206
+ search: async () => new Promise<SearchableItem[]>(() => {}),
207
+ bootstrap: () => [],
208
+ };
209
+ }
210
+
211
+ /** Owners whose busy state is a search in flight, started by typing. */
212
+ const SEARCH_OWNERS: ReadonlyArray<
213
+ readonly [string, (source: SearchSource<SearchableItem>) => ReactElement]
214
+ > = [
215
+ [
216
+ 'Typeahead',
217
+ source => (
218
+ <Typeahead
219
+ label="User"
220
+ searchSource={source}
221
+ value={null}
222
+ onChange={() => {}}
223
+ debounceMs={0}
224
+ />
225
+ ),
226
+ ],
227
+ [
228
+ 'Tokenizer',
229
+ source => (
230
+ <Tokenizer
231
+ label="Users"
232
+ searchSource={source}
233
+ value={[]}
234
+ onChange={() => {}}
235
+ debounceMs={0}
236
+ />
237
+ ),
238
+ ],
239
+ [
240
+ 'BaseTypeahead',
241
+ source => (
242
+ <BaseTypeahead
243
+ searchSource={source}
244
+ value={null}
245
+ onChange={() => {}}
246
+ debounceMs={0}
247
+ />
248
+ ),
249
+ ],
250
+ ];
251
+
252
+ // The bottom-sheet DateInput measures its sheet; jsdom has no ResizeObserver.
253
+ beforeAll(() => {
254
+ vi.stubGlobal(
255
+ 'ResizeObserver',
256
+ class {
257
+ observe() {}
258
+ unobserve() {}
259
+ disconnect() {}
260
+ },
261
+ );
262
+ });
263
+ afterAll(() => {
264
+ vi.unstubAllGlobals();
265
+ });
266
+
267
+ /** Calls that announced the busy start, ignoring each control's other messages. */
268
+ function busyAnnouncements() {
269
+ return announce.mock.calls.filter(([message]) => message === 'Loading');
270
+ }
271
+
272
+ /** The hook picks its route a frame after the busy start; wait for it. */
273
+ async function nextFrame() {
274
+ await act(
275
+ async () =>
276
+ void (await new Promise(resolve => requestAnimationFrame(resolve))),
277
+ );
278
+ }
279
+
280
+ function strict(element: ReactElement) {
281
+ return <StrictMode>{element}</StrictMode>;
282
+ }
283
+
284
+ beforeEach(() => {
285
+ announce.mockClear();
286
+ });
287
+
288
+ describe.each(OWNERS)('%s busy state (spec:AST-086)', (_name, renderOwner) => {
289
+ it('keeps its Spinner silent: hidden from assistive technology, no live role exposed (FR8)', () => {
290
+ const {container} = render(renderOwner(true));
291
+ const spinner = container.querySelector('.astryx-spinner');
292
+ expect(spinner).not.toBeNull();
293
+ // Hidden itself, or inside a decorative hidden indicator (CheckboxInput).
294
+ expect(spinner!.closest('[aria-hidden="true"]')).not.toBeNull();
295
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
296
+ });
297
+
298
+ it('announces its busy start once through the polite channel, even under StrictMode (FR6, FR8)', async () => {
299
+ const {rerender} = render(strict(renderOwner(true)));
300
+ await nextFrame();
301
+ rerender(strict(renderOwner(true)));
302
+ await nextFrame();
303
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
304
+ });
305
+
306
+ it('announces nothing while idle, and again for each new busy start (FR6)', async () => {
307
+ const {rerender} = render(renderOwner(false));
308
+ await nextFrame();
309
+ expect(busyAnnouncements()).toEqual([]);
310
+ rerender(renderOwner(true));
311
+ await nextFrame();
312
+ rerender(renderOwner(false));
313
+ rerender(renderOwner(true));
314
+ await nextFrame();
315
+ expect(busyAnnouncements()).toEqual([
316
+ ['Loading', 'polite'],
317
+ ['Loading', 'polite'],
318
+ ]);
319
+ });
320
+ });
321
+
322
+ describe.each(OWNERS)(
323
+ '%s busy inside an open native modal (spec:AST-086 Platform support)',
324
+ (_name, renderOwner) => {
325
+ // jsdom has no showModal(), so an open dialog never matches `:modal`;
326
+ // stand in for a dialog opened modally.
327
+ beforeEach(() => {
328
+ const matches = Element.prototype.matches;
329
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
330
+ this: Element,
331
+ selector: string,
332
+ ) {
333
+ return selector === ':modal'
334
+ ? this instanceof HTMLDialogElement && this.open
335
+ : matches.call(this, selector);
336
+ });
337
+ });
338
+ afterEach(() => {
339
+ vi.restoreAllMocks();
340
+ });
341
+
342
+ it('announces its busy start through a persistent region inside the dialog, not the body-level channel', async () => {
343
+ const {rerender} = render(<dialog open>{renderOwner(false)}</dialog>);
344
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
345
+ rerender(<dialog open>{renderOwner(true)}</dialog>);
346
+ await nextFrame();
347
+ const region = dialog.querySelector('[data-astryx-modal-busy-region]');
348
+ expect(region).not.toBeNull();
349
+ expect(region).toHaveAttribute('role', 'status');
350
+ expect(region).toHaveAttribute('aria-live', 'polite');
351
+ // Inserted empty, then filled on the next frame (FR3).
352
+ expect(region).toHaveTextContent('');
353
+ await nextFrame();
354
+ expect(region).toHaveTextContent('Loading');
355
+ expect(busyAnnouncements()).toEqual([]);
356
+ });
357
+
358
+ it('announces inside the dialog when it is already busy as its dialog opens', async () => {
359
+ // The dialog opens in its own Effect, after the field's Effect has run,
360
+ // the way Dialog calls showModal().
361
+ function OpeningDialog() {
362
+ const ref = useRef<HTMLDialogElement>(null);
363
+ useEffect(() => {
364
+ ref.current?.setAttribute('open', '');
365
+ }, []);
366
+ return <dialog ref={ref}>{renderOwner(true)}</dialog>;
367
+ }
368
+ render(<OpeningDialog />);
369
+ await nextFrame();
370
+ await nextFrame();
371
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
372
+ expect(
373
+ dialog.querySelector('[data-astryx-modal-busy-region]'),
374
+ ).toHaveTextContent('Loading');
375
+ expect(busyAnnouncements()).toEqual([]);
376
+ });
377
+ },
378
+ );
379
+
380
+ describe.each(SEARCH_OWNERS)(
381
+ '%s search busy state (spec:AST-086)',
382
+ (_name, renderOwner) => {
383
+ it('keeps its search Spinner named but not live, and announces the search start once (FR6, FR8)', async () => {
384
+ const {container} = render(strict(renderOwner(pendingSource())));
385
+ await nextFrame();
386
+ expect(busyAnnouncements()).toEqual([]);
387
+ act(() => {
388
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
389
+ });
390
+ await nextFrame();
391
+ // Named but not live (family:input-fields FR7, FR8).
392
+ const spinner = container.querySelector('.astryx-spinner');
393
+ expect(spinner).toHaveAttribute('role', 'img');
394
+ expect(screen.getByRole('img', {name: 'Loading'})).toBe(spinner);
395
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
396
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
397
+ });
398
+
399
+ it('announces each new search start again (FR6)', async () => {
400
+ render(renderOwner(pendingSource()));
401
+ const input = screen.getByRole('combobox');
402
+ act(() => {
403
+ fireEvent.change(input, {target: {value: 'Al'}});
404
+ });
405
+ await nextFrame();
406
+ // Clearing the query ends the search; typing again starts a new one.
407
+ act(() => {
408
+ fireEvent.change(input, {target: {value: ''}});
409
+ });
410
+ await nextFrame();
411
+ act(() => {
412
+ fireEvent.change(input, {target: {value: 'Be'}});
413
+ });
414
+ await nextFrame();
415
+ expect(busyAnnouncements()).toEqual([
416
+ ['Loading', 'polite'],
417
+ ['Loading', 'polite'],
418
+ ]);
419
+ });
420
+
421
+ it('announces the search start inside an open modal dialog, not the body channel', async () => {
422
+ const matches = Element.prototype.matches;
423
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
424
+ this: Element,
425
+ selector: string,
426
+ ) {
427
+ return selector === ':modal'
428
+ ? this instanceof HTMLDialogElement && this.open
429
+ : matches.call(this, selector);
430
+ });
431
+ render(<dialog open>{renderOwner(pendingSource())}</dialog>);
432
+ act(() => {
433
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
434
+ });
435
+ await nextFrame();
436
+ await nextFrame();
437
+ const dialog = document.querySelector('dialog') as HTMLDialogElement;
438
+ expect(
439
+ dialog.querySelector('[data-astryx-modal-busy-region]'),
440
+ ).toHaveTextContent('Loading');
441
+ expect(busyAnnouncements()).toEqual([]);
442
+ vi.restoreAllMocks();
443
+ });
444
+ },
445
+ );
446
+
447
+ /**
448
+ * Bottom-sheet fields whose sheet stays open while a changeAction is pending:
449
+ * the sheet is a modal dialog, so the busy start must be announced inside it.
450
+ */
451
+ const SHEET_OWNERS: ReadonlyArray<
452
+ readonly [
453
+ string,
454
+ (changeAction: () => Promise<void>) => ReactElement,
455
+ () => void,
456
+ ]
457
+ > = [
458
+ [
459
+ 'TimeInput',
460
+ changeAction => (
461
+ <TimeInput
462
+ label="Time"
463
+ presentation="bottom-sheet"
464
+ value={'09:00' as ISOTimeString}
465
+ onChange={() => {}}
466
+ changeAction={changeAction}
467
+ />
468
+ ),
469
+ () => {
470
+ fireEvent.click(screen.getByRole('button', {name: /Time/}));
471
+ },
472
+ ],
473
+ ];
474
+
475
+ describe.each(SHEET_OWNERS)(
476
+ '%s busy start from inside its open sheet (spec:AST-086 Platform support)',
477
+ (_name, renderOwner, openSheet) => {
478
+ beforeEach(() => {
479
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
480
+ this: HTMLDialogElement,
481
+ ) {
482
+ this.setAttribute('open', '');
483
+ });
484
+ HTMLDialogElement.prototype.close = vi.fn(function (
485
+ this: HTMLDialogElement,
486
+ ) {
487
+ this.removeAttribute('open');
488
+ });
489
+ const matches = Element.prototype.matches;
490
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
491
+ this: Element,
492
+ selector: string,
493
+ ) {
494
+ return selector === ':modal'
495
+ ? this instanceof HTMLDialogElement && this.open
496
+ : matches.call(this, selector);
497
+ });
498
+ });
499
+ afterEach(() => {
500
+ vi.restoreAllMocks();
501
+ });
502
+
503
+ it('announces the busy start in a region inside the open sheet, not behind it', async () => {
504
+ render(renderOwner(async () => new Promise<void>(() => {})));
505
+ openSheet();
506
+ const sheet = document.querySelector('dialog[open]');
507
+ expect(sheet).not.toBeNull();
508
+ act(() => {
509
+ fireEvent.click(within(sheet as HTMLElement).getAllByRole('option')[1]);
510
+ });
511
+ await nextFrame();
512
+ await nextFrame();
513
+ expect(
514
+ sheet!.querySelector('[data-astryx-modal-busy-region]'),
515
+ ).toHaveTextContent('Loading');
516
+ expect(busyAnnouncements()).toEqual([]);
517
+ });
518
+ },
519
+ );
520
+
521
+ describe('DropdownMenuSubMenu loading state (spec:AST-086 FR8)', () => {
522
+ function Menu({isLoading}: {isLoading: boolean}) {
523
+ return (
524
+ <DropdownMenu button={{label: 'Actions'}}>
525
+ <DropdownMenuSubMenu label="Move to" hasSpinner={isLoading}>
526
+ <DropdownMenuItem label="Inbox" onClick={() => {}} />
527
+ </DropdownMenuSubMenu>
528
+ </DropdownMenu>
529
+ );
530
+ }
531
+
532
+ it('announces nothing while its menu is closed', async () => {
533
+ render(strict(<Menu isLoading />));
534
+ await nextFrame();
535
+ expect(busyAnnouncements()).toEqual([]);
536
+ });
537
+
538
+ it('announces the busy start when its menu opens while loading', async () => {
539
+ render(strict(<Menu isLoading />));
540
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
541
+ await nextFrame();
542
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
543
+ });
544
+
545
+ it('keeps the trigger Spinner silent and announces each busy start once', async () => {
546
+ const {rerender} = render(strict(<Menu isLoading={false} />));
547
+ fireEvent.click(screen.getByRole('button', {name: /Actions/}));
548
+ await nextFrame();
549
+ expect(busyAnnouncements()).toEqual([]);
550
+ rerender(strict(<Menu isLoading />));
551
+ await nextFrame();
552
+ const spinner = document.querySelector('.astryx-spinner');
553
+ expect(spinner).toHaveAttribute('aria-hidden', 'true');
554
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
555
+ rerender(strict(<Menu isLoading={false} />));
556
+ rerender(strict(<Menu isLoading />));
557
+ await nextFrame();
558
+ expect(busyAnnouncements()).toEqual([
559
+ ['Loading', 'polite'],
560
+ ['Loading', 'polite'],
561
+ ]);
562
+ });
563
+ });
564
+
565
+ describe('ChatMessageList top loader (spec:AST-086 FR4, FR8)', () => {
566
+ it('keeps the loader inside the log silent and announces loading once through the channel', async () => {
567
+ let notify!: IntersectionObserverCallback;
568
+ vi.stubGlobal(
569
+ 'IntersectionObserver',
570
+ class {
571
+ constructor(callback: IntersectionObserverCallback) {
572
+ notify = callback;
573
+ }
574
+ observe() {}
575
+ disconnect() {}
576
+ },
577
+ );
578
+ render(
579
+ strict(
580
+ <ChatMessageList
581
+ scrollToTopAction={async () => new Promise<void>(() => {})}>
582
+ <div>Earlier message</div>
583
+ </ChatMessageList>,
584
+ ),
585
+ );
586
+ act(() => {
587
+ notify(
588
+ [{isIntersecting: true} as IntersectionObserverEntry],
589
+ {} as IntersectionObserver,
590
+ );
591
+ });
592
+ await nextFrame();
593
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
594
+ 'aria-hidden',
595
+ 'true',
596
+ );
597
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
598
+ });
599
+ });
600
+
601
+ describe('FileInput busy start from a pick or a clear (spec:AST-086 FR6, FR8)', () => {
602
+ const file = new File(['x'], 'report.pdf', {type: 'application/pdf'});
603
+
604
+ it('keeps a pick under a pending changeAction to its one "selected" message', async () => {
605
+ const {container} = render(
606
+ strict(
607
+ <FileInput
608
+ label="Attachment"
609
+ value={null}
610
+ onChange={() => {}}
611
+ changeAction={async () => new Promise<void>(() => {})}
612
+ />,
613
+ ),
614
+ );
615
+ act(() => {
616
+ fireEvent.change(
617
+ container.querySelector('input[type="file"]') as HTMLInputElement,
618
+ {target: {files: [file]}},
619
+ );
620
+ });
621
+ await nextFrame();
622
+ expect(container.querySelector('.astryx-spinner')).toHaveAttribute(
623
+ 'aria-hidden',
624
+ 'true',
625
+ );
626
+ expect(announce).toHaveBeenCalledWith('1 file selected: report.pdf');
627
+ expect(busyAnnouncements()).toEqual([]);
628
+ });
629
+
630
+ it('keeps a pick whose onChange starts an upload (isLoading) to its one "selected" message', async () => {
631
+ function Uploader() {
632
+ const [value, setValue] = useState<File | null>(null);
633
+ const [isLoading, setIsLoading] = useState(false);
634
+ return (
635
+ <FileInput
636
+ label="Attachment"
637
+ value={value}
638
+ isLoading={isLoading}
639
+ onChange={next => {
640
+ setValue(next as File | null);
641
+ setIsLoading(true);
642
+ }}
643
+ />
644
+ );
645
+ }
646
+ const {container} = render(strict(<Uploader />));
647
+ act(() => {
648
+ fireEvent.change(
649
+ container.querySelector('input[type="file"]') as HTMLInputElement,
650
+ {target: {files: [file]}},
651
+ );
652
+ });
653
+ await nextFrame();
654
+ await nextFrame();
655
+ expect(announce).toHaveBeenCalledWith('1 file selected: report.pdf');
656
+ expect(busyAnnouncements()).toEqual([]);
657
+ });
658
+
659
+ it('announces nothing more when an earlier pick settles while a later one is still pending', async () => {
660
+ const settles: (() => void)[] = [];
661
+ const {container} = render(
662
+ strict(
663
+ <FileInput
664
+ label="Attachment"
665
+ value={null}
666
+ onChange={() => {}}
667
+ changeAction={async () =>
668
+ new Promise<void>(resolve => {
669
+ settles.push(resolve);
670
+ })
671
+ }
672
+ />,
673
+ ),
674
+ );
675
+ const input = container.querySelector(
676
+ 'input[type="file"]',
677
+ ) as HTMLInputElement;
678
+ act(() => {
679
+ fireEvent.change(input, {target: {files: [file]}});
680
+ });
681
+ await nextFrame();
682
+ act(() => {
683
+ fireEvent.change(input, {
684
+ target: {files: [new File(['y'], 'notes.txt', {type: 'text/plain'})]},
685
+ });
686
+ });
687
+ await nextFrame();
688
+ await act(async () => {
689
+ settles[0]();
690
+ });
691
+ await nextFrame();
692
+ await nextFrame();
693
+ expect(busyAnnouncements()).toEqual([]);
694
+ });
695
+
696
+ it('announces the busy start of a clear under a pending changeAction', async () => {
697
+ render(
698
+ strict(
699
+ <FileInput
700
+ label="Attachment"
701
+ value={file}
702
+ onChange={() => {}}
703
+ changeAction={async () => new Promise<void>(() => {})}
704
+ />,
705
+ ),
706
+ );
707
+ act(() => {
708
+ fireEvent.click(screen.getByRole('button', {name: /Clear/}));
709
+ });
710
+ await nextFrame();
711
+ expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
712
+ });
713
+ });
714
+
715
+ describe('CommandPalette busy start inside its modal (spec:AST-086 FR8, FR3)', () => {
716
+ beforeEach(() => {
717
+ HTMLDialogElement.prototype.showModal = vi.fn(function (
718
+ this: HTMLDialogElement,
719
+ ) {
720
+ this.setAttribute('open', '');
721
+ });
722
+ HTMLDialogElement.prototype.close = vi.fn(function (
723
+ this: HTMLDialogElement,
724
+ ) {
725
+ this.removeAttribute('open');
726
+ });
727
+ const matches = Element.prototype.matches;
728
+ vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
729
+ this: Element,
730
+ selector: string,
731
+ ) {
732
+ return selector === ':modal'
733
+ ? this instanceof HTMLDialogElement && this.open
734
+ : matches.call(this, selector);
735
+ });
736
+ });
737
+ afterEach(() => {
738
+ vi.restoreAllMocks();
739
+ });
740
+
741
+ function regionOf(): Element | null {
742
+ return (
743
+ document
744
+ .querySelector('dialog[open]')
745
+ ?.querySelector('[data-astryx-modal-busy-region]') ?? null
746
+ );
747
+ }
748
+
749
+ it('announces the load on opening from a region inside its dialog, with a silent Spinner', async () => {
750
+ render(
751
+ strict(
752
+ <CommandPalette
753
+ isOpen
754
+ onOpenChange={() => {}}
755
+ searchSource={{
756
+ bootstrap: async () => new Promise<SearchableItem[]>(() => {}),
757
+ search: async () => new Promise<SearchableItem[]>(() => {}),
758
+ }}
759
+ />,
760
+ ),
761
+ );
762
+ await nextFrame();
763
+ await nextFrame();
764
+ const dialog = document.querySelector('dialog[open]') as HTMLElement;
765
+ expect(dialog.querySelector('.astryx-spinner')).toHaveAttribute(
766
+ 'aria-hidden',
767
+ 'true',
768
+ );
769
+ expect(regionOf()).toHaveTextContent('Loading');
770
+ expect(busyAnnouncements()).toEqual([]);
771
+ });
772
+
773
+ it('announces a search start once from inside its dialog, not on the hidden page channel', async () => {
774
+ render(
775
+ strict(
776
+ <CommandPalette
777
+ isOpen
778
+ onOpenChange={() => {}}
779
+ searchSource={pendingSource()}
780
+ />,
781
+ ),
782
+ );
783
+ await nextFrame();
784
+ act(() => {
785
+ fireEvent.change(screen.getByRole('combobox'), {target: {value: 'se'}});
786
+ });
787
+ await nextFrame();
788
+ await nextFrame();
789
+ expect(regionOf()).toHaveTextContent('Loading');
790
+ expect(busyAnnouncements()).toEqual([]);
791
+ });
792
+ });