@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.
Files changed (116) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +18 -6
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +268 -58
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
  5. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
  7. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
  8. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
  10. package/dist/Carousel/Carousel.d.ts.map +1 -1
  11. package/dist/Carousel/Carousel.js +7 -11
  12. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  13. package/dist/Chat/useTriggerMenu.js +46 -7
  14. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  15. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  16. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  17. package/dist/ContextMenu/ContextMenu.js +12 -0
  18. package/dist/DateInput/DateInput.d.ts.map +1 -1
  19. package/dist/DateInput/DateInput.js +6 -1
  20. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  21. package/dist/DateInput/NativeDateField.js +6 -1
  22. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  23. package/dist/DateInput/TouchDateField.js +9 -1
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  30. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  31. package/dist/MultiSelector/MultiSelector.js +6 -1
  32. package/dist/Selector/Selector.d.ts.map +1 -1
  33. package/dist/Selector/Selector.js +6 -1
  34. package/dist/TextArea/TextArea.d.ts.map +1 -1
  35. package/dist/TextArea/TextArea.js +6 -1
  36. package/dist/TextInput/TextInput.d.ts.map +1 -1
  37. package/dist/TextInput/TextInput.js +6 -1
  38. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  39. package/dist/TimeInput/TimeInput.js +6 -1
  40. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  41. package/dist/TimeInput/TouchTimeField.js +9 -1
  42. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  43. package/dist/Tokenizer/Tokenizer.js +9 -1
  44. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  45. package/dist/Typeahead/BaseTypeahead.js +7 -1
  46. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  47. package/dist/Typeahead/Typeahead.js +9 -1
  48. package/dist/astryx.css +2 -0
  49. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  50. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  51. package/dist/hooks/useBusyAnnouncement.js +87 -0
  52. package/dist/hooks/useMenuPress.d.ts +2 -2
  53. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  54. package/dist/hooks/useMenuPress.js +5 -1
  55. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  56. package/dist/hooks/useTypeahead.js +7 -2
  57. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  58. package/dist/i18n/generated-locales/en.generated.js +1 -0
  59. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  60. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  61. package/locales/en.json +6 -2
  62. package/locales/pseudo.json +3 -0
  63. package/package.json +4 -4
  64. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  65. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  66. package/src/BottomSheet/BottomSheet.tsx +260 -47
  67. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  68. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  69. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  70. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  71. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  72. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  73. package/src/Carousel/Carousel.test.tsx +8 -12
  74. package/src/Carousel/Carousel.tsx +6 -13
  75. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  76. package/src/Chat/useTriggerMenu.tsx +55 -6
  77. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  78. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  79. package/src/ContextMenu/ContextMenu.tsx +13 -1
  80. package/src/DateInput/DateInput.tsx +5 -1
  81. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  82. package/src/DateInput/NativeDateField.test.tsx +10 -2
  83. package/src/DateInput/NativeDateField.tsx +5 -1
  84. package/src/DateInput/TouchDateField.tsx +14 -2
  85. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  86. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  87. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  88. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  89. package/src/MultiSelector/MultiSelector.tsx +5 -1
  90. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  91. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  92. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  93. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  94. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  95. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  96. package/src/Selector/Selector.test.tsx +22 -5
  97. package/src/Selector/Selector.tsx +5 -1
  98. package/src/TextArea/TextArea.test.tsx +9 -1
  99. package/src/TextArea/TextArea.tsx +5 -1
  100. package/src/TextInput/TextInput.tsx +5 -1
  101. package/src/TimeInput/TimeInput.tsx +5 -1
  102. package/src/TimeInput/TouchTimeField.tsx +12 -2
  103. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  104. package/src/Tokenizer/Tokenizer.tsx +10 -1
  105. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  106. package/src/Typeahead/Typeahead.test.tsx +32 -41
  107. package/src/Typeahead/Typeahead.tsx +10 -1
  108. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +470 -0
  109. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  110. package/src/hooks/useBusyAnnouncement.ts +100 -0
  111. package/src/hooks/useMenuPress.test.tsx +27 -1
  112. package/src/hooks/useMenuPress.ts +12 -3
  113. package/src/hooks/useTypeahead.test.tsx +42 -1
  114. package/src/hooks/useTypeahead.ts +7 -2
  115. package/src/i18n/generated-locales/en.generated.ts +1 -0
  116. 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
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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
- expect(politeRegion()?.textContent ?? '').toBe('');
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
- expect(politeRegion()?.textContent ?? '').toBe('');
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
- expect(politeRegion()?.textContent ?? '').toBe('');
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
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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
- expect(screen.getByRole('status')).toBeInTheDocument();
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(screen.getByRole('status')).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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 size="sm" aria-label={t('@astryx.typeahead.loading')} />
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(