@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,576 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DateInputAudit.a11y.chromium.spec.ts
|
|
5
|
+
* @input Exact-head DateInput stories across field states, presentations,
|
|
6
|
+
* themes, direction, pointers, and viewport sizes
|
|
7
|
+
* @output Screenshots and fail-closed sensor receipts for the DateInput
|
|
8
|
+
* whole-component audit
|
|
9
|
+
* @position Component-owned Night Watch browser evidence; CI runs it only for a
|
|
10
|
+
* DateInput audit pull request
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {createHash} from 'node:crypto';
|
|
14
|
+
import {execFileSync} from 'node:child_process';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import {expect, test, type Locator, type Page} from '@playwright/test';
|
|
18
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_STORYBOOK_DIR,
|
|
21
|
+
serveStorybook,
|
|
22
|
+
type StaticServer,
|
|
23
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
24
|
+
|
|
25
|
+
const OUTPUT = path.resolve('date-input-audit-evidence');
|
|
26
|
+
const WIDE = {width: 1024, height: 768};
|
|
27
|
+
const NARROW = {width: 320, height: 720};
|
|
28
|
+
|
|
29
|
+
type Mode = 'light' | 'dark';
|
|
30
|
+
type Direction = 'ltr' | 'rtl';
|
|
31
|
+
type Theme = 'neutral' | 'butter';
|
|
32
|
+
type Pointer = 'fine' | 'coarse';
|
|
33
|
+
type Story =
|
|
34
|
+
| 'default'
|
|
35
|
+
| 'with-value'
|
|
36
|
+
| 'disabled'
|
|
37
|
+
| 'disabled-with-message'
|
|
38
|
+
| 'with-error-status'
|
|
39
|
+
| 'with-warning-status'
|
|
40
|
+
| 'with-success-status'
|
|
41
|
+
| 'clearable'
|
|
42
|
+
| 'open-calendar-audit'
|
|
43
|
+
| 'styling-parts';
|
|
44
|
+
type State =
|
|
45
|
+
| 'rest'
|
|
46
|
+
| 'hover'
|
|
47
|
+
| 'focus-toggle'
|
|
48
|
+
| 'focus-input'
|
|
49
|
+
| 'typed-invalid'
|
|
50
|
+
| 'clear-hover'
|
|
51
|
+
| 'open'
|
|
52
|
+
| 'sheet-open';
|
|
53
|
+
type Surface = 'popover' | 'native' | 'sheet' | 'mixed';
|
|
54
|
+
|
|
55
|
+
type AuditCase = {
|
|
56
|
+
key: string;
|
|
57
|
+
story: Story;
|
|
58
|
+
args: Record<string, string>;
|
|
59
|
+
surface: Surface;
|
|
60
|
+
mode: Mode;
|
|
61
|
+
direction: Direction;
|
|
62
|
+
theme: Theme;
|
|
63
|
+
state: State;
|
|
64
|
+
pointer: Pointer;
|
|
65
|
+
forcedColors: boolean;
|
|
66
|
+
viewport: {width: number; height: number};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type AuditReceipt = {
|
|
70
|
+
passed: boolean;
|
|
71
|
+
expected: Record<string, unknown>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const cases: AuditCase[] = [];
|
|
75
|
+
function addPair(
|
|
76
|
+
key: string,
|
|
77
|
+
options: Partial<Omit<AuditCase, 'key' | 'mode'>> = {},
|
|
78
|
+
) {
|
|
79
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
80
|
+
cases.push({
|
|
81
|
+
key: `${key}-${mode}`,
|
|
82
|
+
story: options.story ?? 'with-value',
|
|
83
|
+
args: options.args ?? {},
|
|
84
|
+
surface: options.surface ?? 'popover',
|
|
85
|
+
mode,
|
|
86
|
+
direction: options.direction ?? 'ltr',
|
|
87
|
+
theme: options.theme ?? 'neutral',
|
|
88
|
+
state: options.state ?? 'rest',
|
|
89
|
+
pointer: options.pointer ?? 'fine',
|
|
90
|
+
forcedColors: options.forcedColors ?? false,
|
|
91
|
+
viewport: options.viewport ?? WIDE,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BUSY = {isLoading: '!true'};
|
|
97
|
+
|
|
98
|
+
addPair('empty-rest', {story: 'default'});
|
|
99
|
+
addPair('valued-rest');
|
|
100
|
+
addPair('valued-hover', {state: 'hover'});
|
|
101
|
+
addPair('focus-toggle', {state: 'focus-toggle'});
|
|
102
|
+
addPair('focus-input', {state: 'focus-input'});
|
|
103
|
+
addPair('typed-invalid', {state: 'typed-invalid'});
|
|
104
|
+
addPair('open-calendar', {story: 'open-calendar-audit', state: 'open'});
|
|
105
|
+
addPair('size-sm', {args: {size: 'sm'}});
|
|
106
|
+
addPair('size-lg', {args: {size: 'lg'}});
|
|
107
|
+
addPair('busy-rest', {args: BUSY});
|
|
108
|
+
addPair('busy-hover', {args: BUSY, state: 'hover'});
|
|
109
|
+
addPair('busy-focus-input', {args: BUSY, state: 'focus-input'});
|
|
110
|
+
addPair('disabled-rest', {story: 'disabled'});
|
|
111
|
+
addPair('disabled-hover', {story: 'disabled', state: 'hover'});
|
|
112
|
+
addPair('disabled-focus-input', {story: 'disabled', state: 'focus-input'});
|
|
113
|
+
addPair('disabled-message-focus', {
|
|
114
|
+
story: 'disabled-with-message',
|
|
115
|
+
state: 'focus-toggle',
|
|
116
|
+
});
|
|
117
|
+
addPair('status-error', {story: 'with-error-status'});
|
|
118
|
+
addPair('status-warning', {story: 'with-warning-status'});
|
|
119
|
+
addPair('status-success', {story: 'with-success-status'});
|
|
120
|
+
addPair('clearable-rest', {story: 'clearable'});
|
|
121
|
+
addPair('clearable-hover-clear', {story: 'clearable', state: 'clear-hover'});
|
|
122
|
+
addPair('native-rest', {args: {presentation: 'native'}, surface: 'native'});
|
|
123
|
+
addPair('native-busy', {
|
|
124
|
+
args: {presentation: 'native', ...BUSY},
|
|
125
|
+
surface: 'native',
|
|
126
|
+
});
|
|
127
|
+
addPair('sheet-rest', {
|
|
128
|
+
args: {presentation: 'bottom-sheet'},
|
|
129
|
+
surface: 'sheet',
|
|
130
|
+
});
|
|
131
|
+
addPair('sheet-busy', {
|
|
132
|
+
args: {presentation: 'bottom-sheet', ...BUSY},
|
|
133
|
+
surface: 'sheet',
|
|
134
|
+
});
|
|
135
|
+
addPair('styling-parts', {story: 'styling-parts', surface: 'mixed'});
|
|
136
|
+
addPair('custom-theme-rest', {theme: 'butter'});
|
|
137
|
+
addPair('custom-theme-busy', {theme: 'butter', args: BUSY});
|
|
138
|
+
addPair('custom-theme-disabled', {theme: 'butter', story: 'disabled'});
|
|
139
|
+
addPair('custom-theme-open', {
|
|
140
|
+
theme: 'butter',
|
|
141
|
+
story: 'open-calendar-audit',
|
|
142
|
+
state: 'open',
|
|
143
|
+
});
|
|
144
|
+
addPair('rtl-clearable', {story: 'clearable', direction: 'rtl'});
|
|
145
|
+
addPair('rtl-open', {
|
|
146
|
+
story: 'open-calendar-audit',
|
|
147
|
+
state: 'open',
|
|
148
|
+
direction: 'rtl',
|
|
149
|
+
});
|
|
150
|
+
addPair('narrow-fine', {viewport: NARROW});
|
|
151
|
+
addPair('narrow-coarse-native', {
|
|
152
|
+
viewport: NARROW,
|
|
153
|
+
pointer: 'coarse',
|
|
154
|
+
surface: 'native',
|
|
155
|
+
});
|
|
156
|
+
addPair('narrow-coarse-sheet-open', {
|
|
157
|
+
viewport: NARROW,
|
|
158
|
+
pointer: 'coarse',
|
|
159
|
+
surface: 'sheet',
|
|
160
|
+
args: {presentation: 'adaptive-bottom-sheet'},
|
|
161
|
+
state: 'sheet-open',
|
|
162
|
+
});
|
|
163
|
+
addPair('forced-colors-focus-input', {
|
|
164
|
+
state: 'focus-input',
|
|
165
|
+
forcedColors: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** Accessible name each story gives its field. */
|
|
169
|
+
const LABEL: Record<Story, string | null> = {
|
|
170
|
+
default: 'Date',
|
|
171
|
+
'with-value': 'Event date',
|
|
172
|
+
disabled: 'Locked date',
|
|
173
|
+
'disabled-with-message': 'Event date',
|
|
174
|
+
'with-error-status': 'Event date',
|
|
175
|
+
'with-warning-status': 'Meeting date',
|
|
176
|
+
'with-success-status': 'Appointment date',
|
|
177
|
+
clearable: 'Event date',
|
|
178
|
+
'open-calendar-audit': 'Appointment date',
|
|
179
|
+
'styling-parts': null,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Default mode, so a failing case does not skip the rest of the matrix.
|
|
183
|
+
test.describe.configure({retries: 0});
|
|
184
|
+
let server: StaticServer;
|
|
185
|
+
let checkoutSha: string;
|
|
186
|
+
let storybookSha: string;
|
|
187
|
+
|
|
188
|
+
function storyId(item: AuditCase) {
|
|
189
|
+
return `core-dateinput--${item.story}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function storyUrl(item: AuditCase) {
|
|
193
|
+
const args = Object.entries(item.args)
|
|
194
|
+
.map(([name, value]) => `${name}:${value}`)
|
|
195
|
+
.join(';');
|
|
196
|
+
return `${server.origin}/iframe.html?id=${storyId(item)}&viewMode=story${
|
|
197
|
+
args ? `&args=${args}` : ''
|
|
198
|
+
}&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isBusyCase(item: AuditCase) {
|
|
202
|
+
return item.args.isLoading === '!true';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isDisabledCase(item: AuditCase) {
|
|
206
|
+
return item.story === 'disabled' || item.story === 'disabled-with-message';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function settle(page: Page) {
|
|
210
|
+
await holdMotionStill(page);
|
|
211
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
212
|
+
await page.waitForTimeout(40);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The element that carries the field's name and value on this surface. */
|
|
216
|
+
function fieldControl(page: Page, item: AuditCase): Locator {
|
|
217
|
+
return item.surface === 'native'
|
|
218
|
+
? page.locator('#storybook-root input[type="date"]').first()
|
|
219
|
+
: page.locator('#storybook-root [role="combobox"]').first();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test.beforeAll(async () => {
|
|
223
|
+
// Never wipe: Playwright restarts the worker after a failing case and
|
|
224
|
+
// reruns this hook, which would delete frames already banked.
|
|
225
|
+
fs.mkdirSync(OUTPUT, {recursive: true});
|
|
226
|
+
checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
}).trim();
|
|
229
|
+
const expected = process.env.ASTRYX_HEAD_SHA;
|
|
230
|
+
if (expected && expected !== checkoutSha) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`checkout ${checkoutSha} does not match expected head ${expected}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
server = await serveStorybook(
|
|
236
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
237
|
+
);
|
|
238
|
+
const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
throw new Error(`Storybook build has no SHA stamp (${response.status})`);
|
|
241
|
+
}
|
|
242
|
+
storybookSha = (await response.text()).trim();
|
|
243
|
+
if (storybookSha !== checkoutSha) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test.afterAll(async () => {
|
|
251
|
+
await server?.close();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
for (const item of cases) {
|
|
255
|
+
test(item.key, async ({browser}) => {
|
|
256
|
+
const context = await browser.newContext({
|
|
257
|
+
viewport: item.viewport,
|
|
258
|
+
colorScheme: item.mode,
|
|
259
|
+
reducedMotion: 'reduce',
|
|
260
|
+
hasTouch: item.pointer === 'coarse',
|
|
261
|
+
forcedColors: item.forcedColors ? 'active' : 'none',
|
|
262
|
+
});
|
|
263
|
+
const page = await context.newPage();
|
|
264
|
+
const pageErrors: string[] = [];
|
|
265
|
+
page.on('pageerror', error => pageErrors.push(String(error)));
|
|
266
|
+
await page.goto(storyUrl(item), {waitUntil: 'load'});
|
|
267
|
+
await page.locator('#storybook-root').waitFor();
|
|
268
|
+
await expect(
|
|
269
|
+
page.locator('#storybook-root .astryx-date-input').first(),
|
|
270
|
+
).toBeVisible();
|
|
271
|
+
|
|
272
|
+
const wrapper = page.locator('#storybook-root .astryx-date-input').first();
|
|
273
|
+
const control = fieldControl(page, item);
|
|
274
|
+
const label = LABEL[item.story];
|
|
275
|
+
if (item.surface !== 'mixed') {
|
|
276
|
+
await expect(control).toBeVisible();
|
|
277
|
+
if (label != null) {
|
|
278
|
+
await expect(control).toHaveAccessibleName(label);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await settle(page);
|
|
282
|
+
|
|
283
|
+
// The calendar toggle is the field's first button; its name follows the
|
|
284
|
+
// open state.
|
|
285
|
+
const toggle = wrapper.locator('button').first();
|
|
286
|
+
if (item.state === 'hover') {
|
|
287
|
+
await wrapper.hover();
|
|
288
|
+
} else if (item.state === 'focus-toggle') {
|
|
289
|
+
await page.locator('body').focus();
|
|
290
|
+
await page.keyboard.press('Tab');
|
|
291
|
+
if (item.story === 'disabled-with-message') {
|
|
292
|
+
// The reason tooltip opens after its show delay.
|
|
293
|
+
await page.waitForTimeout(800);
|
|
294
|
+
}
|
|
295
|
+
} else if (item.state === 'focus-input') {
|
|
296
|
+
await control.focus();
|
|
297
|
+
// Keyboard modality: a key press after programmatic focus.
|
|
298
|
+
await page.keyboard.press('Shift');
|
|
299
|
+
} else if (item.state === 'typed-invalid') {
|
|
300
|
+
await control.focus();
|
|
301
|
+
await page.keyboard.press('ControlOrMeta+a');
|
|
302
|
+
await page.keyboard.type('not a date');
|
|
303
|
+
} else if (item.state === 'clear-hover') {
|
|
304
|
+
await wrapper.getByRole('button', {name: 'Clear Event date'}).hover();
|
|
305
|
+
await page.waitForTimeout(800);
|
|
306
|
+
} else if (item.state === 'open') {
|
|
307
|
+
await expect(control).toHaveAttribute('aria-expanded', 'true');
|
|
308
|
+
await expect(page.getByRole('dialog').first()).toBeVisible();
|
|
309
|
+
} else if (item.state === 'sheet-open') {
|
|
310
|
+
await control.tap();
|
|
311
|
+
await expect(page.getByRole('dialog').first()).toBeVisible();
|
|
312
|
+
await page.waitForTimeout(120);
|
|
313
|
+
}
|
|
314
|
+
await page.evaluate(async () => document.fonts.ready);
|
|
315
|
+
await page.waitForTimeout(40);
|
|
316
|
+
|
|
317
|
+
const observed = await page.evaluate(() => ({
|
|
318
|
+
theme: document
|
|
319
|
+
.querySelector('[data-astryx-theme]')
|
|
320
|
+
?.getAttribute('data-astryx-theme'),
|
|
321
|
+
mode: document.documentElement.getAttribute('data-theme'),
|
|
322
|
+
direction: getComputedStyle(
|
|
323
|
+
document.querySelector('.astryx-date-input') as HTMLElement,
|
|
324
|
+
).direction,
|
|
325
|
+
viewport: {width: innerWidth, height: innerHeight},
|
|
326
|
+
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
327
|
+
coarsePointer: matchMedia('(pointer: coarse)').matches,
|
|
328
|
+
hoverCapable: matchMedia('(hover: hover)').matches,
|
|
329
|
+
forcedColors: matchMedia('(forced-colors: active)').matches,
|
|
330
|
+
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
|
331
|
+
fonts: document.fonts.status,
|
|
332
|
+
activeElement: (() => {
|
|
333
|
+
const active = document.activeElement as HTMLElement | null;
|
|
334
|
+
if (active == null || active === document.body) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
tag: active.tagName.toLowerCase(),
|
|
339
|
+
type: active.getAttribute('type'),
|
|
340
|
+
role: active.getAttribute('role'),
|
|
341
|
+
label: active.getAttribute('aria-label'),
|
|
342
|
+
focusVisible: active.matches(':focus-visible'),
|
|
343
|
+
outlineStyle: getComputedStyle(active).outlineStyle,
|
|
344
|
+
outlineColor: getComputedStyle(active).outlineColor,
|
|
345
|
+
};
|
|
346
|
+
})(),
|
|
347
|
+
// Top-layer popovers have no offsetParent, and BottomSheet is a native
|
|
348
|
+
// <dialog> without an explicit role, so read rendered visibility and
|
|
349
|
+
// both dialog forms.
|
|
350
|
+
tooltips: Array.from(document.querySelectorAll('[role="tooltip"]'))
|
|
351
|
+
.filter(node => node.checkVisibility())
|
|
352
|
+
.map(node => node.textContent?.replace(/\s+/g, ' ').trim()),
|
|
353
|
+
dialogs: Array.from(
|
|
354
|
+
new Set(document.querySelectorAll('[role="dialog"], dialog[open]')),
|
|
355
|
+
)
|
|
356
|
+
.filter(node => node.checkVisibility())
|
|
357
|
+
.map(
|
|
358
|
+
node =>
|
|
359
|
+
node.getAttribute('aria-label') ??
|
|
360
|
+
document
|
|
361
|
+
.getElementById(node.getAttribute('aria-labelledby') ?? '')
|
|
362
|
+
?.textContent?.trim() ??
|
|
363
|
+
null,
|
|
364
|
+
),
|
|
365
|
+
}));
|
|
366
|
+
const paint = await wrapper.evaluate(element => {
|
|
367
|
+
const style = getComputedStyle(element);
|
|
368
|
+
const box = element.getBoundingClientRect();
|
|
369
|
+
return {
|
|
370
|
+
hovered: element.matches(':hover'),
|
|
371
|
+
opacity: style.opacity,
|
|
372
|
+
boxShadow: style.boxShadow,
|
|
373
|
+
borderColor: style.borderTopColor,
|
|
374
|
+
backgroundColor: style.backgroundColor,
|
|
375
|
+
cursor: style.cursor,
|
|
376
|
+
themeSize: element.getAttribute('data-size'),
|
|
377
|
+
themeStatus: element.getAttribute('data-status'),
|
|
378
|
+
themeDisabled: element.getAttribute('data-disabled'),
|
|
379
|
+
spinners: element.querySelectorAll('[class*="astryx-spinner"]').length,
|
|
380
|
+
geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
const semantics =
|
|
384
|
+
item.surface === 'mixed'
|
|
385
|
+
? null
|
|
386
|
+
: await control.evaluate(element => ({
|
|
387
|
+
tag: element.tagName.toLowerCase(),
|
|
388
|
+
type: element.getAttribute('type'),
|
|
389
|
+
role: element.getAttribute('role'),
|
|
390
|
+
disabledAttribute: element.hasAttribute('disabled'),
|
|
391
|
+
ariaDisabled: element.getAttribute('aria-disabled'),
|
|
392
|
+
ariaBusy: element.getAttribute('aria-busy'),
|
|
393
|
+
ariaInvalid: element.getAttribute('aria-invalid'),
|
|
394
|
+
ariaExpanded: element.getAttribute('aria-expanded'),
|
|
395
|
+
readOnly: (element as HTMLInputElement).readOnly,
|
|
396
|
+
focused: element === document.activeElement,
|
|
397
|
+
value: (element as HTMLInputElement).value,
|
|
398
|
+
alertText:
|
|
399
|
+
element
|
|
400
|
+
.closest('.astryx-date-input')
|
|
401
|
+
?.querySelector('[role="alert"]')
|
|
402
|
+
?.textContent?.trim() ?? null,
|
|
403
|
+
}));
|
|
404
|
+
const toggleState =
|
|
405
|
+
item.surface === 'popover'
|
|
406
|
+
? await toggle.evaluate(element => ({
|
|
407
|
+
disabledAttribute: element.hasAttribute('disabled'),
|
|
408
|
+
ariaDisabled: element.getAttribute('aria-disabled'),
|
|
409
|
+
tabIndex: (element as HTMLButtonElement).tabIndex,
|
|
410
|
+
}))
|
|
411
|
+
: null;
|
|
412
|
+
|
|
413
|
+
const busy = isBusyCase(item);
|
|
414
|
+
const disabled = isDisabledCase(item);
|
|
415
|
+
let semanticsPass = true;
|
|
416
|
+
if (semantics != null) {
|
|
417
|
+
semanticsPass =
|
|
418
|
+
semantics.disabledAttribute === false &&
|
|
419
|
+
(busy ? semantics.ariaBusy === 'true' : semantics.ariaBusy === null) &&
|
|
420
|
+
(busy || disabled
|
|
421
|
+
? semantics.ariaDisabled === 'true'
|
|
422
|
+
: semantics.ariaDisabled === null) &&
|
|
423
|
+
(item.surface === 'native'
|
|
424
|
+
? semantics.tag === 'input' && semantics.type === 'date'
|
|
425
|
+
: semantics.role === 'combobox') &&
|
|
426
|
+
(toggleState == null || toggleState.disabledAttribute === false);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Expectations authored from current authority before observation:
|
|
430
|
+
// spec:AST-085 FR1 (focusable, no native disabled), FR6 (disabled paint
|
|
431
|
+
// belongs to the disabled state; busy keeps its pending treatment; no
|
|
432
|
+
// hover affordance while disabled; keyboard focus indicator remains), and
|
|
433
|
+
// family:buttons FR4 (disabled and pending styling stay distinct).
|
|
434
|
+
let statePass = true;
|
|
435
|
+
if (busy) {
|
|
436
|
+
statePass = statePass && paint.opacity === '1' && paint.spinners >= 1;
|
|
437
|
+
}
|
|
438
|
+
if (disabled) {
|
|
439
|
+
statePass =
|
|
440
|
+
statePass &&
|
|
441
|
+
paint.opacity !== '1' &&
|
|
442
|
+
paint.themeDisabled === 'disabled';
|
|
443
|
+
}
|
|
444
|
+
if (item.state === 'hover') {
|
|
445
|
+
statePass = statePass && paint.hovered;
|
|
446
|
+
if (disabled || busy) {
|
|
447
|
+
statePass = statePass && paint.boxShadow === 'none';
|
|
448
|
+
}
|
|
449
|
+
} else if (item.state === 'focus-input') {
|
|
450
|
+
// Forced colors replaces box shadows; the receipt still records the
|
|
451
|
+
// forced border and outline colors for review.
|
|
452
|
+
statePass =
|
|
453
|
+
statePass &&
|
|
454
|
+
semantics?.focused === true &&
|
|
455
|
+
(item.forcedColors || paint.boxShadow !== 'none');
|
|
456
|
+
} else if (item.state === 'focus-toggle') {
|
|
457
|
+
statePass =
|
|
458
|
+
statePass &&
|
|
459
|
+
observed.activeElement?.tag === 'button' &&
|
|
460
|
+
observed.activeElement.focusVisible &&
|
|
461
|
+
observed.activeElement.outlineStyle !== 'none';
|
|
462
|
+
if (item.story === 'disabled-with-message') {
|
|
463
|
+
statePass =
|
|
464
|
+
statePass &&
|
|
465
|
+
observed.tooltips.includes('You need the Editor role to change this');
|
|
466
|
+
}
|
|
467
|
+
} else if (item.state === 'typed-invalid') {
|
|
468
|
+
statePass =
|
|
469
|
+
statePass &&
|
|
470
|
+
semantics?.value === 'not a date' &&
|
|
471
|
+
semantics.ariaInvalid === 'true' &&
|
|
472
|
+
(semantics.alertText ?? '').length > 0;
|
|
473
|
+
} else if (item.state === 'open') {
|
|
474
|
+
statePass =
|
|
475
|
+
statePass &&
|
|
476
|
+
semantics?.ariaExpanded === 'true' &&
|
|
477
|
+
observed.dialogs.length === 1;
|
|
478
|
+
} else if (item.state === 'sheet-open') {
|
|
479
|
+
statePass =
|
|
480
|
+
statePass &&
|
|
481
|
+
semantics?.ariaExpanded === 'true' &&
|
|
482
|
+
observed.dialogs.length >= 1;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
486
|
+
const image = `${item.key}.png`;
|
|
487
|
+
const receipt = {
|
|
488
|
+
schemaVersion: 2,
|
|
489
|
+
expected: {
|
|
490
|
+
build: checkoutSha,
|
|
491
|
+
storyId: storyId(item),
|
|
492
|
+
args: item.args,
|
|
493
|
+
surface: item.surface,
|
|
494
|
+
mode: item.mode,
|
|
495
|
+
direction: item.direction,
|
|
496
|
+
theme: item.theme,
|
|
497
|
+
state: item.state,
|
|
498
|
+
pointer: item.pointer,
|
|
499
|
+
forcedColors: item.forcedColors,
|
|
500
|
+
viewport: item.viewport,
|
|
501
|
+
label,
|
|
502
|
+
busy,
|
|
503
|
+
disabled,
|
|
504
|
+
},
|
|
505
|
+
observed: {
|
|
506
|
+
build: storybookSha,
|
|
507
|
+
...observed,
|
|
508
|
+
paint,
|
|
509
|
+
semantics,
|
|
510
|
+
toggleState,
|
|
511
|
+
pageErrors,
|
|
512
|
+
},
|
|
513
|
+
image: {
|
|
514
|
+
file: image,
|
|
515
|
+
sha256: createHash('sha256').update(png).digest('hex'),
|
|
516
|
+
width: png.readUInt32BE(16),
|
|
517
|
+
height: png.readUInt32BE(20),
|
|
518
|
+
},
|
|
519
|
+
checks: {semanticsPass, statePass},
|
|
520
|
+
passed:
|
|
521
|
+
storybookSha === checkoutSha &&
|
|
522
|
+
pageErrors.length === 0 &&
|
|
523
|
+
observed.theme === item.theme &&
|
|
524
|
+
observed.mode === item.mode &&
|
|
525
|
+
observed.direction === item.direction &&
|
|
526
|
+
observed.viewport.width === item.viewport.width &&
|
|
527
|
+
observed.viewport.height === item.viewport.height &&
|
|
528
|
+
observed.reducedMotion &&
|
|
529
|
+
observed.coarsePointer === (item.pointer === 'coarse') &&
|
|
530
|
+
observed.forcedColors === item.forcedColors &&
|
|
531
|
+
!observed.horizontalOverflow &&
|
|
532
|
+
observed.fonts === 'loaded' &&
|
|
533
|
+
semanticsPass &&
|
|
534
|
+
statePass,
|
|
535
|
+
};
|
|
536
|
+
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
537
|
+
fs.writeFileSync(
|
|
538
|
+
path.join(OUTPUT, `${item.key}.sensors.json`),
|
|
539
|
+
`${JSON.stringify(receipt, null, 2)}\n`,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
await context.close();
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
test('manifest is complete', async () => {
|
|
547
|
+
const receiptFiles = fs
|
|
548
|
+
.readdirSync(OUTPUT)
|
|
549
|
+
.filter(file => file.endsWith('.sensors.json'));
|
|
550
|
+
const receipts = receiptFiles.map(
|
|
551
|
+
(file): AuditReceipt =>
|
|
552
|
+
JSON.parse(
|
|
553
|
+
fs.readFileSync(path.join(OUTPUT, file), 'utf8'),
|
|
554
|
+
) as AuditReceipt,
|
|
555
|
+
);
|
|
556
|
+
const manifest = {
|
|
557
|
+
schemaVersion: 2,
|
|
558
|
+
component: 'core/DateInput',
|
|
559
|
+
build: checkoutSha,
|
|
560
|
+
frames: cases.map(item => ({
|
|
561
|
+
key: item.key,
|
|
562
|
+
image: `${item.key}.png`,
|
|
563
|
+
receipt: `${item.key}.sensors.json`,
|
|
564
|
+
})),
|
|
565
|
+
};
|
|
566
|
+
fs.writeFileSync(
|
|
567
|
+
path.join(OUTPUT, 'manifest.json'),
|
|
568
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
569
|
+
);
|
|
570
|
+
expect(receiptFiles).toHaveLength(cases.length);
|
|
571
|
+
expect(
|
|
572
|
+
receipts
|
|
573
|
+
.filter(receipt => !receipt.passed)
|
|
574
|
+
.map(receipt => receipt.expected),
|
|
575
|
+
).toEqual([]);
|
|
576
|
+
});
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* @file tokens.stylex.ts
|
|
5
5
|
* @input StyleX variable and constant definitions
|
|
6
6
|
* @output Exports DateInput layout constants and derived geometry
|
|
7
|
-
* @position
|
|
7
|
+
* @position Core touch-picker geometry consumed by the DateInput touch surface
|
|
8
|
+
* (TouchDateField, MonthScroller, Wheel, MonthYearWheels) and by the
|
|
9
|
+
* DateTimeInput and TimeInput touch fields
|
|
8
10
|
*
|
|
9
11
|
* The picker's whole geometry derives from two numbers, which is what makes
|
|
10
12
|
* the fixed height and the scroll snapping exact:
|
|
@@ -72,6 +72,7 @@ import {Item} from '../Item';
|
|
|
72
72
|
import {useLayer} from '../Layer/useLayer';
|
|
73
73
|
import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
74
74
|
import {useListFocus} from '../hooks/useListFocus';
|
|
75
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
75
76
|
import {useMenuHover} from '../hooks/useMenuHover';
|
|
76
77
|
import {
|
|
77
78
|
isMenuPressActivation,
|
|
@@ -682,6 +683,14 @@ export function DropdownMenuSubMenu(
|
|
|
682
683
|
// closed from elsewhere is cancelled instead of firing into the hidden
|
|
683
684
|
// menu (#6893).
|
|
684
685
|
const isRootMenuOpen = useDropdownMenuRootOpen();
|
|
686
|
+
// The loading Spinner on the trigger is decorative; the submenu announces
|
|
687
|
+
// its busy start instead (spec:AST-086 FR8), once its level is shown: a
|
|
688
|
+
// closed menu stays mounted but hidden, and announces nothing.
|
|
689
|
+
useBusyAnnouncement(
|
|
690
|
+
hasSpinner && isRootMenuOpen,
|
|
691
|
+
t('@astryx.spinner.loading'),
|
|
692
|
+
triggerRef,
|
|
693
|
+
);
|
|
685
694
|
const {triggerProps, contentProps, confirmHoverOpen} =
|
|
686
695
|
useMenuHover<HTMLDivElement>({
|
|
687
696
|
show: showLayer,
|
|
@@ -1033,7 +1042,7 @@ export function DropdownMenuSubMenu(
|
|
|
1033
1042
|
|
|
1034
1043
|
const endAffordance = hasSpinner ? (
|
|
1035
1044
|
<span {...stylex.props(triggerStyles.caret)}>
|
|
1036
|
-
<Spinner size="sm" />
|
|
1045
|
+
<Spinner size="sm" aria-hidden="true" />
|
|
1037
1046
|
</span>
|
|
1038
1047
|
) : (
|
|
1039
1048
|
<span {...stylex.props(triggerStyles.caret)}>
|
|
@@ -47,6 +47,7 @@ import {Icon} from '../Icon';
|
|
|
47
47
|
import {Spinner} from '../Spinner';
|
|
48
48
|
import {VisuallyHidden} from '../VisuallyHidden';
|
|
49
49
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
50
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
50
51
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
51
52
|
import {useClickableContainer} from '../hooks/useClickableContainer';
|
|
52
53
|
import {useTooltip} from '../Tooltip';
|
|
@@ -490,6 +491,20 @@ export function FileInput({
|
|
|
490
491
|
const [, startTransition] = useTransition();
|
|
491
492
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
492
493
|
const isBusy = isLoading || optimisticValue !== value;
|
|
494
|
+
// A pick is one user action, already announced as "file selected"; a busy
|
|
495
|
+
// period it starts (changeAction, or a caller setting isLoading from
|
|
496
|
+
// onChange) adds no second message (spec:AST-086 FR6). The mark lasts until
|
|
497
|
+
// the field is idle again. Other busy starts announce; the Spinner stays
|
|
498
|
+
// decorative (FR8).
|
|
499
|
+
const [isPickAnnounced, setIsPickAnnounced] = useState(false);
|
|
500
|
+
if (isPickAnnounced && !isBusy) {
|
|
501
|
+
setIsPickAnnounced(false);
|
|
502
|
+
}
|
|
503
|
+
useBusyAnnouncement(
|
|
504
|
+
isBusy && !isPickAnnounced,
|
|
505
|
+
t('@astryx.spinner.loading'),
|
|
506
|
+
containerRef,
|
|
507
|
+
);
|
|
493
508
|
|
|
494
509
|
// Announce successful file selection to screen readers via a persistent
|
|
495
510
|
// live region (forms-17). Validation errors are announced (assertively) by
|
|
@@ -580,6 +595,7 @@ export function FileInput({
|
|
|
580
595
|
// status mounts, so only announce the attach here (do not
|
|
581
596
|
// double-announce errors).
|
|
582
597
|
if (errors.length === 0) {
|
|
598
|
+
setIsPickAnnounced(true);
|
|
583
599
|
announce(
|
|
584
600
|
valid.length === 1
|
|
585
601
|
? t('@astryx.fileInput.fileSelected', {fileName: valid[0].name})
|
|
@@ -741,7 +757,7 @@ export function FileInput({
|
|
|
741
757
|
|
|
742
758
|
const renderDropzoneContent = () => {
|
|
743
759
|
if (isBusy) {
|
|
744
|
-
return <Spinner size="md" />;
|
|
760
|
+
return <Spinner size="md" aria-hidden="true" />;
|
|
745
761
|
}
|
|
746
762
|
if (hasFiles) {
|
|
747
763
|
return (
|
|
@@ -772,7 +788,7 @@ export function FileInput({
|
|
|
772
788
|
<span {...stylex.props(styles.fileNameText)}>
|
|
773
789
|
{fileNames ?? displayPlaceholder}
|
|
774
790
|
</span>
|
|
775
|
-
<Spinner size="sm" />
|
|
791
|
+
<Spinner size="sm" aria-hidden="true" />
|
|
776
792
|
</>
|
|
777
793
|
);
|
|
778
794
|
}
|