@astryxdesign/core 0.7.3 → 0.7.4-canary.b25f95e
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/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/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/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/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/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 +369 -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/i18n/generated-locales/en.generated.ts +1 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
|
@@ -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
|
);
|
|
@@ -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(
|
|
@@ -99,6 +99,17 @@ const fruitSource: SearchSource = {
|
|
|
99
99
|
bootstrap: () => fruits.slice(0, 3),
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The search Spinner: named but not live (family:input-fields FR7,
|
|
104
|
+
* spec:AST-086 FR8), so it is found as an image named "Loading".
|
|
105
|
+
*/
|
|
106
|
+
function queryBusySpinner(): Element | null {
|
|
107
|
+
return screen.queryByRole('img', {name: 'Loading'});
|
|
108
|
+
}
|
|
109
|
+
function getBusySpinner(): Element {
|
|
110
|
+
return screen.getByRole('img', {name: 'Loading'});
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
describe('BaseTypeahead', () => {
|
|
103
114
|
it('renders input with combobox role', () => {
|
|
104
115
|
render(
|
|
@@ -929,14 +940,12 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
929
940
|
|
|
930
941
|
fireEvent.change(input, {target: {value: 'a'}});
|
|
931
942
|
await waitFor(() => {
|
|
932
|
-
expect(
|
|
943
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
933
944
|
});
|
|
934
945
|
|
|
935
946
|
fireEvent.change(input, {target: {value: ''}});
|
|
936
947
|
await waitFor(() => {
|
|
937
|
-
expect(
|
|
938
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
939
|
-
).not.toBeInTheDocument();
|
|
948
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
940
949
|
});
|
|
941
950
|
|
|
942
951
|
await act(async () => {
|
|
@@ -966,16 +975,14 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
966
975
|
|
|
967
976
|
fireEvent.focus(input);
|
|
968
977
|
await waitFor(() => {
|
|
969
|
-
expect(
|
|
978
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
970
979
|
});
|
|
971
980
|
|
|
972
981
|
await act(async () => {
|
|
973
982
|
settle([]);
|
|
974
983
|
await Promise.resolve();
|
|
975
984
|
});
|
|
976
|
-
expect(
|
|
977
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
978
|
-
).not.toBeInTheDocument();
|
|
985
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
979
986
|
});
|
|
980
987
|
|
|
981
988
|
it('clears the loading state when the search source is replaced during an in-flight load', async () => {
|
|
@@ -1004,7 +1011,7 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
1004
1011
|
|
|
1005
1012
|
fireEvent.focus(input);
|
|
1006
1013
|
await waitFor(() => {
|
|
1007
|
-
expect(
|
|
1014
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1008
1015
|
});
|
|
1009
1016
|
|
|
1010
1017
|
rerender(
|
|
@@ -1017,18 +1024,14 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
1017
1024
|
);
|
|
1018
1025
|
|
|
1019
1026
|
await waitFor(() => {
|
|
1020
|
-
expect(
|
|
1021
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1022
|
-
).not.toBeInTheDocument();
|
|
1027
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1023
1028
|
});
|
|
1024
1029
|
|
|
1025
1030
|
await act(async () => {
|
|
1026
1031
|
settle([]);
|
|
1027
1032
|
await Promise.resolve();
|
|
1028
1033
|
});
|
|
1029
|
-
expect(
|
|
1030
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1031
|
-
).not.toBeInTheDocument();
|
|
1034
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1032
1035
|
});
|
|
1033
1036
|
|
|
1034
1037
|
it('keeps the loading state under StrictMode for a pending autofocus bootstrap', async () => {
|
|
@@ -1052,15 +1055,13 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
1052
1055
|
</StrictMode>,
|
|
1053
1056
|
);
|
|
1054
1057
|
|
|
1055
|
-
expect(
|
|
1058
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1056
1059
|
|
|
1057
1060
|
await act(async () => {
|
|
1058
1061
|
settle([]);
|
|
1059
1062
|
await Promise.resolve();
|
|
1060
1063
|
});
|
|
1061
|
-
expect(
|
|
1062
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1063
|
-
).not.toBeInTheDocument();
|
|
1064
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1064
1065
|
});
|
|
1065
1066
|
|
|
1066
1067
|
let settlePendingTransitionA: (items: SearchableItem[]) => void = () => {};
|
|
@@ -1125,7 +1126,7 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
1125
1126
|
await waitFor(() => {
|
|
1126
1127
|
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1127
1128
|
});
|
|
1128
|
-
expect(
|
|
1129
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1129
1130
|
|
|
1130
1131
|
// Create the pending promise for suspension
|
|
1131
1132
|
isSuspended = true;
|
|
@@ -1160,9 +1161,7 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
1160
1161
|
|
|
1161
1162
|
// The combobox must not keep aria-busy and the spinner must be gone
|
|
1162
1163
|
expect(input).not.toHaveAttribute('aria-busy');
|
|
1163
|
-
expect(
|
|
1164
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1165
|
-
).not.toBeInTheDocument();
|
|
1164
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1166
1165
|
});
|
|
1167
1166
|
|
|
1168
1167
|
it('shows bootstrap results on mouse click', async () => {
|
|
@@ -1443,7 +1442,7 @@ describe('BaseTypeahead minQueryLength', () => {
|
|
|
1443
1442
|
|
|
1444
1443
|
fireEvent.change(input, {target: {value: 'App'}});
|
|
1445
1444
|
await waitFor(() => {
|
|
1446
|
-
expect(
|
|
1445
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
1447
1446
|
});
|
|
1448
1447
|
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1449
1448
|
|
|
@@ -1453,9 +1452,7 @@ describe('BaseTypeahead minQueryLength', () => {
|
|
|
1453
1452
|
await Promise.resolve();
|
|
1454
1453
|
});
|
|
1455
1454
|
|
|
1456
|
-
expect(
|
|
1457
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
1458
|
-
).not.toBeInTheDocument();
|
|
1455
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
1459
1456
|
expect(input).not.toHaveAttribute('aria-busy');
|
|
1460
1457
|
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
1461
1458
|
});
|
|
@@ -2185,12 +2182,12 @@ describe('busy indicator ownership', () => {
|
|
|
2185
2182
|
};
|
|
2186
2183
|
};
|
|
2187
2184
|
|
|
2188
|
-
it('renders its own
|
|
2185
|
+
it('renders its own indicator for a direct caller', async () => {
|
|
2189
2186
|
// BaseTypeaheadProps is re-exported from the package entry point, so the
|
|
2190
2187
|
// base has direct callers this repo cannot see. They painted no indicator
|
|
2191
|
-
// of their own — the base did it for them — so it keeps doing it
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2188
|
+
// of their own — the base did it for them — so it keeps doing it. The
|
|
2189
|
+
// Spinner is decorative and the base announces the search's busy start
|
|
2190
|
+
// through the shared channel (spec:AST-086 FR8).
|
|
2194
2191
|
const {source, settle} = pendingSource();
|
|
2195
2192
|
render(
|
|
2196
2193
|
<BaseTypeahead
|
|
@@ -2204,22 +2201,18 @@ describe('busy indicator ownership', () => {
|
|
|
2204
2201
|
|
|
2205
2202
|
fireEvent.change(input, {target: {value: 'App'}});
|
|
2206
2203
|
await waitFor(() => {
|
|
2207
|
-
expect(
|
|
2204
|
+
expect(getBusySpinner()).toBeInTheDocument();
|
|
2208
2205
|
});
|
|
2209
2206
|
// A Spinner, not the static clock glyph this used to render: `clock`
|
|
2210
2207
|
// means *time* everywhere else in core, and nothing about it moved while
|
|
2211
2208
|
// a search was out.
|
|
2212
|
-
expect(
|
|
2213
|
-
screen.getByRole('status', {name: 'Loading'}).querySelector('svg'),
|
|
2214
|
-
).toBeInTheDocument();
|
|
2209
|
+
expect(getBusySpinner().querySelector('svg')).toBeInTheDocument();
|
|
2215
2210
|
|
|
2216
2211
|
await act(async () => {
|
|
2217
2212
|
settle();
|
|
2218
2213
|
await Promise.resolve();
|
|
2219
2214
|
});
|
|
2220
|
-
expect(
|
|
2221
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
2222
|
-
).not.toBeInTheDocument();
|
|
2215
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
2223
2216
|
});
|
|
2224
2217
|
|
|
2225
2218
|
it('hands the indicator over to a field that takes it, and renders none itself', async () => {
|
|
@@ -2250,9 +2243,7 @@ describe('busy indicator ownership', () => {
|
|
|
2250
2243
|
});
|
|
2251
2244
|
// Scoped by name: the announcer for result counts is a role="status"
|
|
2252
2245
|
// live region too, and it is not the indicator.
|
|
2253
|
-
expect(
|
|
2254
|
-
screen.queryByRole('status', {name: 'Loading'}),
|
|
2255
|
-
).not.toBeInTheDocument();
|
|
2246
|
+
expect(queryBusySpinner()).not.toBeInTheDocument();
|
|
2256
2247
|
expect(onLoadingChange).toHaveBeenLastCalledWith(true);
|
|
2257
2248
|
|
|
2258
2249
|
await act(async () => {
|
|
@@ -25,6 +25,7 @@ import React, {
|
|
|
25
25
|
useMemo,
|
|
26
26
|
useState,
|
|
27
27
|
type ReactNode,
|
|
28
|
+
type RefObject,
|
|
28
29
|
} from 'react';
|
|
29
30
|
import * as stylex from '@stylexjs/stylex';
|
|
30
31
|
import {
|
|
@@ -70,6 +71,7 @@ import {themeProps} from '../utils/themeProps';
|
|
|
70
71
|
import {useTranslator} from '../i18n';
|
|
71
72
|
|
|
72
73
|
import {useMergedRefs} from '../hooks/useMergedRefs';
|
|
74
|
+
import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
|
|
73
75
|
export type {
|
|
74
76
|
InputStatus as TypeaheadStatus,
|
|
75
77
|
InputStatusType as TypeaheadStatusType,
|
|
@@ -349,19 +351,25 @@ const wrapperSizeStyles = stylex.create({
|
|
|
349
351
|
function EndLane({
|
|
350
352
|
lane,
|
|
351
353
|
loadingLabel,
|
|
354
|
+
anchorRef,
|
|
352
355
|
clear,
|
|
353
356
|
}: {
|
|
354
357
|
lane: BusyIndicatorLane;
|
|
355
358
|
loadingLabel: string;
|
|
359
|
+
anchorRef: RefObject<Element | null>;
|
|
356
360
|
clear: ReactNode;
|
|
357
361
|
}) {
|
|
358
362
|
const isBusy = useIsBusy(lane);
|
|
363
|
+
// The search Spinner stays named (family:input-fields FR7) but is not a
|
|
364
|
+
// live region; the field announces each search's busy start instead
|
|
365
|
+
// (spec:AST-086 FR8).
|
|
366
|
+
useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
|
|
359
367
|
if (!isBusy && clear == null) {
|
|
360
368
|
return null;
|
|
361
369
|
}
|
|
362
370
|
return (
|
|
363
371
|
<div {...stylex.props(styles.endLane)}>
|
|
364
|
-
{isBusy && <Spinner size="sm" aria-label={loadingLabel} />}
|
|
372
|
+
{isBusy && <Spinner size="sm" aria-label={loadingLabel} role="img" />}
|
|
365
373
|
{clear}
|
|
366
374
|
</div>
|
|
367
375
|
);
|
|
@@ -679,6 +687,7 @@ export function Typeahead<T extends SearchableItem>({
|
|
|
679
687
|
<EndLane
|
|
680
688
|
lane={busyLane}
|
|
681
689
|
loadingLabel={t('@astryx.typeahead.loading')}
|
|
690
|
+
anchorRef={wrapperRef}
|
|
682
691
|
clear={
|
|
683
692
|
hasClear && value && !isDisabled ? (
|
|
684
693
|
<InputClearButton
|