@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.
- package/dist/BottomSheet/BottomSheet.d.ts +18 -6
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +268 -58
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +7 -11
- package/dist/Chat/ChatMessageList.d.ts.map +1 -1
- package/dist/Chat/ChatMessageList.js +8 -1
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +46 -7
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +6 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +12 -0
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +6 -1
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +9 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +6 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +6 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -1
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +9 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +9 -1
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +9 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +7 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +9 -1
- package/dist/astryx.css +2 -0
- package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
- package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
- package/dist/hooks/useBusyAnnouncement.js +87 -0
- package/dist/hooks/useMenuPress.d.ts +2 -2
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +5 -1
- package/dist/hooks/useTypeahead.d.ts.map +1 -1
- package/dist/hooks/useTypeahead.js +7 -2
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +1 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
- package/locales/en.json +6 -2
- package/locales/pseudo.json +3 -0
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
- package/src/BottomSheet/BottomSheet.spec.md +2 -5
- package/src/BottomSheet/BottomSheet.tsx +260 -47
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
- package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
- package/src/Carousel/Carousel.test.tsx +8 -12
- package/src/Carousel/Carousel.tsx +6 -13
- package/src/Chat/ChatComposerInput.test.tsx +167 -1
- package/src/Chat/ChatMessageList.test.tsx +6 -2
- package/src/Chat/ChatMessageList.tsx +7 -1
- package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
- package/src/Chat/useTriggerMenu.tsx +55 -6
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +5 -1
- package/src/ContextMenu/ContextMenu.test.tsx +34 -0
- package/src/ContextMenu/ContextMenu.tsx +13 -1
- package/src/DateInput/DateInput.tsx +5 -1
- package/src/DateInput/DateInputTouch.test.tsx +45 -0
- package/src/DateInput/NativeDateField.test.tsx +10 -2
- package/src/DateInput/NativeDateField.tsx +5 -1
- package/src/DateInput/TouchDateField.tsx +14 -2
- package/src/DateRangeInput/DateRangeInput.tsx +5 -1
- package/src/DateTimeInput/DateTimeInput.tsx +5 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
- package/src/MultiSelector/MultiSelector.test.tsx +18 -4
- package/src/MultiSelector/MultiSelector.tsx +5 -1
- package/src/ProgressBar/ProgressBar.test.tsx +4 -21
- package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
- package/src/Selector/Selector.test.tsx +22 -5
- package/src/Selector/Selector.tsx +5 -1
- package/src/TextArea/TextArea.test.tsx +9 -1
- package/src/TextArea/TextArea.tsx +5 -1
- package/src/TextInput/TextInput.tsx +5 -1
- package/src/Thumbnail/Thumbnail.test.tsx +6 -1
- package/src/Thumbnail/Thumbnail.tsx +12 -3
- package/src/TimeInput/TimeInput.tsx +5 -1
- package/src/TimeInput/TouchTimeField.tsx +12 -2
- package/src/Tokenizer/Tokenizer.test.tsx +17 -12
- package/src/Tokenizer/Tokenizer.tsx +10 -1
- package/src/Typeahead/BaseTypeahead.tsx +14 -1
- package/src/Typeahead/Typeahead.test.tsx +32 -41
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/BusyOwnerAnnouncement.test.tsx +576 -0
- package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
- package/src/hooks/useBusyAnnouncement.ts +100 -0
- package/src/hooks/useMenuPress.test.tsx +27 -1
- package/src/hooks/useMenuPress.ts +12 -3
- package/src/hooks/useTypeahead.test.tsx +42 -1
- package/src/hooks/useTypeahead.ts +7 -2
- package/src/i18n/generated-locales/en.generated.ts +1 -0
- 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
|
+
});
|