@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
@@ -5,11 +5,22 @@
5
5
  /**
6
6
  * @file BottomSheetSwitcherContext.ts
7
7
  * @input Uses React context
8
- * @output Internal phase and lifecycle context shared by BottomSheetSwitcher and BottomSheet
8
+ * @output Internal phase, lifecycle, and host-dialog context shared by BottomSheetSwitcher and BottomSheet
9
9
  * @position Private coordination layer for mutually exclusive BottomSheets
10
+ *
11
+ * Every child sheet renders its own native `<dialog>`; the switcher's only
12
+ * element is a flow wrapper. This context carries the phase machine, the
13
+ * host-dialog selection, and the switcher's dialog-level props and handlers
14
+ * down to those sheet hosts.
10
15
  */
11
16
 
17
+ import type {
18
+ KeyboardEvent as ReactKeyboardEvent,
19
+ MouseEvent as ReactMouseEvent,
20
+ SyntheticEvent,
21
+ } from 'react';
12
22
  import {createContext} from 'react';
23
+ import type {BaseProps} from '../BaseProps';
13
24
  import type {DialogPurpose} from '../Dialog';
14
25
 
15
26
  export type BottomSheetSwitcherPhase =
@@ -26,23 +37,67 @@ export interface BottomSheetSwitcherTransitionEvent {
26
37
  phase: 'entering' | 'aligning' | 'fading' | 'exiting';
27
38
  }
28
39
 
40
+ /**
41
+ * The switcher's own dialog surface, applied by every sheet host. `ref` is not
42
+ * part of it: the switcher resolves its ref to the host sheet's dialog, and
43
+ * the single-instance attributes (`id`, `data-testid`) render there only.
44
+ */
45
+ export type BottomSheetSwitcherDialogProps = Omit<
46
+ BaseProps<HTMLDialogElement>,
47
+ 'onClick' | 'onKeyDown'
48
+ >;
49
+
29
50
  export interface BottomSheetSwitcherContextValue {
30
51
  activeSheet: string | null;
31
52
  hasScrim: boolean;
32
53
  onActiveSheetChange: (sheetId: string | null) => void;
33
54
  getSheetPhase: (sheetId: string) => BottomSheetSwitcherPhase;
34
55
  getSheetAlignmentOffset: (sheetId: string) => number;
56
+ /**
57
+ * The sheet whose dialog hosts the flow: the active sheet, the exiting
58
+ * sheet through its exit, and the last host (or the first mounted sheet)
59
+ * while the flow is closed. The switcher's ref and its single-instance
60
+ * attributes resolve to this sheet's dialog.
61
+ */
62
+ hostSheet: string | null;
63
+ /**
64
+ * Whether this sheet's dialog paints the flow's one scrim right now. During
65
+ * a handoff the retained sheet keeps it, so the entering dialog's backdrop
66
+ * stays transparent above the still-visible previous panel.
67
+ */
68
+ isScrimOwner: (sheetId: string) => boolean;
69
+ /** The flow is closing: the owning backdrop fades out on the exit curve. */
70
+ isFlowClosing: boolean;
35
71
  registerSheetElement: (sheetId: string, element: HTMLElement | null) => void;
36
- registerSheetLabel: (sheetId: string, label: string | null) => void;
72
+ registerSheetDialog: (
73
+ sheetId: string,
74
+ dialog: HTMLDialogElement | null,
75
+ ) => void;
37
76
  registerSheetPurpose: (
38
77
  sheetId: string,
39
78
  purpose: DialogPurpose | null,
40
79
  ) => void;
80
+ /**
81
+ * Called by a sheet host just before it presents its dialog modally, so the
82
+ * switcher can remember the page element that opened the flow.
83
+ */
84
+ onBeforeSheetPresent: (sheetId: string) => void;
85
+ /**
86
+ * Whether a modal sheet dialog that is already open sits beneath another
87
+ * open sheet dialog in the top layer, so it must re-present to become the
88
+ * topmost layer again.
89
+ */
90
+ isCoveredInTopLayer: (sheetId: string) => boolean;
41
91
  onSheetEnterStart: (sheetId: string) => void;
42
92
  onSheetTransitionComplete: (
43
93
  event: BottomSheetSwitcherTransitionEvent,
44
94
  ) => void;
45
95
  onSheetScrimOpacityChange: (sheetId: string, opacity: number) => void;
96
+ /** Consumer dialog props the switcher forwards to every sheet dialog. */
97
+ dialogProps: BottomSheetSwitcherDialogProps;
98
+ onDialogCancel: (event: SyntheticEvent<HTMLDialogElement>) => void;
99
+ onDialogClick: (event: ReactMouseEvent<HTMLDialogElement>) => void;
100
+ onDialogKeyDown: (event: ReactKeyboardEvent<HTMLDialogElement>) => void;
46
101
  }
