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