@astryxdesign/core 0.7.3 → 0.7.4-canary.1a552f2

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 (143) 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/ChatMessageList.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageList.js +8 -1
  14. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  15. package/dist/Chat/useTriggerMenu.js +46 -7
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  18. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  19. package/dist/CommandPalette/CommandPalette.js +6 -11
  20. package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
  21. package/dist/CommandPalette/CommandPaletteInput.js +7 -1
  22. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  23. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  24. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  25. package/dist/ContextMenu/ContextMenu.js +12 -0
  26. package/dist/DateInput/DateInput.d.ts.map +1 -1
  27. package/dist/DateInput/DateInput.js +6 -1
  28. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  29. package/dist/DateInput/NativeDateField.js +6 -1
  30. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  31. package/dist/DateInput/TouchDateField.js +9 -1
  32. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  33. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  34. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  35. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  36. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  37. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  38. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  40. package/dist/FileInput/FileInput.d.ts.map +1 -1
  41. package/dist/FileInput/FileInput.js +16 -2
  42. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  43. package/dist/MultiSelector/MultiSelector.js +6 -1
  44. package/dist/Selector/Selector.d.ts.map +1 -1
  45. package/dist/Selector/Selector.js +6 -1
  46. package/dist/TextArea/TextArea.d.ts.map +1 -1
  47. package/dist/TextArea/TextArea.js +6 -1
  48. package/dist/TextInput/TextInput.d.ts.map +1 -1
  49. package/dist/TextInput/TextInput.js +6 -1
  50. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  51. package/dist/Thumbnail/Thumbnail.js +9 -2
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +6 -1
  54. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  55. package/dist/TimeInput/TouchTimeField.js +9 -1
  56. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  57. package/dist/Tokenizer/Tokenizer.js +9 -1
  58. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  59. package/dist/Typeahead/BaseTypeahead.js +7 -1
  60. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  61. package/dist/Typeahead/Typeahead.js +9 -1
  62. package/dist/astryx.css +2 -0
  63. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  64. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  65. package/dist/hooks/useBusyAnnouncement.js +87 -0
  66. package/dist/hooks/useMenuPress.d.ts +2 -2
  67. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  68. package/dist/hooks/useMenuPress.js +5 -1
  69. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  70. package/dist/hooks/useTypeahead.js +7 -2
  71. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  72. package/dist/i18n/generated-locales/en.generated.js +1 -0
  73. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  74. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  75. package/locales/en.json +6 -2
  76. package/locales/pseudo.json +3 -0
  77. package/package.json +4 -4
  78. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  79. package/src/BottomSheet/BottomSheet.spec.md +3 -5
  80. package/src/BottomSheet/BottomSheet.tsx +260 -47
  81. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +29 -4
  82. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  83. package/src/BottomSheet/BottomSheetSwitcher.spec.md +35 -41
  84. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +1051 -49
  85. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  86. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  87. package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
  88. package/src/Carousel/Carousel.test.tsx +8 -12
  89. package/src/Carousel/Carousel.tsx +6 -13
  90. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  91. package/src/Chat/ChatMessageList.test.tsx +6 -2
  92. package/src/Chat/ChatMessageList.tsx +7 -1
  93. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  94. package/src/Chat/useTriggerMenu.tsx +55 -6
  95. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  96. package/src/CommandPalette/CommandPalette.test.tsx +4 -2
  97. package/src/CommandPalette/CommandPalette.tsx +6 -11
  98. package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
  99. package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
  100. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  101. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  102. package/src/ContextMenu/ContextMenu.tsx +13 -1
  103. package/src/DateInput/DateInput.tsx +5 -1
  104. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  105. package/src/DateInput/NativeDateField.test.tsx +10 -2
  106. package/src/DateInput/NativeDateField.tsx +5 -1
  107. package/src/DateInput/TouchDateField.tsx +14 -2
  108. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  109. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  110. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  112. package/src/FileInput/FileInput.tsx +18 -2
  113. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  114. package/src/MultiSelector/MultiSelector.tsx +5 -1
  115. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  116. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  117. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  118. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  119. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  120. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  121. package/src/Selector/Selector.test.tsx +22 -5
  122. package/src/Selector/Selector.tsx +5 -1
  123. package/src/TextArea/TextArea.test.tsx +9 -1
  124. package/src/TextArea/TextArea.tsx +5 -1
  125. package/src/TextInput/TextInput.tsx +5 -1
  126. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  127. package/src/Thumbnail/Thumbnail.tsx +12 -3
  128. package/src/TimeInput/TimeInput.tsx +5 -1
  129. package/src/TimeInput/TouchTimeField.tsx +12 -2
  130. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  131. package/src/Tokenizer/Tokenizer.tsx +10 -1
  132. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  133. package/src/Typeahead/Typeahead.test.tsx +32 -41
  134. package/src/Typeahead/Typeahead.tsx +10 -1
  135. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +792 -0
  136. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  137. package/src/hooks/useBusyAnnouncement.ts +100 -0
  138. package/src/hooks/useMenuPress.test.tsx +27 -1
  139. package/src/hooks/useMenuPress.ts +12 -3
  140. package/src/hooks/useTypeahead.test.tsx +42 -1
  141. package/src/hooks/useTypeahead.ts +7 -2
  142. package/src/i18n/generated-locales/en.generated.ts +1 -0
  143. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -666,6 +666,51 @@ describe('DateInput — field parity', () => {
666
666
  resolve();
667
667
  });
