@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.
Files changed (100) 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/MultiSelector/MultiSelector.d.ts.map +1 -1
  27. package/dist/MultiSelector/MultiSelector.js +6 -1
  28. package/dist/Selector/Selector.d.ts.map +1 -1
  29. package/dist/Selector/Selector.js +6 -1
  30. package/dist/TextArea/TextArea.d.ts.map +1 -1
  31. package/dist/TextArea/TextArea.js +6 -1
  32. package/dist/TextInput/TextInput.d.ts.map +1 -1
  33. package/dist/TextInput/TextInput.js +6 -1
  34. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  35. package/dist/Tokenizer/Tokenizer.js +9 -1
  36. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  37. package/dist/Typeahead/BaseTypeahead.js +7 -1
  38. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  39. package/dist/Typeahead/Typeahead.js +9 -1
  40. package/dist/astryx.css +2 -0
  41. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  42. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  43. package/dist/hooks/useBusyAnnouncement.js +87 -0
  44. package/dist/hooks/useMenuPress.d.ts +2 -2
  45. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  46. package/dist/hooks/useMenuPress.js +5 -1
  47. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  48. package/dist/i18n/generated-locales/en.generated.js +1 -0
  49. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  50. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  51. package/locales/en.json +6 -2
  52. package/locales/pseudo.json +3 -0
  53. package/package.json +4 -4
  54. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  55. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  56. package/src/BottomSheet/BottomSheet.tsx +260 -47
  57. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  58. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  59. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  60. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  61. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  62. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  63. package/src/Carousel/Carousel.test.tsx +8 -12
  64. package/src/Carousel/Carousel.tsx +6 -13
  65. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  66. package/src/Chat/useTriggerMenu.tsx +55 -6
  67. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  68. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  69. package/src/ContextMenu/ContextMenu.tsx +13 -1
  70. package/src/DateInput/DateInput.tsx +5 -1
  71. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  72. package/src/DateInput/NativeDateField.test.tsx +10 -2
  73. package/src/DateInput/NativeDateField.tsx +5 -1
  74. package/src/DateInput/TouchDateField.tsx +14 -2
  75. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  76. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  77. package/src/MultiSelector/MultiSelector.tsx +5 -1
  78. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  79. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  80. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  81. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  82. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  83. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  84. package/src/Selector/Selector.test.tsx +22 -5
  85. package/src/Selector/Selector.tsx +5 -1
  86. package/src/TextArea/TextArea.test.tsx +9 -1
  87. package/src/TextArea/TextArea.tsx +5 -1
  88. package/src/TextInput/TextInput.tsx +5 -1
  89. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  90. package/src/Tokenizer/Tokenizer.tsx +10 -1
  91. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  92. package/src/Typeahead/Typeahead.test.tsx +32 -41
  93. package/src/Typeahead/Typeahead.tsx +10 -1
  94. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +369 -0
  95. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  96. package/src/hooks/useBusyAnnouncement.ts +100 -0
  97. package/src/hooks/useMenuPress.test.tsx +27 -1
  98. package/src/hooks/useMenuPress.ts +12 -3
  99. package/src/i18n/generated-locales/en.generated.ts +1 -0
  100. 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
- 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
  );
@@ -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(
@@ -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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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 named status for a direct caller', async () => {
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, and the
2192
- // status stays a named one rather than a bare aria-busy that only reaches
2193
- // assistive tech.
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(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
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