47
102
 
48
103
  export const BottomSheetSwitcherContext =
@@ -519,7 +519,7 @@ describe('Carousel', () => {
519
519
  fireEvent.scroll(el);
520
520
  }
521
521
 
522
- it('hands focus to the opposite button when the edge disables the one in use', async () => {
522
+ it('hands focus to the scroll area when the edge disables the one in use', async () => {
523
523
  const user = userEvent.setup();
524
524
  render(
525
525
  <Carousel aria-label="Gallery">
@@ -536,7 +536,7 @@ describe('Carousel', () => {
536
536
  await user.click(next);
537
537
 
538
538
  expect(next).toBeDisabled();
539
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
539
+ expect(document.activeElement).toBe(getScroller());
540
540
  expect(document.activeElement).not.toBe(document.body);
541
541
  });
542
542
 
@@ -559,9 +559,7 @@ describe('Carousel', () => {
559
559
  await user.click(previous);
560
560
 
561
561
  expect(previous).toBeDisabled();
562
- expect(document.activeElement).toBe(
563
- screen.getByLabelText('Scroll right'),
564
- );
562
+ expect(document.activeElement).toBe(getScroller());
565
563
  });
566
564
 
567
565
  it('hands off under RTL, where the scroll axis is inverted', async () => {
@@ -605,7 +603,7 @@ describe('Carousel', () => {
605
603
  await user.click(next);
606
604
 
607
605
  expect(next).toBeDisabled();
608
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
606
+ expect(document.activeElement).toBe(getScroller());
609
607
  });
610
608
 
611
609
  it('hands off when scroll-snap carries the container past what the press asked for', async () => {
@@ -626,7 +624,7 @@ describe('Carousel', () => {
626
624
  await user.click(next);
627
625
 
628
626
  expect(next).toBeDisabled();
629
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
627
+ expect(document.activeElement).toBe(getScroller());
630
628
  });
631
629
 
632
630
  it('falls back to the scroll container when the carousel stops overflowing', async () => {
@@ -701,7 +699,7 @@ describe('Carousel', () => {
701
699
  // rather than loosening the guard -- is caught.
702
700
  await user.click(screen.getByLabelText('Scroll right'));
703
701
 
704
- expect(document.activeElement).toBe(screen.getByLabelText('Scroll left'));
702
+ expect(document.activeElement).toBe(getScroller());
705
703
  });
706
704
 
707
705
  it('does not move focus on a press that leaves the direction available', async () => {
@@ -748,8 +746,7 @@ describe('Carousel', () => {
748
746
  </Carousel>,
749
747
  );
750
748
  makeScrollable(getScroller(), 400);
751
- const previous = screen.getByLabelText('Scroll left');
752
- const focus = vi.spyOn(previous, 'focus');
749
+ const focus = vi.spyOn(getScroller(), 'focus');
753
750
 
754
751
  await user.click(screen.getByLabelText('Scroll right'));
755
752
 
@@ -789,8 +786,7 @@ describe('Carousel', () => {
789
786
  makeScrollable(getScroller(), 400);
790
787
  await user.click(screen.getByLabelText('Scroll right'));
791
788
 
792
- const previous = screen.getByLabelText('Scroll left');
793
- expect(document.activeElement).toBe(previous);
789
+ expect(document.activeElement).toBe(getScroller());
794
790
  // Park focus outside and re-render: the transition is spent, so nothing
795
791
  // may pull focus back in.
796
792
  const outside = document.createElement('button');
@@ -504,9 +504,7 @@ export function Carousel({
504
504
  // the scroll, the position before it plus the step, and the settled position
505
505
  // reported by `scrollend` -- were each wrong somewhere, under reduced motion,
506
506
  // under mandatory scroll-snap, and on a browser that never fires the event.
507
- // Running after the commit also makes the opposite button a valid receiver,
508
- // which it is not during the press: at that moment it is still disabled and
509
- // focus() on a disabled control does nothing. Ruled acceptable by the
507
+ // Ruled acceptable by the
510
508
  // maintainer, 2026-08-27, on the conditions encoded below: it acts only on
511
509
  // the transition that newly disables the focused button, it moves focus and
512
510
  // nothing else, and it cannot fire twice for one transition.
@@ -543,21 +541,16 @@ export function Carousel({
543
541
  return;
544
542
  }
545
543
 
546
- const opposite =
547
- side === 'start' ? endButtonRef.current : startButtonRef.current;
548
- // The opposite button is the receiver. It is disabled only when the
549
- // carousel stopped overflowing entirely, which happens when its content
550
- // shrinks; the scroll container is then the nearest tab stop still inside
551
- // the labelled region.
552
- const receiver =
553
- opposite != null && !opposite.disabled ? opposite : scrollElRef.current;
544
+ // The scroll area is the receiver (component:Carousel FR2). It takes focus
545
+ // programmatically even when it is out of the tab order.
546
+ const receiver = scrollElRef.current;
554
547
  if (receiver == null) {
555
548
  return;
556
549
  }
557
550
 
558
551
  // One move per transition: clearing the tracker first means a re-render or
559
552
  // a StrictMode double-invoke re-runs this and returns at the `side == null`
560
- // guard above. Focusing the receiver sets it again, to the other side.
553
+ // guard above.
561
554
  focusedNavRef.current = null;
562
555
  // preventScroll: a plain focus() scrolls its element into view, which on
563
556
  // the scroll container cancels the scroll the press just started.
@@ -661,7 +654,7 @@ export function Carousel({
661
654
  // button mounted (stable layout) but removes it from the tab
662
655
  // order and a11y tree while it's visually hidden, so keyboard
663
656
  // users don't land on an invisible control. The Effect above
664
- // hands focus to the other button when the state behind this
657
+ // hands focus to the scroll area when the state behind this
665
658
  // one flips it disabled.
666
659
  isDisabled={!canScrollStart}
667
660
  onFocus={() => {
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect, vi, afterEach} from 'vitest';
3
+ import {StrictMode} from 'react';
4
+ import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest';
4
5
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
5
6
  import userEvent from '@testing-library/user-event';
6
7
  import {ChatComposer} from './ChatComposer';
@@ -13,6 +14,9 @@ import type {
13
14
  import {createStaticSource} from '../Typeahead/createStaticSource';
14
15
  import type {SearchableItem, SearchSource} from '../Typeahead/types';
15
16
 
17
+ const announce = vi.hoisted(() => vi.fn());
18
+ vi.mock('../hooks/useAnnounce', () => ({useAnnounce: () => announce}));
19
+
16
20
  // =============================================================================
17
21
  // Helpers
18
22
  // =============================================================================
@@ -1824,3 +1828,165 @@ describe('ChatComposerInput', () => {
1824
1828
  });
1825
1829
  });
1826
1830
  });
1831
+
1832
+ describe('ChatComposerInput trigger loading announcement (spec:AST-086)', () => {
1833
+ function typeAtEnd(editable: HTMLElement, text: string) {
1834
+ editable.textContent = text;
1835
+ const node = editable.firstChild!;
1836
+ const range = document.createRange();
1837
+ range.setStart(node, text.length);
1838
+ range.collapse(true);
1839
+ const sel = window.getSelection()!;
1840
+ sel.removeAllRanges();
1841
+ sel.addRange(range);
1842
+ fireEvent.input(editable);
1843
+ }
1844
+
1845
+ /**
1846
+ * Lets a settled search apply, as the browser would before the next frame,
1847
+ * then lets the loading announcement's two frames run.
1848
+ */
1849
+ async function nextFrame() {
1850
+ await act(async () => {});
1851
+ await act(async () => {
1852
+ for (let frame = 0; frame < 2; frame++) {
1853
+ await new Promise(resolve => requestAnimationFrame(resolve));
1854
+ }
1855
+ });
1856
+ }
1857
+
1858
+ /** An async source whose searches resolve only when the test says so. */
1859
+ function createPendingSource() {
1860
+ const resolvers: ((items: SearchableItem[]) => void)[] = [];
1861
+ const source: SearchSource = {
1862
+ bootstrap: async () => [],
1863
+ search: async () =>
1864
+ new Promise<SearchableItem[]>(resolve => {
1865
+ resolvers.push(resolve);
1866
+ }),
1867
+ };
1868
+ return {source, resolvers};
1869
+ }
1870
+
1871
+ beforeEach(() => {
1872
+ announce.mockClear();
1873
+ });
1874
+
1875
+ it('shows the loading row without a live role (FR2)', () => {
1876
+ const {source} = createPendingSource();
1877
+ render(
1878
+ <ChatComposerInput
1879
+ triggers={[createMentionTrigger({searchSource: source})]}
1880
+ debounceMs={0}
1881
+ />,
1882
+ );
1883
+ const editable = screen.getByRole('combobox');
1884
+ editable.focus();
1885
+ typeAtEnd(editable, '@c');
1886
+
1887
+ const row = screen.getByText('Searching…');
1888
+ expect(row).not.toHaveAttribute('role');
1889
+ expect(screen.queryByRole('status')).toBeNull();
1890
+ });
1891
+
1892
+ it('announces the loading text once per search, not on every keystroke (FR2, FR6)', async () => {
1893
+ const {source, resolvers} = createPendingSource();
1894
+ render(
1895
+ <StrictMode>
1896
+ <ChatComposerInput
1897
+ triggers={[createMentionTrigger({searchSource: source})]}
1898
+ debounceMs={0}
1899
+ />
1900
+ </StrictMode>,
1901
+ );
1902
+ const editable = screen.getByRole('combobox');
1903
+ editable.focus();
1904
+
1905
+ typeAtEnd(editable, '@c');
1906
+ await nextFrame();
1907
+ typeAtEnd(editable, '@ci');
1908
+ await nextFrame();
1909
+ expect(announce.mock.calls).toEqual([['Searching…', 'polite']]);
1910
+
1911
+ await act(async () => {
1912
+ resolvers.forEach(resolve =>
1913
+ resolve([{id: 'cindy', label: 'Cindy Zhang'}]),
1914
+ );
1915
+ });
1916
+ expect(screen.queryByText('Searching…')).toBeNull();
1917
+
1918
+ typeAtEnd(editable, '@cin');
1919
+ await nextFrame();
1920
+ expect(announce.mock.calls).toEqual([
1921
+ ['Searching…', 'polite'],
1922
+ ['Searching…', 'polite'],
1923
+ ]);
1924
+ });
1925
+
1926
+ it('announces nothing for a synchronous source, whose results are already listed (FR7)', async () => {
1927
+ render(
1928
+ <ChatComposerInput triggers={[createMentionTrigger()]} debounceMs={0} />,
1929
+ );
1930
+ const editable = screen.getByRole('combobox');
1931
+ editable.focus();
1932
+
1933
+ typeAtEnd(editable, '@c');
1934
+ await act(async () => {});
1935
+ typeAtEnd(editable, '@ci');
1936
+ await act(async () => {});
1937
+
1938
+ // jsdom gives the popover no semantics, so query the menu by its id.
1939
+ const menu = document.getElementById(
1940
+ editable.getAttribute('aria-controls')!,
1941
+ );
1942
+ expect(menu?.querySelector('[role="option"]')).toHaveTextContent(
1943
+ 'Cindy Zhang',
1944
+ );
1945
+ expect(screen.queryByText('Searching…')).toBeNull();
1946
+ expect(announce).not.toHaveBeenCalled();
1947
+ });
1948
+
1949
+ it('announces nothing for a search that settles before the row shows (FR7)', async () => {
1950
+ const settled: SearchSource = {
1951
+ bootstrap: async () => [],
1952
+ search: async () => [{id: 'cindy', label: 'Cindy Zhang'}],
1953
+ };
1954
+ render(
1955
+ <ChatComposerInput
1956
+ triggers={[createMentionTrigger({searchSource: settled})]}
1957
+ debounceMs={0}
1958
+ />,
1959
+ );
1960
+ const editable = screen.getByRole('combobox');
1961
+ editable.focus();
1962
+
1963
+ typeAtEnd(editable, '@c');
1964
+ await nextFrame();
1965
+ typeAtEnd(editable, '@ci');
1966
+ await nextFrame();
1967
+
1968
+ expect(screen.queryByText('Searching…')).toBeNull();
1969
+ expect(announce).not.toHaveBeenCalled();
1970
+ });
1971
+
1972
+ it("announces a trigger's own loadingText", async () => {
1973
+ const {source} = createPendingSource();
1974
+ render(
1975
+ <ChatComposerInput
1976
+ triggers={[
1977
+ createMentionTrigger({
1978
+ searchSource: source,
1979
+ loadingText: 'Looking up people…',
1980
+ }),
1981
+ ]}
1982
+ debounceMs={0}
1983
+ />,
1984
+ );
1985
+ const editable = screen.getByRole('combobox');
1986
+ editable.focus();
1987
+ typeAtEnd(editable, '@c');
1988
+ await nextFrame();
1989
+
1990
+ expect(announce.mock.calls).toEqual([['Looking up people…', 'polite']]);
1991
+ });
1992
+ });
@@ -32,6 +32,7 @@ import {
32
32
  } from 'react';
33
33
  import * as stylex from '@stylexjs/stylex';
34
34
  import {usePopover} from '../Popover/usePopover';
35
+ import {useAnnounce} from '../hooks/useAnnounce';
35
36
  import {useHighlightedOptionScroll} from '../hooks/useHighlightedOptionScroll';
36
37
  import {
37
38
  colorVars,
@@ -353,6 +354,38 @@ export function useTriggerMenu(
353
354
  isHidden: false,
354
355
  });
355
356
 
357
+ // The loading row carries no live role: it mounts already holding its text
358
+ // and is removed when results arrive, so it would be skipped. The shared
359
+ // channel announces each loading episode once instead (spec:AST-086 FR2,
360
+ // FR6), once the row has been painted: the second animation frame runs
361
+ // after the first paint, and a search that settles before then never shows
362
+ // the row, so it announces nothing (FR7). The flag clears when the loading
363
+ // row goes away, so the next search is a new occasion.
364
+ const announce = useAnnounce();
365
+ const hasAnnouncedLoadingRef = useRef(false);
366
+ // Mirrors renderMenu: no row exists without an active trigger or while hidden.
367
+ const isLoadingRowShown =
368
+ state.isLoading && !state.isHidden && state.activeTrigger !== null;
369
+ // One catalog string serves the row and the announcement (FR7).
370
+ const loadingText =
371
+ state.activeTrigger?.loadingText ?? t('@astryx.chatTriggerMenu.searching');
372
+ useEffect(() => {
373
+ if (!isLoadingRowShown) {
374
+ hasAnnouncedLoadingRef.current = false;
375
+ return;
376
+ }
377
+ if (hasAnnouncedLoadingRef.current) {
378
+ return;
379
+ }
380
+ let frame = requestAnimationFrame(() => {
381
+ frame = requestAnimationFrame(() => {
382
+ hasAnnouncedLoadingRef.current = true;
383
+ announce(loadingText, 'polite');
384
+ });
385
+ });
386
+ return () => cancelAnimationFrame(frame);
387
+ }, [announce, isLoadingRowShown, loadingText]);
388
+
356
389
  const triggerStartRef = useRef<number>(-1);
357
390
  const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
358
391
  const searchRequestIdRef = useRef<number>(0);
@@ -382,6 +415,12 @@ export function useTriggerMenu(
382
415
  if (isMenuSuppressedRef.current) {
383
416
  return;
384
417
  }
418
+ // A search still waiting out its debounce must not start for a menu
419
+ // that is gone.
420
+ if (searchTimeoutRef.current) {
421
+ clearTimeout(searchTimeoutRef.current);
422
+ searchTimeoutRef.current = null;
423
+ }
385
424
  searchRequestIdRef.current++;
386
425
  removeAnchorSpan();
387
426
  setState(prev => ({
@@ -555,8 +594,12 @@ export function useTriggerMenu(
555
594
  if (trigger.searchSource) {
556
595
  // Use SearchSource — cancel previous, then search
557
596
  trigger.searchSource.cancel?.();
558
- setState(prev => ({...prev, isLoading: true}));
559
597
  const result = trigger.searchSource.search(query);
598
+ // A synchronous source already has its results, so it never shows
599
+ // or announces the loading row (spec:AST-086 FR7).
600
+ if (!Array.isArray(result)) {
601
+ setState(prev => ({...prev, isLoading: true}));
602
+ }
560
603
  Promise.resolve(result).then(
561
604
  items => {
562
605
  if (!isCurrent()) {
@@ -873,14 +916,11 @@ export function useTriggerMenu(
873
916
  }
874
917
 
875
918
  const emptyText = activeTrigger.emptySearchText ?? 'No results';
876
- const loadingText = activeTrigger.loadingText ?? 'Searching\u2026';
877
919
 
878
920
  let listContent: ReactNode;
879
921
  if (state.isLoading) {
880
922
  listContent = (
881
- <div role="status" {...stylex.props(styles.loadingState)}>
882
- {loadingText}
883
- </div>
923
+ <div {...stylex.props(styles.loadingState)}>{loadingText}</div>
884
924
  );
885
925
  } else if (state.items.length === 0 && state.isActive) {
886
926
  listContent = <div {...stylex.props(styles.emptyState)}>{emptyText}</div>;
@@ -951,7 +991,16 @@ export function useTriggerMenu(
951
991
  xstyle: styles.popoverSurface,
952
992
  },
953
993
  );
954
- }, [popover, listboxId, state, selectItem, getItemId, highlightOnHover, t]);
994
+ }, [
995
+ popover,
996
+ listboxId,
997
+ state,
998
+ selectItem,
999
+ getItemId,
1000
+ highlightOnHover,
1001
+ t,
1002
+ loadingText,
1003
+ ]);
955
1004
 
956
1005
  return {
957
1006
  state,
@@ -35,6 +35,7 @@ import {
35
35
  import {useDevWarning} from '../hooks/useDevWarning';
36
36
  import {mergeRefs} from '../utils/mergeRefs';
37
37
  import {useMergedRefs} from '../hooks/useMergedRefs';
38
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
38
39
  import {Field, inputWrapperStyles, type FieldStatusVariant} from '../Field';
39
40
  import {Icon, renderIconSlot, type IconType} from '../Icon';
40
41
  import {Spinner} from '../Spinner';
@@ -547,6 +548,9 @@ export function ComplexSelector<Value>({
547
548
  const [isPending, startTransition] = useTransition();
548
549
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
549
550
  const isBusy = isLoading || isPending;
551
+ // The busy Spinner is decorative; this control announces its busy start
552
+ // (spec:AST-086 FR8).
553
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), triggerRef);
550
554
 
551
555
  const handlePopoverShow = useCallback(() => {
552
556
  onOpenChange?.(true);
@@ -790,7 +794,7 @@ export function ComplexSelector<Value>({
790
794
  {...stylex.props(styles.trigger)}>
791
795
  <span {...stylex.props(styles.triggerText)}>{triggerContent}</span>
792
796
  </button>
793
- {isBusy && <Spinner size="sm" />}
797
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
794
798
  <Icon
795
799
  icon="chevronDown"
796
800
  size="sm"
@@ -346,6 +346,40 @@ describe('ContextMenu', () => {
346
346
  expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled();
347
347
  });
348
348
 
349
+ it.each(['open', 'closed', 'nested'] as const)(
350
+ 'judges presses once inside a %s shadow root',
351
+ mode => {
352
+ const host = document.createElement('div');
353
+ document.body.append(host);
354
+ let root = host.attachShadow({mode: mode === 'open' ? 'open' : 'closed'});
355
+ if (mode === 'nested') {
356
+ const innerHost = document.createElement('div');
357
+ root.append(innerHost);
358
+ root = innerHost.attachShadow({mode: 'closed'});
359
+ }
360
+ const container = document.createElement('div');
361
+ root.append(container);
362
+ try {
363
+ render(
364
+ <ContextMenu items={[{label: 'Item 1'}]}>
365
+ <div>Right-click me</div>
366
+ </ContextMenu>,
367
+ {container},
368
+ );
369
+ fireEvent.contextMenu(container.querySelector('div div')!);
370
+ // At the document a press on the menu is retargeted to a shadow host.
371
+ const item = container.querySelector('[role="menuitem"]')!;
372
+ fireEvent.mouseDown(item, {composed: true});
373
+ expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled();
374
+ // A press on the host itself is outside the menu.
375
+ fireEvent.mouseDown(host);
376
+ expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled();
377
+ } finally {
378
+ host.remove();
379
+ }
380
+ },
381
+ );
382
+
349
383
  it('closes the menu when Tab is pressed inside it (APG menu pattern)', () => {
350
384
  render(
351
385
  <ContextMenu items={[{label: 'Item 1'}]}>
@@ -466,15 +466,27 @@ export function ContextMenu({
466
466
  if (!isOpen || usesBottomSheet) {
467
467
  return;
468
468
  }
469
- const handleClickOutside = (e: MouseEvent) => {
469
+ // Inside a shadow root, even a closed or nested one, the document sees a
470
+ // press on the menu as a press on an outer shadow host, so the menu's own
471
+ // tree judges its presses first and the document skips those.
472
+ const tree = listRef.current?.getRootNode();
473
+ const shadow = tree instanceof ShadowRoot ? tree : null;
474
+ const judged = new WeakSet<Event>();
475
+ const handleClickOutside = (e: Event) => {
470
476
  const menu = listRef.current;
477
+ if (judged.has(e)) {
478
+ return;
479
+ }
480
+ judged.add(e);
471
481
  if (menu && !menu.contains(e.target as Node)) {
472
482
  closeMenu();
473
483
  }
474
484
  };
475
485
  document.addEventListener('mousedown', handleClickOutside);
486
+ shadow?.addEventListener('mousedown', handleClickOutside);
476
487
  return () => {
477
488
  document.removeEventListener('mousedown', handleClickOutside);
489
+ shadow?.removeEventListener('mousedown', handleClickOutside);
478
490
  };
479
491
  }, [isOpen, closeMenu, listRef, usesBottomSheet]);
480
492
 
@@ -57,6 +57,7 @@ import {
57
57
  } from '../Calendar';
58
58
  import {useCalendarConstraints} from '../Calendar/hooks';
59
59
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
60
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
60
61
  import {useMediaQuery} from '../hooks/useMediaQuery';
61
62
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
62
63
  import {usePopover} from '../Popover';
@@ -547,6 +548,9 @@ function PointerDateField({
547
548
  const [, startTransition] = useTransition();
548
549
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
549
550
  const isBusy = isLoading || optimisticValue !== value;
551
+ // The busy Spinner is decorative; this control announces its busy start
552
+ // (spec:AST-086 FR8).
553
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), inputRef);
550
554
  const isEffectivelyDisabled = isDisabled || isBusy;
551
555
 
552
556
  // A disabled or busy field never renders native `disabled` (spec:AST-085
@@ -932,7 +936,7 @@ function PointerDateField({
932
936
  onClick={handleClear}
933
937
  />
934
938
  )}
935
- {isBusy && <Spinner size="sm" />}
939
+ {isBusy && <Spinner size="sm" aria-hidden="true" />}
936
940
  {statusIcon}
937
941
  {popover.render(
938
942
  <Calendar
@@ -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">