@astryxdesign/core 0.7.3 → 0.7.4-canary.3f6ea7d
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,107 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
/** @vitest-environment jsdom */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file ProgressBar.a11y.test.tsx
|
|
6
|
+
* @input PROGRESSBAR_PATTERN bound to ProgressBar's representative states
|
|
7
|
+
* @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
|
|
8
|
+
* @position Binding test for the ProgressBar binding (the Chromium spec covers the rest)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {cleanup, render, screen} from '@testing-library/react';
|
|
12
|
+
import {describe, expect, it} from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
PROGRESSBAR_PATTERN,
|
|
15
|
+
checkAccessibilitySpec,
|
|
16
|
+
createJsdomHarness,
|
|
17
|
+
expectAccessibilitySpec,
|
|
18
|
+
summarize,
|
|
19
|
+
type BindingResult,
|
|
20
|
+
} from '@astryxdesign/a11y-spec';
|
|
21
|
+
import {PROGRESSBAR_KNOWN_FAILURES} from './ProgressBar.a11y.known-failures';
|
|
22
|
+
import {renderProgressBarState} from './ProgressBar.a11y.renders';
|
|
23
|
+
import {
|
|
24
|
+
PROGRESSBAR_BINDING_STATES,
|
|
25
|
+
PROGRESSBAR_EXCLUSIONS,
|
|
26
|
+
type ProgressBarBindingState,
|
|
27
|
+
} from './ProgressBar.a11y.states';
|
|
28
|
+
|
|
29
|
+
function progressbar(state: ProgressBarBindingState): HTMLElement {
|
|
30
|
+
return screen.getByRole('progressbar', {name: state.facts.name});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function checkState(
|
|
34
|
+
state: ProgressBarBindingState,
|
|
35
|
+
): Promise<BindingResult> {
|
|
36
|
+
return checkAccessibilitySpec({
|
|
37
|
+
spec: PROGRESSBAR_PATTERN,
|
|
38
|
+
binding: 'ProgressBar',
|
|
39
|
+
state: state.id,
|
|
40
|
+
facts: state.facts,
|
|
41
|
+
knownFailures: PROGRESSBAR_KNOWN_FAILURES,
|
|
42
|
+
mount: async () => {
|
|
43
|
+
render(renderProgressBarState(state));
|
|
44
|
+
return createJsdomHarness({subject: progressbar(state), related: {}});
|
|
45
|
+
},
|
|
46
|
+
unmount: cleanup,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('ProgressBar — progressbar pattern, jsdom lane', () => {
|
|
51
|
+
it.each(
|
|
52
|
+
PROGRESSBAR_BINDING_STATES.map(
|
|
53
|
+
state => [state.id, state.summary, state] as const,
|
|
54
|
+
),
|
|
55
|
+
)('%s — %s', async (_id, _summary, state) => {
|
|
56
|
+
await expectAccessibilitySpec({
|
|
57
|
+
spec: PROGRESSBAR_PATTERN,
|
|
58
|
+
binding: 'ProgressBar',
|
|
59
|
+
state: state.id,
|
|
60
|
+
facts: state.facts,
|
|
61
|
+
knownFailures: PROGRESSBAR_KNOWN_FAILURES,
|
|
62
|
+
render: () => {
|
|
63
|
+
render(renderProgressBarState(state));
|
|
64
|
+
},
|
|
65
|
+
subject: () => progressbar(state),
|
|
66
|
+
related: () => ({}),
|
|
67
|
+
cleanup,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
|
|
72
|
+
const results: BindingResult[] = [];
|
|
73
|
+
for (const state of PROGRESSBAR_BINDING_STATES) {
|
|
74
|
+
results.push(await checkState(state));
|
|
75
|
+
}
|
|
76
|
+
const report = summarize(PROGRESSBAR_PATTERN, results);
|
|
77
|
+
expect(report.counts.pass).toBeGreaterThan(0);
|
|
78
|
+
expect(report.unrunLayers).toEqual(['accessibility-tree']);
|
|
79
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('ProgressBar binding inventory', () => {
|
|
84
|
+
it('gives every representative state a distinct checked-in story', () => {
|
|
85
|
+
const stories = PROGRESSBAR_BINDING_STATES.map(state => state.storyId);
|
|
86
|
+
expect(new Set(stories).size).toBe(stories.length);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps clamping and mark-trigger ownership explicit', () => {
|
|
90
|
+
expect(PROGRESSBAR_EXCLUSIONS.map(exclusion => exclusion.id)).toEqual([
|
|
91
|
+
'value-clamping',
|
|
92
|
+
'mark-trigger',
|
|
93
|
+
]);
|
|
94
|
+
for (const exclusion of PROGRESSBAR_EXCLUSIONS) {
|
|
95
|
+
expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
|
|
96
|
+
expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('renders exactly one named progress bar for every state', () => {
|
|
101
|
+
for (const state of PROGRESSBAR_BINDING_STATES) {
|
|
102
|
+
render(renderProgressBarState(state));
|
|
103
|
+
expect(progressbar(state), state.id).toBeTruthy();
|
|
104
|
+
cleanup();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -253,6 +253,14 @@ function mockSelectorRects({
|
|
|
253
253
|
};
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/** A busy control's Spinner is decorative and silent (spec:AST-086 FR8). */
|
|
257
|
+
function expectSilentBusySpinner() {
|
|
258
|
+
const spinner = document.querySelector('.astryx-spinner');
|
|
259
|
+
expect(spinner).not.toBeNull();
|
|
260
|
+
expect(spinner).toHaveAttribute('aria-hidden', 'true');
|
|
261
|
+
expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
|
|
262
|
+
}
|
|
263
|
+
|
|
256
264
|
describe('Selector', () => {
|
|
257
265
|
it('uses a bottom sheet and closes after a selection when requested', async () => {
|
|
258
266
|
const user = userEvent.setup();
|
|
@@ -1388,7 +1396,7 @@ describe('Selector', () => {
|
|
|
1388
1396
|
<Selector label="Fruit" options={[]} onChange={() => {}} isLoading />,
|
|
1389
1397
|
);
|
|
1390
1398
|
const trigger = screen.getByRole('combobox', {name: 'Fruit'});
|
|
1391
|
-
|
|
1399
|
+
expectSilentBusySpinner();
|
|
1392
1400
|
|
|
1393
1401
|
await user.click(trigger);
|
|
1394
1402
|
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
@@ -1404,7 +1412,10 @@ describe('Selector', () => {
|
|
|
1404
1412
|
// component cannot make; the trigger's spinner carries the state.
|
|
1405
1413
|
const listbox = screen.getByRole('listbox', h);
|
|
1406
1414
|
expect(within(listbox).queryByText('No options')).not.toBeInTheDocument();
|
|
1407
|
-
|
|
1415
|
+
// Only the busy start is spoken (spec:AST-086 FR8), never an empty or
|
|
1416
|
+
// no-results message the panel is suppressing.
|
|
1417
|
+
// (The channel clears its text on a timer, so it may already be empty.)
|
|
1418
|
+
expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
|
|
1408
1419
|
});
|
|
1409
1420
|
|
|
1410
1421
|
it('announces nothing while isLoading, matching the silent panel', async () => {
|
|
@@ -1437,7 +1448,10 @@ describe('Selector', () => {
|
|
|
1437
1448
|
expect(
|
|
1438
1449
|
within(listbox).queryByText('Nothing like that here'),
|
|
1439
1450
|
).not.toBeInTheDocument();
|
|
1440
|
-
|
|
1451
|
+
// Only the busy start is spoken (spec:AST-086 FR8), never an empty or
|
|
1452
|
+
// no-results message the panel is suppressing.
|
|
1453
|
+
// (The channel clears its text on a timer, so it may already be empty.)
|
|
1454
|
+
expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
|
|
1441
1455
|
});
|
|
1442
1456
|
|
|
1443
1457
|
it('announces the seeded query from type-to-open', async () => {
|
|
@@ -1490,7 +1504,10 @@ describe('Selector', () => {
|
|
|
1490
1504
|
async () =>
|
|
1491
1505
|
void (await new Promise(resolve => requestAnimationFrame(resolve))),
|
|
1492
1506
|
);
|
|
1493
|
-
|
|
1507
|
+
// Only the busy start is spoken (spec:AST-086 FR8), never an empty or
|
|
1508
|
+
// no-results message the panel is suppressing.
|
|
1509
|
+
// (The channel clears its text on a timer, so it may already be empty.)
|
|
1510
|
+
expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
|
|
1494
1511
|
|
|
1495
1512
|
// The fetch lands empty while the panel is open. The message appears, so
|
|
1496
1513
|
// the region owes the same words — an open-only announcement misses this.
|
|
@@ -2896,7 +2913,7 @@ describe('Selector', () => {
|
|
|
2896
2913
|
|
|
2897
2914
|
const trigger = screen.getByRole('combobox', {name: 'Fruit'});
|
|
2898
2915
|
expect(trigger).toHaveAttribute('aria-busy', 'true');
|
|
2899
|
-
|
|
2916
|
+
expectSilentBusySpinner();
|
|
2900
2917
|
|
|
2901
2918
|
await user.click(trigger);
|
|
2902
2919
|
expect(screen.queryByRole('listbox', h)).not.toBeInTheDocument();
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
} from '../Field/fieldParts';
|
|
88
88
|
import {composeEventHandlers} from '../utils/composeEventHandlers';
|
|
89
89
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
90
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
90
91
|
import type {SizeValue} from '../utils/types';
|
|
91
92
|
import {themeProps} from '../utils/themeProps';
|
|
92
93
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
@@ -1047,6 +1048,9 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1047
1048
|
const [, startTransition] = useTransition();
|
|
1048
1049
|
const [optimisticValue, setOptimisticValue] = useOptimistic(normalizedValue);
|
|
1049
1050
|
const isBusy = isLoading || optimisticValue !== normalizedValue;
|
|
1051
|
+
// The busy Spinner is decorative; this control announces its busy start
|
|
1052
|
+
// (spec:AST-086 FR8).
|
|
1053
|
+
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), triggerRef);
|
|
1050
1054
|
const announce = useAnnounce();
|
|
1051
1055
|
|
|
1052
1056
|
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
|
|
@@ -2038,7 +2042,7 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
2038
2042
|
{htmlName != null && !isDisabled && (
|
|
2039
2043
|
<input type="hidden" name={htmlName} value={value ?? ''} />
|
|
2040
2044
|
)}
|
|
2041
|
-
{isBusy && <Spinner size="sm" />}
|
|
2045
|
+
{isBusy && <Spinner size="sm" aria-hidden="true" />}
|
|
2042
2046
|
{hasClear && value != null && !isDisabled && !isEffectivelyReadOnly && (
|
|
2043
2047
|
<InternalInputClearButton
|
|
2044
2048
|
{...keepOpenProps}
|
|
@@ -55,6 +55,14 @@ beforeEach(() => {
|
|
|
55
55
|
// accessibility tree. Use hidden: true to find it.
|
|
56
56
|
const h = {hidden: true} as const;
|
|
57
57
|
|
|
58
|
+
/** A busy control's Spinner is decorative and silent (spec:AST-086 FR8). */
|
|
59
|
+
function expectSilentBusySpinner() {
|
|
60
|
+
const spinner = document.querySelector('.astryx-spinner');
|
|
61
|
+
expect(spinner).not.toBeNull();
|
|
62
|
+
expect(spinner).toHaveAttribute('aria-hidden', 'true');
|
|
63
|
+
expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
describe('TextArea', () => {
|
|
59
67
|
it('renders with label', () => {
|
|
60
68
|
render(<TextArea label="Description" value="" onChange={() => {}} />);
|
|
@@ -348,7 +356,7 @@ describe('TextArea', () => {
|
|
|
348
356
|
);
|
|
349
357
|
// Matches the other inputs: spinner (role="status") and the status icon
|
|
350
358
|
// render side by side in the end slot, not mutually exclusively.
|
|
351
|
-
|
|
359
|
+
expectSilentBusySpinner();
|
|
352
360
|
// Status icon svg is also present alongside the spinner.
|
|
353
361
|
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0);
|
|
354
362
|
});
|
|
@@ -63,6 +63,7 @@ import {characterCount} from '../utils/characters';
|
|
|
63
63
|
import {useTranslator} from '../i18n';
|
|
64
64
|
|
|
65
65
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
66
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
66
67
|
const COUNTER_WARNING_THRESHOLD = 0.8;
|
|
67
68
|
|
|
68
69
|
const styles = stylex.create({
|
|
@@ -438,6 +439,9 @@ export function TextArea({
|
|
|
438
439
|
const [, startTransition] = useTransition();
|
|
439
440
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
440
441
|
const isBusy = isLoading || optimisticValue !== value;
|
|
442
|
+
// The busy Spinner is decorative; this control announces its busy start
|
|
443
|
+
// (spec:AST-086 FR8).
|
|
444
|
+
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
441
445
|
|
|
442
446
|
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
|
|
443
447
|
// tooltip listeners attach to the textarea container (which already exists)
|
|
@@ -660,7 +664,7 @@ export function TextArea({
|
|
|
660
664
|
/>
|
|
661
665
|
{(isBusy || statusIcon) && (
|
|
662
666
|
<span {...stylex.props(styles.endSlot)}>
|
|
663
|
-
{isBusy && <Spinner size="sm" />}
|
|
667
|
+
{isBusy && <Spinner size="sm" aria-hidden="true" />}
|
|
664
668
|
{statusIcon}
|
|
665
669
|
</span>
|
|
666
670
|
)}
|
|
@@ -118,6 +118,7 @@ import {themeProps} from '../utils/themeProps';
|
|
|
118
118
|
import {useTranslator} from '../i18n';
|
|
119
119
|
|
|
120
120
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
121
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
121
122
|
export type TextInputType = 'text' | 'password' | 'email';
|
|
122
123
|
|
|
123
124
|
export interface TextInputProps extends Omit<
|
|
@@ -344,6 +345,9 @@ export function TextInput({
|
|
|
344
345
|
const [, startTransition] = useTransition();
|
|
345
346
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
346
347
|
const isBusy = isLoading || optimisticValue !== value;
|
|
348
|
+
// The busy Spinner is decorative; this control announces its busy start
|
|
349
|
+
// (spec:AST-086 FR8).
|
|
350
|
+
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
347
351
|
|
|
348
352
|
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
|
|
349
353
|
// tooltip listeners attach to the input container (which already exists) and
|
|
@@ -506,7 +510,7 @@ export function TextInput({
|
|
|
506
510
|
onClick={handleClear}
|
|
507
511
|
/>
|
|
508
512
|
)}
|
|
509
|
-
{isBusy && <Spinner size="sm" />}
|
|
513
|
+
{isBusy && <Spinner size="sm" aria-hidden="true" />}
|
|
510
514
|
{statusIcon}
|
|
511
515
|
</div>
|
|
512
516
|
);
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
import type {SizeValue} from '../utils/types';
|
|
71
71
|
import {useSize} from '../SizeContext/SizeContext';
|
|
72
72
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
73
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
73
74
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
74
75
|
import {useMediaQuery} from '../hooks/useMediaQuery';
|
|
75
76
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
@@ -475,6 +476,9 @@ function TimeField({
|
|
|
475
476
|
const [, startTransition] = useTransition();
|
|
476
477
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
477
478
|
const isBusy = isLoading || optimisticValue !== value;
|
|
479
|
+
// The busy Spinner is decorative; this control announces its busy start
|
|
480
|
+
// (spec:AST-086 FR8).
|
|
481
|
+
useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), containerRef);
|
|
478
482
|
|
|
479
483
|
// In grouped mode the status message renders as a visually-hidden node that
|
|
480
484
|
// exists only for aria-describedby. Announce it through the persistent
|
|
@@ -865,7 +869,7 @@ function TimeField({
|
|
|
865
869
|
</VisuallyHidden>
|
|
866
870
|
</>
|
|
867
871
|
)}
|
|
868
|
-
{isBusy && <Spinner size="sm" />}
|
|
872
|
+
{isBusy && <Spinner size="sm" aria-hidden="true" />}
|
|
869
873
|
{hasClear && optimisticValue && !isDisabled && (
|
|
870
874
|
<InputClearButton
|
|
871
875
|
label={t('@astryx.timeInput.clearLabel', {label})}
|
|
@@ -42,6 +42,7 @@ import {useFieldInputStyling} from '../Field/fieldParts';
|
|
|
42
42
|
import {joinAriaIDs} from '../utils/inputAria';
|
|
43
43
|
import {groupStyles} from '../InputGroup/groupStyles';
|
|
44
44
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
45
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
45
46
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
46
47
|
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
47
48
|
import {Icon} from '../Icon';
|
|
@@ -254,6 +255,15 @@ export function TouchTimeField({
|
|
|
254
255
|
const [, startTransition] = useTransition();
|
|
255
256
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
256
257
|
const isBusy = isLoading || optimisticValue !== value;
|
|
258
|
+
const sheetBodyRef = useRef<HTMLDivElement | null>(null);
|
|
259
|
+
// The busy Spinner is decorative; this control announces its busy start
|
|
260
|
+
// (spec:AST-086 FR8). A change made in the open modal sheet starts it while
|
|
261
|
+
// the sheet is open, so it is announced from inside the sheet.
|
|
262
|
+
useBusyAnnouncement(
|
|
263
|
+
isBusy,
|
|
264
|
+
t('@astryx.spinner.loading'),
|
|
265
|
+
isSheetOpen ? sheetBodyRef : inputRef,
|
|
266
|
+
);
|
|
257
267
|
const isEffectivelyDisabled = isDisabled || isLoading;
|
|
258
268
|
|
|
259
269
|
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
@@ -516,7 +526,7 @@ export function TouchTimeField({
|
|
|
516
526
|
}
|
|
517
527
|
{...stylex.props(styles.input)}
|
|
518
528
|
/>
|
|
519
|
-
{isBusy && <Spinner size="sm" />}
|
|
529
|
+
{isBusy && <Spinner size="sm" aria-hidden="true" />}
|
|
520
530
|
{hasClear && optimisticValue && !isEffectivelyDisabled && (
|
|
521
531
|
<InputClearButton
|
|
522
532
|
label={t('@astryx.timeInput.clearLabel', {label})}
|
|
@@ -531,7 +541,7 @@ export function TouchTimeField({
|
|
|
531
541
|
height="hug"
|
|
532
542
|
// sheetBody owns the inset, including the band under the grab handle.
|
|
533
543
|
padding={0}>
|
|
534
|
-
<div {...stylex.props(styles.sheetBody)}>
|
|
544
|
+
<div ref={sheetBodyRef} {...stylex.props(styles.sheetBody)}>
|
|
535
545
|
<div {...stylex.props(styles.wheels)}>
|
|
536
546
|
<Wheel
|
|
537
547
|
label={t('@astryx.dateTimeInput.hourWheel')}
|
|
@@ -122,6 +122,17 @@ const userSource: SearchSource = {
|
|
|
122
122
|
bootstrap: () => users.slice(0, 3),
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The search Spinner: named but not live (family:input-fields FR7,
|
|
127
|
+
* spec:AST-086 FR8), so it is found as an image named "Loading".
|
|
128
|
+
*/
|
|
129
|
+
function queryBusySpinner(): Element | null {
|
|
130
|
+
return screen.queryByRole('img', {name: 'Loading'});
|
|
131
|
+
}
|
|
132
|
+
function getBusySpinner(): Element {
|
|
133
|
+
return screen.getByRole('img', {name: 'Loading'});
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
describe('Tokenizer minQueryLength', () => {
|
|
126
137
|
it('forwards the threshold to the typeahead engine', async () => {
|
|
127
138
|
const search = vi.fn((query: string) =>
|
|
@@ -1675,7 +1686,7 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1675
1686
|
fireEvent.change(screen.getByRole('combobox'), {target: {value: 'Al'}});
|
|
1676
1687
|
});
|
|
1677
1688
|
|
|
1678
|
-
expect(
|
|
1689
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1679
1690
|
expect(reserveOf(container)).toBe(
|
|
1680
1691
|
`calc(var(--spacing-2) + ${LANE_LOCAL_WIDTH}px)`,
|
|
1681
1692
|
);
|
|
@@ -1774,7 +1785,7 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1774
1785
|
// The indicator is up, so the transition really did happen. Scoped by
|
|
1775
1786
|
// name: the announcer for result counts is a role="status" live region
|
|
1776
1787
|
// too, and it is not the indicator.
|
|
1777
|
-
expect(
|
|
1788
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1778
1789
|
// ...and it cost no token a render.
|
|
1779
1790
|
expect(renderedTokens).toEqual([]);
|
|
1780
1791
|
|
|
@@ -1783,9 +1794,7 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1783
1794
|
await Promise.resolve();
|
|
1784
1795
|
});
|
|
1785
1796
|
await waitFor(() => {
|
|
1786
|
-
expect(
|
|
1787
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1788
|
-
).not.toBeInTheDocument();
|
|
1797
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1789
1798
|
});
|
|
1790
1799
|
expect(renderedTokens).toEqual([]);
|
|
1791
1800
|
});
|
|
@@ -1823,24 +1832,20 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1823
1832
|
|
|
1824
1833
|
fireEvent.focus(input);
|
|
1825
1834
|
await waitFor(() => {
|
|
1826
|
-
expect(
|
|
1835
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1827
1836
|
});
|
|
1828
1837
|
|
|
1829
1838
|
fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
|
|
1830
1839
|
|
|
1831
1840
|
await waitFor(() => {
|
|
1832
|
-
expect(
|
|
1833
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1834
|
-
).not.toBeInTheDocument();
|
|
1841
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1835
1842
|
});
|
|
1836
1843
|
|
|
1837
1844
|
await act(async () => {
|
|
1838
1845
|
settle([]);
|
|
1839
1846
|
await Promise.resolve();
|
|
1840
1847
|
});
|
|
1841
|
-
expect(
|
|
1842
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1843
|
-
).not.toBeInTheDocument();
|
|
1848
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1844
1849
|
});
|
|
1845
1850
|
|
|
1846
1851
|
it('shares one observer across every field on the page', () => {
|
|
@@ -23,6 +23,7 @@ import React, {
|
|
|
23
23
|
useRef,
|
|
24
24
|
useState,
|
|
25
25
|
type ReactNode,
|
|
26
|
+
type RefObject,
|
|
26
27
|
} from 'react';
|
|
27
28
|
import * as stylex from '@stylexjs/stylex';
|
|
28
29
|
import {
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
useFieldInputStyling,
|
|
38
39
|
} from '../Field/fieldParts';
|
|
39
40
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
41
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
40
42
|
import type {SizeValue} from '../utils/types';
|
|
41
43
|
import {BaseTypeahead} from '../Typeahead/BaseTypeahead';
|
|
42
44
|
import {useSize} from '../SizeContext/SizeContext';
|
|
@@ -521,6 +523,7 @@ function EndLane({
|
|
|
521
523
|
laneRef,
|
|
522
524
|
laneXStyle,
|
|
523
525
|
loadingLabel,
|
|
526
|
+
anchorRef,
|
|
524
527
|
hasStaticContent,
|
|
525
528
|
children,
|
|
526
529
|
}: {
|
|
@@ -528,16 +531,21 @@ function EndLane({
|
|
|
528
531
|
laneRef: (node: HTMLElement | null) => void;
|
|
529
532
|
laneXStyle: stylex.StyleXStyles[];
|
|
530
533
|
loadingLabel: string;
|
|
534
|
+
anchorRef: RefObject<Element | null>;
|
|
531
535
|
hasStaticContent: boolean;
|
|
532
536
|
children: ReactNode;
|
|
533
537
|
}) {
|
|
534
538
|
const isBusy = useIsBusy(lane);
|
|
539
|
+
// The search Spinner stays named (family:input-fields FR7) but is not a
|
|
540
|
+
// live region; the field announces each search's busy start instead
|
|
541
|
+
// (spec:AST-086 FR8).
|
|
542
|
+
useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
|
|
535
543
|
if (!isBusy && !hasStaticContent) {
|
|
536
544
|
return null;
|
|
537
545
|
}
|
|
538
546
|
return (
|
|
539
547
|
<div ref={laneRef} {...stylex.props(laneXStyle)}>
|
|
540
|
-
{isBusy && <Spinner size="sm" aria-label={loadingLabel} />}
|
|
548
|
+
{isBusy && <Spinner size="sm" aria-label={loadingLabel} role="img" />}
|
|
541
549
|
{children}
|
|
542
550
|
</div>
|
|
543
551
|
);
|
|
@@ -1084,6 +1092,7 @@ export function Tokenizer<T extends SearchableItem>({
|
|
|
1084
1092
|
laneRef={laneRef}
|
|
1085
1093
|
laneXStyle={[styles.endSection, endSectionSizeStyles[size]]}
|
|
1086
1094
|
loadingLabel={t('@astryx.typeahead.loading')}
|
|
1095
|
+
anchorRef={wrapperRef}
|
|
1087
1096
|
hasStaticContent={hasStaticEndLane}>
|
|
1088
1097
|
{endContent}
|
|
1089
1098
|
{hasClearButton && (
|
|
@@ -62,6 +62,7 @@ import {themeProps} from '../utils/themeProps';
|
|
|
62
62
|
import {useTranslator} from '../i18n';
|
|
63
63
|
|
|
64
64
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
65
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
65
66
|
import {layerViewportInset} from '../Layer/layerViewportInset.stylex';
|
|
66
67
|
import {clampInlineSize} from '../Layer/clampInlineSize';
|
|
67
68
|
// =============================================================================
|
|
@@ -561,6 +562,14 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
561
562
|
// A wrapper that owns the inline-end lane subscribes through context; see
|
|
562
563
|
// busyIndicatorLane.tsx for why this is not a prop.
|
|
563
564
|
const busyLane = useBusyIndicatorLane();
|
|
565
|
+
// Without a wrapper that took the indicator over, this input announces each
|
|
566
|
+
// search's busy start (spec:AST-086 FR8); its Spinner stays named but is not
|
|
567
|
+
// a live region (family:input-fields FR7).
|
|
568
|
+
useBusyAnnouncement(
|
|
569
|
+
isLoading && busyLane == null,
|
|
570
|
+
t('@astryx.typeahead.loading'),
|
|
571
|
+
inputRef,
|
|
572
|
+
);
|
|
564
573
|
const onLoadingChangeRef = useRef(busyLane?.onBusyChange);
|
|
565
574
|
useIsomorphicLayoutEffect(() => {
|
|
566
575
|
onLoadingChangeRef.current = busyLane?.onBusyChange;
|
|
@@ -1218,7 +1227,11 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
1218
1227
|
/>
|
|
1219
1228
|
{isLoading && busyLane == null && (
|
|
1220
1229
|
<span {...stylex.props(styles.loadingStatus)}>
|
|
1221
|
-
<Spinner
|
|
1230
|
+
<Spinner
|
|
1231
|
+
size="sm"
|
|
1232
|
+
aria-label={t('@astryx.typeahead.loading')}
|
|
1233
|
+
role="img"
|
|
1234
|
+
/>
|
|
1222
1235
|
</span>
|
|
1223
1236
|
)}
|
|
1224
1237
|
{popover.render(
|