@astryxdesign/core 0.7.3 → 0.7.4-canary.28b1cd4
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/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +46 -7
- 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/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/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/useTriggerMenu.tsx +55 -6
- 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/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/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 +470 -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,470 @@
|
|
|
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 {BaseTypeahead, Typeahead} from '../Typeahead';
|
|
33
|
+
import type {SearchableItem, SearchSource} from '../Typeahead';
|
|
34
|
+
import type {ISOTimeString} from '../utils';
|
|
35
|
+
import {Tokenizer} from '../Tokenizer';
|
|
36
|
+
|
|
37
|
+
const announce = vi.fn();
|
|
38
|
+
vi.mock('../hooks/useAnnounce', async importOriginal => ({
|
|
39
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
40
|
+
useAnnounce: () => announce,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const OPTIONS = [
|
|
44
|
+
{label: 'Apple', value: 'apple'},
|
|
45
|
+
{label: 'Banana', value: 'banana'},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const OWNERS: ReadonlyArray<
|
|
49
|
+
readonly [string, (isLoading: boolean) => ReactElement]
|
|
50
|
+
> = [
|
|
51
|
+
[
|
|
52
|
+
'TextInput',
|
|
53
|
+
isLoading => (
|
|
54
|
+
<TextInput
|
|
55
|
+
label="Email"
|
|
56
|
+
value=""
|
|
57
|
+
onChange={() => {}}
|
|
58
|
+
isLoading={isLoading}
|
|
59
|
+
/>
|
|
60
|
+
),
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'TextArea',
|
|
64
|
+
isLoading => (
|
|
65
|
+
<TextArea
|
|
66
|
+
label="Notes"
|
|
67
|
+
value=""
|
|
68
|
+
onChange={() => {}}
|
|
69
|
+
isLoading={isLoading}
|
|
70
|
+
/>
|
|
71
|
+
),
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'Selector',
|
|
75
|
+
isLoading => (
|
|
76
|
+
<Selector
|
|
77
|
+
label="Fruit"
|
|
78
|
+
options={OPTIONS}
|
|
79
|
+
onChange={() => {}}
|
|
80
|
+
isLoading={isLoading}
|
|
81
|
+
/>
|
|
82
|
+
),
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
'MultiSelector',
|
|
86
|
+
isLoading => (
|
|
87
|
+
<MultiSelector
|
|
88
|
+
label="Fruits"
|
|
89
|
+
options={OPTIONS}
|
|
90
|
+
value={[]}
|
|
91
|
+
onChange={() => {}}
|
|
92
|
+
isLoading={isLoading}
|
|
93
|
+
/>
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
...(['popover', 'native', 'bottom-sheet'] as const).map(
|
|
97
|
+
presentation =>
|
|
98
|
+
[
|
|
99
|
+
`DateInput (${presentation})`,
|
|
100
|
+
(isLoading: boolean) => (
|
|
101
|
+
<DateInput
|
|
102
|
+
label="Date"
|
|
103
|
+
presentation={presentation}
|
|
104
|
+
onChange={() => {}}
|
|
105
|
+
isLoading={isLoading}
|
|
106
|
+
/>
|
|
107
|
+
),
|
|
108
|
+
] as const,
|
|
109
|
+
),
|
|
110
|
+
...(['popover', 'native', 'bottom-sheet'] as const).flatMap(presentation => [
|
|
111
|
+
[
|
|
112
|
+
`DateTimeInput (${presentation})`,
|
|
113
|
+
(isLoading: boolean) => (
|
|
114
|
+
<DateTimeInput
|
|
115
|
+
label="When"
|
|
116
|
+
presentation={presentation}
|
|
117
|
+
onChange={() => {}}
|
|
118
|
+
isLoading={isLoading}
|
|
119
|
+
/>
|
|
120
|
+
),
|
|
121
|
+
] as const,
|
|
122
|
+
[
|
|
123
|
+
`TimeInput (${presentation})`,
|
|
124
|
+
(isLoading: boolean) => (
|
|
125
|
+
<TimeInput
|
|
126
|
+
label="Time"
|
|
127
|
+
presentation={presentation}
|
|
128
|
+
onChange={() => {}}
|
|
129
|
+
isLoading={isLoading}
|
|
130
|
+
/>
|
|
131
|
+
),
|
|
132
|
+
] as const,
|
|
133
|
+
]),
|
|
134
|
+
[
|
|
135
|
+
'DateRangeInput',
|
|
136
|
+
isLoading => (
|
|
137
|
+
<DateRangeInput
|
|
138
|
+
label="Dates"
|
|
139
|
+
value={null}
|
|
140
|
+
onChange={() => {}}
|
|
141
|
+
isLoading={isLoading}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
'ComplexSelector',
|
|
147
|
+
isLoading => (
|
|
148
|
+
<ComplexSelector label="Fruit blend" value="Apple" isLoading={isLoading}>
|
|
149
|
+
{() => <div>Options</div>}
|
|
150
|
+
</ComplexSelector>
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/** A search source whose searches stay in flight until the test settles them. */
|
|
156
|
+
function pendingSource(): SearchSource<SearchableItem> {
|
|
157
|
+
return {
|
|
158
|
+
search: async () => new Promise<SearchableItem[]>(() => {}),
|
|
159
|
+
bootstrap: () => [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Owners whose busy state is a search in flight, started by typing. */
|
|
164
|
+
const SEARCH_OWNERS: ReadonlyArray<
|
|
165
|
+
readonly [string, (source: SearchSource<SearchableItem>) => ReactElement]
|
|
166
|
+
> = [
|
|
167
|
+
[
|
|
168
|
+
'Typeahead',
|
|
169
|
+
source => (
|
|
170
|
+
<Typeahead
|
|
171
|
+
label="User"
|
|
172
|
+
searchSource={source}
|
|
173
|
+
value={null}
|
|
174
|
+
onChange={() => {}}
|
|
175
|
+
debounceMs={0}
|
|
176
|
+
/>
|
|
177
|
+
),
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
'Tokenizer',
|
|
181
|
+
source => (
|
|
182
|
+
<Tokenizer
|
|
183
|
+
label="Users"
|
|
184
|
+
searchSource={source}
|
|
185
|
+
value={[]}
|
|
186
|
+
onChange={() => {}}
|
|
187
|
+
debounceMs={0}
|
|
188
|
+
/>
|
|
189
|
+
),
|
|
190
|
+
],
|
|
191
|
+
[
|
|
192
|
+
'BaseTypeahead',
|
|
193
|
+
source => (
|
|
194
|
+
<BaseTypeahead
|
|
195
|
+
searchSource={source}
|
|
196
|
+
value={null}
|
|
197
|
+
onChange={() => {}}
|
|
198
|
+
debounceMs={0}
|
|
199
|
+
/>
|
|
200
|
+
),
|
|
201
|
+
],
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
// The bottom-sheet DateInput measures its sheet; jsdom has no ResizeObserver.
|
|
205
|
+
beforeAll(() => {
|
|
206
|
+
vi.stubGlobal(
|
|
207
|
+
'ResizeObserver',
|
|
208
|
+
class {
|
|
209
|
+
observe() {}
|
|
210
|
+
unobserve() {}
|
|
211
|
+
disconnect() {}
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
afterAll(() => {
|
|
216
|
+
vi.unstubAllGlobals();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** Calls that announced the busy start, ignoring each control's other messages. */
|
|
220
|
+
function busyAnnouncements() {
|
|
221
|
+
return announce.mock.calls.filter(([message]) => message === 'Loading');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The hook picks its route a frame after the busy start; wait for it. */
|
|
225
|
+
async function nextFrame() {
|
|
226
|
+
await act(
|
|
227
|
+
async () =>
|
|
228
|
+
void (await new Promise(resolve => requestAnimationFrame(resolve))),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function strict(element: ReactElement) {
|
|
233
|
+
return <StrictMode>{element}</StrictMode>;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
announce.mockClear();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe.each(OWNERS)('%s busy state (spec:AST-086)', (_name, renderOwner) => {
|
|
241
|
+
it('keeps its Spinner silent: hidden from assistive technology, no live role exposed (FR8)', () => {
|
|
242
|
+
const {container} = render(renderOwner(true));
|
|
243
|
+
const spinner = container.querySelector('.astryx-spinner');
|
|
244
|
+
expect(spinner).not.toBeNull();
|
|
245
|
+
expect(spinner).toHaveAttribute('aria-hidden', 'true');
|
|
246
|
+
expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('announces its busy start once through the polite channel, even under StrictMode (FR6, FR8)', async () => {
|
|
250
|
+
const {rerender} = render(strict(renderOwner(true)));
|
|
251
|
+
await nextFrame();
|
|
252
|
+
rerender(strict(renderOwner(true)));
|
|
253
|
+
await nextFrame();
|
|
254
|
+
expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('announces nothing while idle, and again for each new busy start (FR6)', async () => {
|
|
258
|
+
const {rerender} = render(renderOwner(false));
|
|
259
|
+
await nextFrame();
|
|
260
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
261
|
+
rerender(renderOwner(true));
|
|
262
|
+
await nextFrame();
|
|
263
|
+
rerender(renderOwner(false));
|
|
264
|
+
rerender(renderOwner(true));
|
|
265
|
+
await nextFrame();
|
|
266
|
+
expect(busyAnnouncements()).toEqual([
|
|
267
|
+
['Loading', 'polite'],
|
|
268
|
+
['Loading', 'polite'],
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe.each(OWNERS)(
|
|
274
|
+
'%s busy inside an open native modal (spec:AST-086 Platform support)',
|
|
275
|
+
(_name, renderOwner) => {
|
|
276
|
+
// jsdom has no showModal(), so an open dialog never matches `:modal`;
|
|
277
|
+
// stand in for a dialog opened modally.
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
const matches = Element.prototype.matches;
|
|
280
|
+
vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
|
|
281
|
+
this: Element,
|
|
282
|
+
selector: string,
|
|
283
|
+
) {
|
|
284
|
+
return selector === ':modal'
|
|
285
|
+
? this instanceof HTMLDialogElement && this.open
|
|
286
|
+
: matches.call(this, selector);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
vi.restoreAllMocks();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('announces its busy start through a persistent region inside the dialog, not the body-level channel', async () => {
|
|
294
|
+
const {rerender} = render(<dialog open>{renderOwner(false)}</dialog>);
|
|
295
|
+
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
|
296
|
+
rerender(<dialog open>{renderOwner(true)}</dialog>);
|
|
297
|
+
await nextFrame();
|
|
298
|
+
const region = dialog.querySelector('[data-astryx-modal-busy-region]');
|
|
299
|
+
expect(region).not.toBeNull();
|
|
300
|
+
expect(region).toHaveAttribute('role', 'status');
|
|
301
|
+
expect(region).toHaveAttribute('aria-live', 'polite');
|
|
302
|
+
// Inserted empty, then filled on the next frame (FR3).
|
|
303
|
+
expect(region).toHaveTextContent('');
|
|
304
|
+
await nextFrame();
|
|
305
|
+
expect(region).toHaveTextContent('Loading');
|
|
306
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('announces inside the dialog when it is already busy as its dialog opens', async () => {
|
|
310
|
+
// The dialog opens in its own Effect, after the field's Effect has run,
|
|
311
|
+
// the way Dialog calls showModal().
|
|
312
|
+
function OpeningDialog() {
|
|
313
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
ref.current?.setAttribute('open', '');
|
|
316
|
+
}, []);
|
|
317
|
+
return <dialog ref={ref}>{renderOwner(true)}</dialog>;
|
|
318
|
+
}
|
|
319
|
+
render(<OpeningDialog />);
|
|
320
|
+
await nextFrame();
|
|
321
|
+
await nextFrame();
|
|
322
|
+
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
|
323
|
+
expect(
|
|
324
|
+
dialog.querySelector('[data-astryx-modal-busy-region]'),
|
|
325
|
+
).toHaveTextContent('Loading');
|
|
326
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
describe.each(SEARCH_OWNERS)(
|
|
332
|
+
'%s search busy state (spec:AST-086)',
|
|
333
|
+
(_name, renderOwner) => {
|
|
334
|
+
it('keeps its search Spinner named but not live, and announces the search start once (FR6, FR8)', async () => {
|
|
335
|
+
const {container} = render(strict(renderOwner(pendingSource())));
|
|
336
|
+
await nextFrame();
|
|
337
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
338
|
+
act(() => {
|
|
339
|
+
fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
|
|
340
|
+
});
|
|
341
|
+
await nextFrame();
|
|
342
|
+
// Named but not live (family:input-fields FR7, FR8).
|
|
343
|
+
const spinner = container.querySelector('.astryx-spinner');
|
|
344
|
+
expect(spinner).toHaveAttribute('role', 'img');
|
|
345
|
+
expect(screen.getByRole('img', {name: 'Loading'})).toBe(spinner);
|
|
346
|
+
expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
|
|
347
|
+
expect(busyAnnouncements()).toEqual([['Loading', 'polite']]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('announces each new search start again (FR6)', async () => {
|
|
351
|
+
render(renderOwner(pendingSource()));
|
|
352
|
+
const input = screen.getByRole('combobox');
|
|
353
|
+
act(() => {
|
|
354
|
+
fireEvent.change(input, {target: {value: 'Al'}});
|
|
355
|
+
});
|
|
356
|
+
await nextFrame();
|
|
357
|
+
// Clearing the query ends the search; typing again starts a new one.
|
|
358
|
+
act(() => {
|
|
359
|
+
fireEvent.change(input, {target: {value: ''}});
|
|
360
|
+
});
|
|
361
|
+
await nextFrame();
|
|
362
|
+
act(() => {
|
|
363
|
+
fireEvent.change(input, {target: {value: 'Be'}});
|
|
364
|
+
});
|
|
365
|
+
await nextFrame();
|
|
366
|
+
expect(busyAnnouncements()).toEqual([
|
|
367
|
+
['Loading', 'polite'],
|
|
368
|
+
['Loading', 'polite'],
|
|
369
|
+
]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('announces the search start inside an open modal dialog, not the body channel', async () => {
|
|
373
|
+
const matches = Element.prototype.matches;
|
|
374
|
+
vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
|
|
375
|
+
this: Element,
|
|
376
|
+
selector: string,
|
|
377
|
+
) {
|
|
378
|
+
return selector === ':modal'
|
|
379
|
+
? this instanceof HTMLDialogElement && this.open
|
|
380
|
+
: matches.call(this, selector);
|
|
381
|
+
});
|
|
382
|
+
render(<dialog open>{renderOwner(pendingSource())}</dialog>);
|
|
383
|
+
act(() => {
|
|
384
|
+
fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
|
|
385
|
+
});
|
|
386
|
+
await nextFrame();
|
|
387
|
+
await nextFrame();
|
|
388
|
+
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
|
389
|
+
expect(
|
|
390
|
+
dialog.querySelector('[data-astryx-modal-busy-region]'),
|
|
391
|
+
).toHaveTextContent('Loading');
|
|
392
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
393
|
+
vi.restoreAllMocks();
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Bottom-sheet fields whose sheet stays open while a changeAction is pending:
|
|
400
|
+
* the sheet is a modal dialog, so the busy start must be announced inside it.
|
|
401
|
+
*/
|
|
402
|
+
const SHEET_OWNERS: ReadonlyArray<
|
|
403
|
+
readonly [
|
|
404
|
+
string,
|
|
405
|
+
(changeAction: () => Promise<void>) => ReactElement,
|
|
406
|
+
() => void,
|
|
407
|
+
]
|
|
408
|
+
> = [
|
|
409
|
+
[
|
|
410
|
+
'TimeInput',
|
|
411
|
+
changeAction => (
|
|
412
|
+
<TimeInput
|
|
413
|
+
label="Time"
|
|
414
|
+
presentation="bottom-sheet"
|
|
415
|
+
value={'09:00' as ISOTimeString}
|
|
416
|
+
onChange={() => {}}
|
|
417
|
+
changeAction={changeAction}
|
|
418
|
+
/>
|
|
419
|
+
),
|
|
420
|
+
() => {
|
|
421
|
+
fireEvent.click(screen.getByRole('button', {name: /Time/}));
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
describe.each(SHEET_OWNERS)(
|
|
427
|
+
'%s busy start from inside its open sheet (spec:AST-086 Platform support)',
|
|
428
|
+
(_name, renderOwner, openSheet) => {
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
431
|
+
this: HTMLDialogElement,
|
|
432
|
+
) {
|
|
433
|
+
this.setAttribute('open', '');
|
|
434
|
+
});
|
|
435
|
+
HTMLDialogElement.prototype.close = vi.fn(function (
|
|
436
|
+
this: HTMLDialogElement,
|
|
437
|
+
) {
|
|
438
|
+
this.removeAttribute('open');
|
|
439
|
+
});
|
|
440
|
+
const matches = Element.prototype.matches;
|
|
441
|
+
vi.spyOn(Element.prototype, 'matches').mockImplementation(function (
|
|
442
|
+
this: Element,
|
|
443
|
+
selector: string,
|
|
444
|
+
) {
|
|
445
|
+
return selector === ':modal'
|
|
446
|
+
? this instanceof HTMLDialogElement && this.open
|
|
447
|
+
: matches.call(this, selector);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
afterEach(() => {
|
|
451
|
+
vi.restoreAllMocks();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('announces the busy start in a region inside the open sheet, not behind it', async () => {
|
|
455
|
+
render(renderOwner(async () => new Promise<void>(() => {})));
|
|
456
|
+
openSheet();
|
|
457
|
+
const sheet = document.querySelector('dialog[open]');
|
|
458
|
+
expect(sheet).not.toBeNull();
|
|
459
|
+
act(() => {
|
|
460
|
+
fireEvent.click(within(sheet as HTMLElement).getAllByRole('option')[1]);
|
|
461
|
+
});
|
|
462
|
+
await nextFrame();
|
|
463
|
+
await nextFrame();
|
|
464
|
+
expect(
|
|
465
|
+
sheet!.querySelector('[data-astryx-modal-busy-region]'),
|
|
466
|
+
).toHaveTextContent('Loading');
|
|
467
|
+
expect(busyAnnouncements()).toEqual([]);
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useBusyAnnouncement.ts
|
|
7
|
+
* @input Whether the owning control is busy, the words to announce, and a ref
|
|
8
|
+
* to an element inside the control
|
|
9
|
+
* @output Announces each busy start once, through the shared polite channel
|
|
10
|
+
* or a persistent region inside the open modal dialog holding the control
|
|
11
|
+
* @position Core a11y hook for controls that show a decorative busy Spinner
|
|
12
|
+
*
|
|
13
|
+
* The busy Spinner inside a control is decorative (hidden, no live role); the
|
|
14
|
+
* control announces its busy state instead (`spec:AST-086` FR8), once per busy
|
|
15
|
+
* start (FR6): rerenders and StrictMode replay do not repeat it, and settling
|
|
16
|
+
* re-arms it. While a native modal is open, the body-level channel is not
|
|
17
|
+
* exposed, so a control inside it announces through one persistent polite
|
|
18
|
+
* region in that dialog, inserted empty and filled a frame later (Platform
|
|
19
|
+
* support, FR3).
|
|
20
|
+
*
|
|
21
|
+
* SYNC: deliberately NOT exported from /packages/core/src/hooks/index.ts —
|
|
22
|
+
* this is a shared implementation detail of Astryx's busy controls.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {useEffect, useRef, type RefObject} from 'react';
|
|
26
|
+
import {useAnnounce} from './useAnnounce';
|
|
27
|
+
|
|
28
|
+
const REGION_ATTR = 'data-astryx-modal-busy-region';
|
|
29
|
+
const VISUALLY_HIDDEN_CSS =
|
|
30
|
+
'position:absolute;width:1px;height:1px;margin:-1px;padding:0;' +
|
|
31
|
+
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
|
32
|
+
const CLEAR_DELAY_MS = 2000; // Matches useAnnounce.
|
|
33
|
+
|
|
34
|
+
// The pending write and clear of each in-dialog region; a new write cancels
|
|
35
|
+
// them so an earlier message can neither land late nor clear a later one.
|
|
36
|
+
const pendingByRegion = new WeakMap<
|
|
37
|
+
HTMLElement,
|
|
38
|
+
{frame: number; timer?: ReturnType<typeof setTimeout>}
|
|
39
|
+
>();
|
|
40
|
+
|
|
41
|
+
function announceInModal(dialog: HTMLDialogElement, message: string): void {
|
|
42
|
+
let region = dialog.querySelector<HTMLElement>(`:scope > [${REGION_ATTR}]`);
|
|
43
|
+
if (region == null) {
|
|
44
|
+
region = document.createElement('div');
|
|
45
|
+
region.setAttribute(REGION_ATTR, '');
|
|
46
|
+
region.setAttribute('role', 'status');
|
|
47
|
+
region.setAttribute('aria-live', 'polite');
|
|
48
|
+
region.setAttribute('aria-atomic', 'true');
|
|
49
|
+
region.style.cssText = VISUALLY_HIDDEN_CSS;
|
|
50
|
+
dialog.appendChild(region);
|
|
51
|
+
}
|
|
52
|
+
const target = region;
|
|
53
|
+
const previous = pendingByRegion.get(target);
|
|
54
|
+
if (previous != null) {
|
|
55
|
+
cancelAnimationFrame(previous.frame);
|
|
56
|
+
clearTimeout(previous.timer);
|
|
57
|
+
}
|
|
58
|
+
target.textContent = '';
|
|
59
|
+
const pending: {frame: number; timer?: ReturnType<typeof setTimeout>} = {
|
|
60
|
+
frame: 0,
|
|
61
|
+
};
|
|
62
|
+
pending.frame = requestAnimationFrame(() => {
|
|
63
|
+
target.textContent = message;
|
|
64
|
+
pending.timer = setTimeout(() => {
|
|
65
|
+
target.textContent = '';
|
|
66
|
+
pendingByRegion.delete(target);
|
|
67
|
+
}, CLEAR_DELAY_MS);
|
|
68
|
+
});
|
|
69
|
+
pendingByRegion.set(target, pending);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useBusyAnnouncement(
|
|
73
|
+
isBusy: boolean,
|
|
74
|
+
message: string,
|
|
75
|
+
elementRef: RefObject<Element | null>,
|
|
76
|
+
): void {
|
|
77
|
+
const announce = useAnnounce();
|
|
78
|
+
const hasAnnouncedRef = useRef(false);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!isBusy) {
|
|
81
|
+
hasAnnouncedRef.current = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (hasAnnouncedRef.current) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Choose the route a frame later: a Dialog opening in the same commit
|
|
88
|
+
// calls showModal() in its own Effect, which runs after this one.
|
|
89
|
+
const frame = requestAnimationFrame(() => {
|
|
90
|
+
hasAnnouncedRef.current = true;
|
|
91
|
+
const dialog = elementRef.current?.closest('dialog');
|
|
92
|
+
if (dialog?.open && dialog.matches(':modal')) {
|
|
93
|
+
announceInModal(dialog, message);
|
|
94
|
+
} else {
|
|
95
|
+
announce(message, 'polite');
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return () => cancelAnimationFrame(frame);
|
|
99
|
+
}, [announce, elementRef, isBusy, message]);
|
|
100
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
14
|
-
import {act, fireEvent, render, screen} from '@testing-library/react';
|
|
14
|
+
import {act, fireEvent, render, screen, within} from '@testing-library/react';
|
|
15
15
|
import {useRef, useState} from 'react';
|
|
16
16
|
import {
|
|
17
17
|
MENU_PRESS_AUTOSCROLL_ZONE_PX,
|
|
@@ -145,6 +145,32 @@ describe('useMenuPress — tracking inside the menu', () => {
|
|
|
145
145
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
it('hit-tests in the shadow root that holds the menu, not the document', () => {
|
|
149
|
+
const host = document.createElement('div');
|
|
150
|
+
document.body.append(host);
|
|
151
|
+
const shadow = host.attachShadow({mode: 'open'});
|
|
152
|
+
const container = document.createElement('div');
|
|
153
|
+
shadow.append(container);
|
|
154
|
+
const onSelect = vi.fn();
|
|
155
|
+
render(<Harness onSelect={onSelect} />, {container});
|
|
156
|
+
const inShadow = (label: string) =>
|
|
157
|
+
within(container).getByRole('menuitem', {name: label});
|
|
158
|
+
// As in a browser: the document answers with the shadow host; only the
|
|
159
|
+
// shadow root knows the row under the point.
|
|
160
|
+
Object.assign(document, {elementFromPoint: () => host});
|
|
161
|
+
Object.assign(shadow, {elementFromPoint: () => inShadow('C')});
|
|
162
|
+
try {
|
|
163
|
+
const events = {...mouse(), composed: true, bubbles: true};
|
|
164
|
+
fireEvent.pointerDown(inShadow('A'), events);
|
|
165
|
+
fireEvent.pointerUp(inShadow('A'), events);
|
|
166
|
+
expect(onSelect).toHaveBeenCalledWith('C');
|
|
167
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
168
|
+
} finally {
|
|
169
|
+
delete (document as {elementFromPoint?: unknown}).elementFromPoint;
|
|
170
|
+
host.remove();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
148
174
|
it('moves the highlight as focus with preventScroll', () => {
|
|
149
175
|
render(<Harness />);
|
|
150
176
|
const b = row('B');
|
|
@@ -209,8 +209,8 @@ export interface UseMenuPressOptions {
|
|
|
209
209
|
getScroller?: () => HTMLElement | null;
|
|
210
210
|
/**
|
|
211
211
|
* Resolve the element under a viewport point. Defaults to
|
|
212
|
-
* `
|
|
213
|
-
* document lacks it (jsdom). A test seam.
|
|
212
|
+
* `elementFromPoint` on the menu's own document or shadow root, falling back
|
|
213
|
+
* to the event target where the document lacks it (jsdom). A test seam.
|
|
214
214
|
*/
|
|
215
215
|
hitTest?: (x: number, y: number) => Element | null;
|
|
216
216
|
/** @default 500 */
|
|
@@ -364,7 +364,16 @@ export function useMenuPress(options: UseMenuPressOptions): UseMenuPressReturn {
|
|
|
364
364
|
if (hitTest != null) {
|
|
365
365
|
element = hitTest(event.clientX, event.clientY);
|
|
366
366
|
} else if (typeof doc.elementFromPoint === 'function') {
|
|
367
|
-
|
|
367
|
+
// Hit-test in the menu's own tree: inside a shadow root the document
|
|
368
|
+
// answers with the shadow host, never the row under the pointer.
|
|
369
|
+
const tree = menu?.getRootNode();
|
|
370
|
+
const scope: Pick<Document, 'elementFromPoint'> =
|
|
371
|
+
tree != null &&
|
|
372
|
+
tree !== doc &&
|
|
373
|
+
typeof (tree as ShadowRoot).elementFromPoint === 'function'
|
|
374
|
+
? (tree as ShadowRoot)
|
|
375
|
+
: doc;
|
|
376
|
+
element = scope.elementFromPoint(event.clientX, event.clientY);
|
|
368
377
|
} else {
|
|
369
378
|
element = event.target instanceof Element ? event.target : null;
|
|
370
379
|
}
|