668
668
 
669
+ it('announces a busy start from a pick inside the open sheet, not behind it (spec:AST-086 FR8, Platform support)', async () => {
670
+ // The stubbed showModal() opens the dialog but jsdom cannot match
671
+ // `:modal`; stand in for the sheet the browser presents modally.
672
+ const matches = Element.prototype.matches;
673
+ const modalSpy = vi
674
+ .spyOn(Element.prototype, 'matches')
675
+ .mockImplementation(function (this: Element, selector: string) {
676
+ return selector === ':modal'
677
+ ? this instanceof HTMLDialogElement && this.open
678
+ : matches.call(this, selector);
679
+ });
680
+ const changeAction = vi.fn(async () => new Promise<void>(() => {}));
681
+ withLayout(() => {
682
+ render(
683
+ <DateInput
684
+ nativePicker="never"
685
+ label="Ship date"
686
+ value="2026-03-10"
687
+ min="2026-03-01"
688
+ max="2026-03-31"
689
+ onChange={() => {}}
690
+ changeAction={changeAction}
691
+ />,
692
+ );
693
+ fireEvent.click(field());
694
+ fireEvent.click(
695
+ within(pane('March 2026')).getByRole('button', {
696
+ name: /March 25, 2026/,
697
+ }),
698
+ );
699
+ });
700
+ const sheet = document.querySelector('dialog[open]');
701
+ expect(sheet).not.toBeNull();
702
+ await waitFor(() => {
703
+ expect(
704
+ sheet!.querySelector('[data-astryx-modal-busy-region]'),
705
+ ).toHaveTextContent('Loading');
706
+ });
707
+ expect(
708
+ document.querySelector('[data-astryx-live-region="polite"]')
709
+ ?.textContent ?? '',
710
+ ).toBe('');
711
+ modalSpy.mockRestore();
712
+ });
713
+
669
714
  it('drops the Field wrapper inside an InputGroup', () => {
670
715
  render(
671
716
  <InputGroup label="Range">
@@ -222,7 +222,11 @@ describe('DateInput nativePicker', () => {
222
222
  expect(changeAction).toHaveBeenCalledExactlyOnceWith('2026-03-21');
223
223
  expect(getInput()).not.toBeDisabled();
224
224
  expect(getInput()).toHaveAttribute('aria-busy', 'true');
225
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
225
+ // The busy Spinner is decorative (spec:AST-086 FR8).
226
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
227
+ 'aria-hidden',
228
+ 'true',
229
+ );
226
230
  expect(screen.getByText('March 21, 2026')).toBeInTheDocument();
227
231
 
228
232
  fireEvent.change(getInput(), {target: {value: '2026-03-22'}});
@@ -597,7 +601,11 @@ describe('DateInput nativePicker', () => {
597
601
 
598
602
  expect(calls).toEqual(['onChange', 'changeAction']);
599
603
  expect(changeAction).toHaveBeenCalledExactlyOnceWith(undefined);
600
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
604
+ // The busy Spinner is decorative (spec:AST-086 FR8).
605
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
606
+ 'aria-hidden',
607
+ 'true',
608
+ );
601
609
  await act(async () => resolveAction());
602
610
  });
603
611
 
@@ -49,6 +49,7 @@ import {
49
49
  } from '../Field';
50
50
  import {useInputStatusIcon, useMergedRefs} from '../hooks';
51
51
  import {useMediaQuery} from '../hooks/useMediaQuery';
52
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
52
53
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
53
54
  import {Icon} from '../Icon';
54
55
  import {useLocale, useTranslator} from '../i18n';
@@ -328,6 +329,9 @@ export function NativeDateField({
328
329
  const [, startTransition] = useTransition();
329
330
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
330
331
  const isBusy = isLoading || optimisticValue !== value;
332
+ // The busy Spinner is decorative; this control announces its busy start
333
+ // (spec:AST-086 FR8).
334
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), inputRef);
331
335
  // Keep the OS picker mounted and focusable while its own Action is pending.
332
336
  // Native TimeInput follows the same pattern: commit guards block duplicates,
333
337
  // while aria-busy and the Spinner communicate the save without detaching the
@@ -687,7 +691,7 @@ export function NativeDateField({
687
691
  onClick={handleClear}
688
692
  />
689
693
  )}
690
- {isBusy && <Spinner size="sm" />}
694
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
691
695
  {statusIcon}
692
696
  {showsDisabledMessage &&
693
697
  disabledMessageTooltip.renderTooltip(disabledMessage)}
@@ -73,6 +73,7 @@ import {
73
73
  } from '../Field';
74
74
  import {useInputStatusIcon, useMergedRefs} from '../hooks';
75
75
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
76
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
76
77
  import {Icon} from '../Icon';
77
78
  import {IconButton} from '../IconButton';
78
79
  import {useLocale, useTranslator} from '../i18n';
@@ -665,6 +666,15 @@ export function TouchDateField({
665
666
  );
666
667
 
667
668
  const [isSheetOpen, setIsSheetOpen] = useState(false);
669
+ const sheetBodyRef = useRef<HTMLDivElement | null>(null);
670
+ // The busy Spinner is decorative; this control announces its busy start
671
+ // (spec:AST-086 FR8). A date picked under a pending changeAction starts it
672
+ // while the modal sheet is open, so it is announced from inside the sheet.
673
+ useBusyAnnouncement(
674
+ isBusy,
675
+ t('@astryx.spinner.loading'),
676
+ isSheetOpen ? sheetBodyRef : inputRef,
677
+ );
668
678
  const [isWheelOpen, setIsWheelOpen] = useState(false);
669
679
  const scrollerHandleRef = useRef<MonthScrollerHandle | null>(null);
670
680
  // Pending focus handoff from the clear button; see handleClear.
@@ -1303,7 +1313,7 @@ export function TouchDateField({
1303
1313
  onClick={handleClear}
1304
1314
  />
1305
1315
  )}
1306
- {isBusy && <Spinner size="sm" />}
1316
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
1307
1317
  {statusIcon}
1308
1318
  <BottomSheet
1309
1319
  isOpen={isSheetOpen}
@@ -1314,7 +1324,9 @@ export function TouchDateField({
1314
1324
  height="hug"
1315
1325
  // sheetBody owns the inset, including the band under the grab handle.
1316
1326
  padding={0}>
1317
- <div {...stylex.props(styles.sheetBody)}>{surface}</div>
1327
+ <div ref={sheetBodyRef} {...stylex.props(styles.sheetBody)}>
1328
+ {surface}
1329
+ </div>
1318
1330
  </BottomSheet>
1319
1331
  {showsDisabledMessage &&
1320
1332
  disabledMessageTooltip.renderTooltip(disabledMessage)}
@@ -75,6 +75,7 @@ import {
75
75
  import type {SizeValue} from '../utils/types';
76
76
  import {useSize} from '../SizeContext/SizeContext';
77
77
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
78
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
78
79
  import {useMergedRefs} from '../hooks/useMergedRefs';
79
80
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
80
81
  import {themeProps} from '../utils/themeProps';
@@ -645,6 +646,9 @@ export function DateRangeInput({
645
646
  );
646
647
 
647
648
  const triggerRef = useRef<HTMLButtonElement>(null);
649
+ // The busy Spinner is decorative; this control announces its busy start
650
+ // (spec:AST-086 FR8).
651
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), triggerRef);
648
652
  const triggerButtonRef = useMergedRefs<HTMLButtonElement>(triggerRef, ref);
649
653
 
650
654
  const handleClear = useCallback(
@@ -787,7 +791,7 @@ export function DateRangeInput({
787
791
  {ariaLabelProp ?? triggerAriaLabel}
788
792
  </VisuallyHidden>
789
793
  )}
790
- {isBusy && <Spinner size="sm" />}
794
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
791
795
  {statusIcon}
792
796
  </div>
793
797
  {isEffectivelyRequired && (
@@ -65,6 +65,7 @@ import {useCalendarConstraints} from '../Calendar/hooks';
65
65
  import {usePopover} from '../Popover';
66
66
  import {useTooltip} from '../Tooltip';
67
67
  import {useInputContainer} from '../hooks/useInputContainer';
68
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
68
69
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
69
70
  import {
70
71
  type ISOTimeString,
@@ -709,6 +710,9 @@ function PointerDateTimeField({
709
710
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
710
711
  const isPendingChange = optimisticValue !== value;
711
712
  const isBusy = isLoading || isPendingChange;
713
+ // The busy Spinner is decorative; this control announces its busy start
714
+ // (spec:AST-086 FR8).
715
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), dateInputRef);
712
716
  // A native wheel emits several edits while it remains open. Disabling the
713
717
  // control after the first optimistic edit detaches the iOS picker, so pending
714
718
  // changeAction work stays interactive just like TouchDateTimeField. Explicit
@@ -1801,7 +1805,7 @@ function PointerDateTimeField({
1801
1805
  onClick={handleClear}
1802
1806
  />
1803
1807
  )}
1804
- {isBusy && <Spinner size="sm" />}
1808
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
1805
1809
  {statusIcon}
1806
1810
  </div>
1807
1811
 
@@ -47,6 +47,7 @@ import {
47
47
  } from '../Field';
48
48
  import {useInputStatusIcon, useMergedRefs} from '../hooks';
49
49
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
50
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
50
51
  import {Icon} from '../Icon';
51
52
  import {IconButton} from '../IconButton';
52
53
  import {useTranslator, InternationalizationContext} from '../i18n';
@@ -576,6 +577,15 @@ export function TouchDateTimeField({
576
577
  });
577
578
 
578
579
  const [isSheetOpen, setIsSheetOpen] = useState(false);
580
+ const sheetBodyRef = useRef<HTMLDivElement | null>(null);
581
+ // The busy Spinner is decorative; this control announces its busy start
582
+ // (spec:AST-086 FR8). A change made in the open modal sheet starts it while
583
+ // the sheet is open, so it is announced from inside the sheet.
584
+ useBusyAnnouncement(
585
+ isBusy,
586
+ t('@astryx.spinner.loading'),
587
+ isSheetOpen ? sheetBodyRef : dateInputRef,
588
+ );
579
589
  const [activePanel, setActivePanel] = useState<TouchDateTimePanel>('date');
580
590
  const [isWheelOpen, setIsWheelOpen] = useState(false);
581
591
  const [draftTime, setDraftTime] = useState<ISOTimeString | undefined>(() =>
@@ -1412,7 +1422,7 @@ export function TouchDateTimeField({
1412
1422
  onClick={handleClear}
1413
1423
  />
1414
1424
  )}
1415
- {isBusy && <Spinner size="sm" />}
1425
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
1416
1426
  {isRenderable(statusIcon) && statusIcon}
1417
1427
  </div>
1418
1428
 
@@ -1497,7 +1507,9 @@ export function TouchDateTimeField({
1497
1507
  // touchSheetBody owns the inset, including the band under the grab
1498
1508
  // handle.
1499
1509
  padding={0}>
1500
- <div {...stylex.props(styles.touchSheetBody)}>{surface}</div>
1510
+ <div ref={sheetBodyRef} {...stylex.props(styles.touchSheetBody)}>
1511
+ {surface}
1512
+ </div>
1501
1513
  </BottomSheet>
1502
1514
  {showsDisabledMessage &&
1503
1515
  disabledMessageTooltip.renderTooltip(disabledMessage)}
@@ -72,6 +72,7 @@ import {Item} from '../Item';
72
72
  import {useLayer} from '../Layer/useLayer';
73
73
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
74
74
  import {useListFocus} from '../hooks/useListFocus';
75
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
75
76
  import {useMenuHover} from '../hooks/useMenuHover';
76
77
  import {
77
78
  isMenuPressActivation,
@@ -682,6 +683,14 @@ export function DropdownMenuSubMenu(
682
683
  // closed from elsewhere is cancelled instead of firing into the hidden
683
684
  // menu (#6893).
684
685
  const isRootMenuOpen = useDropdownMenuRootOpen();
686
+ // The loading Spinner on the trigger is decorative; the submenu announces
687
+ // its busy start instead (spec:AST-086 FR8), once its level is shown: a
688
+ // closed menu stays mounted but hidden, and announces nothing.
689
+ useBusyAnnouncement(
690
+ hasSpinner && isRootMenuOpen,
691
+ t('@astryx.spinner.loading'),
692
+ triggerRef,
693
+ );
685
694
  const {triggerProps, contentProps, confirmHoverOpen} =
686
695
  useMenuHover<HTMLDivElement>({
687
696
  show: showLayer,
@@ -1033,7 +1042,7 @@ export function DropdownMenuSubMenu(
1033
1042
 
1034
1043
  const endAffordance = hasSpinner ? (
1035
1044
  <span {...stylex.props(triggerStyles.caret)}>
1036
- <Spinner size="sm" />
1045
+ <Spinner size="sm" aria-hidden="true" />
1037
1046
  </span>
1038
1047
  ) : (
1039
1048
  <span {...stylex.props(triggerStyles.caret)}>
@@ -47,6 +47,7 @@ import {Icon} from '../Icon';
47
47
  import {Spinner} from '../Spinner';
48
48
  import {VisuallyHidden} from '../VisuallyHidden';
49
49
  import {useAnnounce} from '../hooks/useAnnounce';
50
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
50
51
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
51
52
  import {useClickableContainer} from '../hooks/useClickableContainer';
52
53
  import {useTooltip} from '../Tooltip';
@@ -490,6 +491,20 @@ export function FileInput({
490
491
  const [, startTransition] = useTransition();
491
492
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
492
493
  const isBusy = isLoading || optimisticValue !== value;
494
+ // A pick is one user action, already announced as "file selected"; a busy
495
+ // period it starts (changeAction, or a caller setting isLoading from
496
+ // onChange) adds no second message (spec:AST-086 FR6). The mark lasts until
497
+ // the field is idle again. Other busy starts announce; the Spinner stays
498
+ // decorative (FR8).
499
+ const [isPickAnnounced, setIsPickAnnounced] = useState(false);
500
+ if (isPickAnnounced && !isBusy) {
501
+ setIsPickAnnounced(false);
502
+ }
503
+ useBusyAnnouncement(
504
+ isBusy && !isPickAnnounced,
505
+ t('@astryx.spinner.loading'),
506
+ containerRef,
507
+ );
493
508
 
494
509
  // Announce successful file selection to screen readers via a persistent
495
510
  // live region (forms-17). Validation errors are announced (assertively) by
@@ -580,6 +595,7 @@ export function FileInput({
580
595
  // status mounts, so only announce the attach here (do not
581
596
  // double-announce errors).
582
597
  if (errors.length === 0) {
598
+ setIsPickAnnounced(true);
583
599
  announce(
584
600
  valid.length === 1
585
601
  ? t('@astryx.fileInput.fileSelected', {fileName: valid[0].name})
@@ -741,7 +757,7 @@ export function FileInput({
741
757
 
742
758
  const renderDropzoneContent = () => {
743
759
  if (isBusy) {
744
- return <Spinner size="md" />;
760
+ return <Spinner size="md" aria-hidden="true" />;
745
761
  }
746
762
  if (hasFiles) {
747
763
  return (
@@ -772,7 +788,7 @@ export function FileInput({
772
788
  <span {...stylex.props(styles.fileNameText)}>
773
789
  {fileNames ?? displayPlaceholder}
774
790
  </span>
775
- <Spinner size="sm" />
791
+ <Spinner size="sm" aria-hidden="true" />
776
792
  </>
777
793
  );
778
794
  }
@@ -105,6 +105,14 @@ afterEach(() => {
105
105
  // "visible" in the accessibility tree. Use hidden: true to find it.
106
106
  const h = {hidden: true} as const;
107
107
 
108
+ /** A busy control's Spinner is decorative and silent (spec:AST-086 FR8). */
109
+ function expectSilentBusySpinner() {
110
+ const spinner = document.querySelector('.astryx-spinner');
111
+ expect(spinner).not.toBeNull();
112
+ expect(spinner).toHaveAttribute('aria-hidden', 'true');
113
+ expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
114
+ }
115
+
108
116
  describe('MultiSelector', () => {
109
117
  const defaultOptions = ['Apple', 'Banana', 'Orange'];
110
118
 
@@ -1303,7 +1311,10 @@ describe('MultiSelector', () => {
1303
1311
  // component cannot make; the trigger's spinner carries the state.
1304
1312
  const listbox = screen.getByRole('listbox', h);
1305
1313
  expect(within(listbox).queryByText('No options')).not.toBeInTheDocument();
1306
- expect(politeRegion()?.textContent ?? '').toBe('');
1314
+ // Only the busy start is spoken (spec:AST-086 FR8), never an empty or
1315
+ // no-results message the panel is suppressing.
1316
+ // (The channel clears its text on a timer, so it may already be empty.)
1317
+ expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
1307
1318
  });
1308
1319
 
1309
1320
  it('announces nothing while isLoading, matching the silent panel', async () => {
@@ -1337,7 +1348,10 @@ describe('MultiSelector', () => {
1337
1348
  expect(
1338
1349
  within(listbox).queryByText('Nothing like that here'),
1339
1350
  ).not.toBeInTheDocument();
1340
- expect(politeRegion()?.textContent ?? '').toBe('');
1351
+ // Only the busy start is spoken (spec:AST-086 FR8), never an empty or
1352
+ // no-results message the panel is suppressing.
1353
+ // (The channel clears its text on a timer, so it may already be empty.)
1354
+ expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
1341
1355
  });
1342
1356
 
1343
1357
  it('renders emptyText when there are no options and no search input', async () => {
@@ -1555,7 +1569,7 @@ describe('MultiSelector', () => {
1555
1569
  />,
1556
1570
  );
1557
1571
  expect(screen.getByRole('combobox')).toHaveAttribute('aria-busy', 'true');
1558
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1572
+ expectSilentBusySpinner();
1559
1573
  });
1560
1574
 
1561
1575
  it('renders with custom selectAllLabel', async () => {
@@ -2250,7 +2264,7 @@ describe('MultiSelector', () => {
2250
2264
 
2251
2265
  const trigger = screen.getByRole('combobox', {name: 'Fruit'});
2252
2266
  expect(trigger).toHaveAttribute('aria-busy', 'true');
2253
- expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
2267
+ expectSilentBusySpinner();
2254
2268
 
2255
2269
  await user.click(trigger);
2256
2270
  expect(screen.queryByRole('listbox', h)).not.toBeInTheDocument();
@@ -92,6 +92,7 @@ import {useDevWarning} from '../hooks/useDevWarning';
92
92
  import {composeEventHandlers} from '../utils/composeEventHandlers';
93
93
  import {mergeRefs} from '../utils/mergeRefs';
94
94
  import {useMergedRefs} from '../hooks/useMergedRefs';
95
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
95
96
  import type {SizeValue} from '../utils/types';
96
97
  import {useSize} from '../SizeContext/SizeContext';
97
98
  import {themeProps} from '../utils/themeProps';
@@ -1203,6 +1204,9 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1203
1204
  const [, startTransition] = useTransition();
1204
1205
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
1205
1206
  const isBusy = isLoading || optimisticValue !== value;
1207
+ // The busy Spinner is decorative; this control announces its busy start
1208
+ // (spec:AST-086 FR8).
1209
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), triggerRef);
1206
1210
 
1207
1211
  // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
1208
1212
  // tooltip listeners attach to the trigger container (which already exists)
@@ -2643,7 +2647,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
2643
2647
  value.map(v => (
2644
2648
  <input key={v} type="hidden" name={htmlName} value={v} />
2645
2649
  ))}
2646
- {isBusy && <Spinner size="sm" />}
2650
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
2647
2651
  {hasClear &&
2648
2652
  value.length > 0 &&
2649
2653
  !isDisabled &&
@@ -565,21 +565,6 @@ describe('ProgressBar', () => {
565
565
  expect(styleClasses(accentMarks[1])).toBe(styleClasses(errorMarks[1]));
566
566
  });
567
567
 
568
- it('keeps tabbable target marks outside the progressbar subtree', () => {
569
- const {container} = render(
570
- <ProgressBar
571
- value={50}
572
- label="Progress"
573
- marks={[{value: 80, label: 'Goal'}]}
574
- />,
575
- );
576
- const progressbar = screen.getByRole('progressbar', {name: 'Progress'});
577
- const mark = container.querySelector(MARK)!;
578
- expect(mark).toHaveAttribute('tabindex', '0');
579
- expect(progressbar.querySelector('[tabindex="0"]')).toBeNull();
580
- expect(mark.closest('[role="progressbar"]')).toBeNull();
581
- });
582
-
583
568
  it('renders every mark as a focusable trigger (label is required, never decorative)', () => {
584
569
  const {container} = render(
585
570
  <ProgressBar
@@ -637,7 +622,6 @@ describe('ProgressBar', () => {
637
622
  />,
638
623
  );
639
624
  const progressbar = screen.getByRole('progressbar');
640
- expect(progressbar.querySelector(MARK)).toBeNull();
641
625
  expect(progressbar.querySelector('[role="img"]')).toBeNull();
642
626
  expect(progressbar.querySelector('[aria-label]')).toBeNull();
643
627
  expect(container.querySelectorAll(MARK)).toHaveLength(1);
@@ -655,9 +639,10 @@ describe('ProgressBar', () => {
655
639
  expect(progressbar.getAttribute('aria-valuetext')).toBe('50%');
656
640
  });
657
641
 
658
- it('preserves the fill inside the progressbar when marks are rendered outside it', () => {
659
- // Only the fill belongs to the semantic track; the focusable marks
660
- // remain independently available outside its presentational subtree.
642
+ it('keeps the fill as the progressbar child when marks are rendered', () => {
643
+ // Only the fill belongs to the semantic track. That the focusable marks
644
+ // stay outside its presentational subtree is the progressbar contract's
645
+ // target-marks state (progressbar.subtree.no-focusable-descendants).
661
646
  const {container} = render(
662
647
  <ProgressBar
663
648
  value={50}
@@ -669,8 +654,6 @@ describe('ProgressBar', () => {
669
654
  const fill = progressbar.firstElementChild as HTMLElement;
670
655
  expect(fill.style.width).toBe('50%');
671
656
  expect(fill.classList.contains('astryx-progress-bar-mark')).toBe(false);
672
- const mark = container.querySelector<HTMLElement>(MARK)!;
673
- expect(progressbar).not.toContainElement(mark);
674
657
  expect(container.querySelectorAll(MARK)).toHaveLength(1);
675
658
  });
676
659
 
@@ -0,0 +1,114 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ProgressBar.a11y.chromium.spec.ts
5
+ * @input Built ProgressBar stories and PROGRESSBAR_PATTERN in real Chromium
6
+ * @output Browser evidence for every progressbar expectation across the binding's states, reconciled against the exact known failures
7
+ * @position Chromium lane of the ProgressBar binding
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ PROGRESSBAR_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ neverExercised,
23
+ summarize,
24
+ unmatchedKnownFailures,
25
+ type BindingResult,
26
+ } from '@astryxdesign/a11y-spec';
27
+ import {
28
+ createChromiumHarness,
29
+ holdMotionStill,
30
+ } from '@astryxdesign/a11y-spec/chromium';
31
+ import {
32
+ DEFAULT_STORYBOOK_DIR,
33
+ serveStorybook,
34
+ type StaticServer,
35
+ } from '@astryxdesign/a11y-spec/storybook';
36
+ import {PROGRESSBAR_KNOWN_FAILURES} from './ProgressBar.a11y.known-failures';
37
+ import {
38
+ PROGRESSBAR_BINDING_STATES,
39
+ type ProgressBarBindingState,
40
+ } from './ProgressBar.a11y.states';
41
+
42
+ let storybook: StaticServer;
43
+
44
+ test.beforeAll(async () => {
45
+ storybook = await serveStorybook(
46
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
47
+ );
48
+ });
49
+
50
+ test.afterAll(async () => {
51
+ await storybook?.close();
52
+ });
53
+
54
+ function storyUrl(storyId: string): string {
55
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
56
+ }
57
+
58
+ async function mountState(
59
+ page: Page,
60
+ state: ProgressBarBindingState,
61
+ ): Promise<{subject: Locator; related: Record<string, Locator>}> {
62
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
63
+ await holdMotionStill(page);
64
+ const subject = page
65
+ .locator('#storybook-root')
66
+ .getByRole('progressbar', {name: state.facts.name});
67
+ await subject.waitFor({state: 'attached'});
68
+ return {subject, related: {}};
69
+ }
70
+
71
+ async function runState(
72
+ page: Page,
73
+ cdp: CDPSession,
74
+ state: ProgressBarBindingState,
75
+ ): Promise<BindingResult> {
76
+ return checkAccessibilitySpec({
77
+ spec: PROGRESSBAR_PATTERN,
78
+ binding: 'ProgressBar',
79
+ state: state.id,
80
+ facts: state.facts,
81
+ knownFailures: PROGRESSBAR_KNOWN_FAILURES,
82
+ mount: async () => {
83
+ const {subject, related} = await mountState(page, state);
84
+ return createChromiumHarness({page, cdp, subject, related});
85
+ },
86
+ });
87
+ }
88
+
89
+ test('every progressbar expectation is exercised by a ProgressBar state', async ({
90
+ page,
91
+ }) => {
92
+ const cdp = await page.context().newCDPSession(page);
93
+ const results: BindingResult[] = [];
94
+ for (const state of PROGRESSBAR_BINDING_STATES) {
95
+ results.push(await runState(page, cdp, state));
96
+ }
97
+ expect(neverExercised(results)).toEqual([]);
98
+ expect(unmatchedKnownFailures(PROGRESSBAR_KNOWN_FAILURES, results)).toEqual(
99
+ [],
100
+ );
101
+ });
102
+
103
+ for (const state of PROGRESSBAR_BINDING_STATES) {
104
+ test(`ProgressBar [${state.id}] — ${state.summary}`, async ({page}) => {
105
+ const cdp = await page.context().newCDPSession(page);
106
+ const result = await runState(page, cdp, state);
107
+ const report = summarize(PROGRESSBAR_PATTERN, [result]);
108
+ // Required rows block (AST-020 FR9); advisory rows and exact known
109
+ // failures report.
110
+ expect(formatFailures(blockingResults([result]))).toBe('');
111
+ expect(report.unrunLayers).toEqual([]);
112
+ expect(report.counts.unexpectedPass).toBe(0);
113
+ });
114
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ProgressBar.a11y.known-failures.ts
5
+ * @input The exact progressbar-contract failures the shipped ProgressBar produces today
6
+ * @output PROGRESSBAR_KNOWN_FAILURES — records both lanes reconcile against, so a fix shows up as an unexpected pass and any other failure stays a failure
7
+ * @position Known-failure ledger for the ProgressBar binding
8
+ */
9
+
10
+ import type {KnownFailure} from '@astryxdesign/a11y-spec';
11
+
12
+ export const PROGRESSBAR_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file ProgressBar.a11y.renders.tsx
7
+ * @input One ProgressBar binding state
8
+ * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook)
9
+ * @position Shared fixture for the ProgressBar binding
10
+ */
11
+
12
+ import type {ReactElement} from 'react';
13
+ import {ProgressBar} from '../ProgressBar';
14
+ import {
15
+ formatGigabytes,
16
+ type ProgressBarBindingState,
17
+ } from './ProgressBar.a11y.states';
18
+
19
+ export function renderProgressBarState(
20
+ state: ProgressBarBindingState,
21
+ ): ReactElement {
22
+ return (
23
+ <ProgressBar
24
+ style={{width: 320}}
25
+ label={state.facts.name}
26
+ // An indeterminate state still passes a value, so the proof shows the
27
+ // bar withholds it rather than never having had one.
28
+ value={state.facts.value ?? 50}
29
+ max={state.facts.max}
30
+ isLabelHidden={state.isLabelHidden}
31
+ hasValueLabel={state.hasValueLabel}
32
+ formatValueLabel={state.formatsValue ? formatGigabytes : undefined}
33
+ isIndeterminate={state.isIndeterminate}
34
+ isDisabled={state.isDisabled}
35
+ marks={state.marks}
36
+ />
37
+ );
38
+ }