@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
@@ -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
+ });
@@ -182,9 +182,13 @@ describe('ChatMessageList', () => {
182
182
  );
183
183
  });
184
184
  expect(action).toHaveBeenCalledTimes(1);
185
- expect(screen.getByRole('status', {name: 'Loading'})).toBeTruthy();
185
+ // The loader Spinner inside the log is silent (spec:AST-086 FR4, FR8).
186
+ expect(document.querySelector('.astryx-spinner')).toHaveAttribute(
187
+ 'aria-hidden',
188
+ 'true',
189
+ );
186
190
  await act(async () => complete());
187
- expect(screen.queryByRole('status', {name: 'Loading'})).toBeNull();
191
+ expect(document.querySelector('.astryx-spinner')).toBeNull();
188
192
  unmount();
189
193
  expect(disconnect).toHaveBeenCalledTimes(1);
190
194
  } finally {
@@ -37,6 +37,8 @@ import {Spinner} from '../Spinner';
37
37
  import type {BaseProps} from '../BaseProps';
38
38
  import type {SpacingStep} from '../utils/types';
39
39
  import {themeProps} from '../utils/themeProps';
40
+ import {useTranslator} from '../i18n';
41
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
40
42
 
41
43
  export interface ChatMessageListProps extends BaseProps<HTMLDivElement> {
42
44
  /** Ref forwarded to the root element */
@@ -238,6 +240,10 @@ export function ChatMessageList({
238
240
  const sentinelRef = useRef<HTMLDivElement>(null);
239
241
  const innerRef = useRef<HTMLDivElement>(null);
240
242
  const [isLoadingTop, startTransition] = useTransition();
243
+ const t = useTranslator();
244
+ // The top loading Spinner sits inside the log and is silent; the list
245
+ // announces its loading through the channel (spec:AST-086 FR4, FR8).
246
+ useBusyAnnouncement(isLoadingTop, t('@astryx.spinner.loading'), innerRef);
241
247
 
242
248
  // Register inner content element with the layout for height observation
243
249
  useEffect(() => {
@@ -309,7 +315,7 @@ export function ChatMessageList({
309
315
  {/* Loading spinner at top */}
310
316
  {isLoadingTop && (
311
317
  <div {...stylex.props(styles.loadingTop)}>
312
- <Spinner size="md" />
318
+ <Spinner size="md" aria-hidden="true" />
313
319
  </div>
314
320
  )}
315
321
 
@@ -42,7 +42,8 @@ interface Case {
42
42
  direction?: 'ltr' | 'rtl';
43
43
  coarsePointer?: boolean;
44
44
  requiresOverflow?: boolean;
45
- statusName?: string;
45
+ /** The top loader is shown, silent inside the log (spec:AST-086 FR4, FR8). */
46
+ hiddenLoader?: boolean;
46
47
  }
47
48
 
48
49
  // Expectations are authored from the public fixture and component source, not
@@ -153,7 +154,7 @@ const CASES: Case[] = [
153
154
  density: 'balanced',
154
155
  busy: false,
155
156
  text: 'Earlier message remains readable',
156
- statusName: 'Loading',
157
+ hiddenLoader: true,
157
158
  },
158
159
  ];
159
160
 
@@ -228,8 +229,10 @@ async function capture(page: Page, scenario: Case, mode: 'light' | 'dark') {
228
229
  );
229
230
  const logs = page.getByRole('log');
230
231
  await logs.first().waitFor();
231
- if (scenario.statusName) {
232
- await page.getByRole('status', {name: scenario.statusName}).waitFor();
232
+ if (scenario.hiddenLoader) {
233
+ await page
234
+ .locator('[role="log"] .astryx-spinner[aria-hidden="true"]')
235
+ .waitFor();
233
236
  }
234
237
  await holdMotionStill(page);
235
238
  await page.evaluate(async () => document.fonts.ready);
@@ -260,17 +263,21 @@ async function capture(page: Page, scenario: Case, mode: 'light' | 'dark') {
260
263
  parentClientHeight: node.parentElement?.clientHeight ?? 0,
261
264
  }
262
265
  : {}),
263
- ...(measures.status
266
+ ...(measures.loader
264
267
  ? {
265
- statusName: node
266
- .querySelector('[role="status"]')
267
- ?.getAttribute('aria-label'),
268
+ hiddenLoader:
269
+ node
270
+ .querySelector('.astryx-spinner')
271
+ ?.closest('[aria-hidden="true"]') != null,
272
+ exposedStatus: [...node.querySelectorAll('[role="status"]')].some(
273
+ status => status.closest('[aria-hidden="true"]') == null,
274
+ ),
268
275
  }
269
276
  : {}),
270
277
  }),
271
278
  {
272
279
  overflow: Boolean(scenario.requiresOverflow),
273
- status: Boolean(scenario.statusName),
280
+ loader: Boolean(scenario.hiddenLoader),
274
281
  },
275
282
  );
276
283
  const environment = await page.evaluate(() => ({
@@ -323,8 +330,9 @@ async function capture(page: Page, scenario: Case, mode: 'light' | 'dark') {
323
330
  if (scenario.text != null) {
324
331
  expect(observed.text).toContain(scenario.text);
325
332
  }
326
- if (scenario.statusName != null) {
327
- expect(observed.statusName).toBe(scenario.statusName);
333
+ if (scenario.hiddenLoader) {
334
+ expect(observed.hiddenLoader).toBe(true);
335
+ expect(observed.exposedStatus).toBe(false);
328
336
  }
329
337
  expect(observed.width).toBeGreaterThan(0);
330
338
  expect(observed.height).toBeGreaterThan(0);
@@ -371,7 +379,7 @@ async function capture(page: Page, scenario: Case, mode: 'light' | 'dark') {
371
379
  text: scenario.text,
372
380
  coarsePointer: scenario.coarsePointer ?? false,
373
381
  ...(scenario.requiresOverflow ? {requiresOverflow: true} : {}),
374
- ...(scenario.statusName ? {statusName: scenario.statusName} : {}),
382
+ ...(scenario.hiddenLoader ? {hiddenLoader: true} : {}),
375
383
  },
376
384
  observed: {
377
385
  build: storybookSha,
@@ -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,
@@ -46,7 +46,9 @@ import {useTooltip} from '../Tooltip';
46
46
  import {mergeProps, rtlStyles} from '../utils';
47
47
  import {indicatorScope} from '../Indicator/indicator.markers.stylex';
48
48
  import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
49
+ import {useTranslator} from '../i18n';
49
50
  import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing';
51
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
50
52
  import {usePressFeedback} from '../hooks/usePressFeedback';
51
53
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
52
54
  import {useIndicator} from '../Indicator';
@@ -371,6 +373,10 @@ export function CheckboxInput({
371
373
  // `opacity: 0`, and only the indicator's own element can shape the outline
372
374
  // to match it. See useIndicatorFocusRing.
373
375
  const indicatorRef = useRef<HTMLSpanElement>(null);
376
+ const t = useTranslator();
377
+ // The busy Spinner sits in the decorative, aria-hidden indicator, so this
378
+ // control announces its busy start itself (spec:AST-086 FR8).
379
+ useBusyAnnouncement(isBusy, t('@astryx.spinner.loading'), indicatorRef);
374
380
  const {focusProps} = useIndicatorFocusRing(indicatorRef);
375
381
 
376
382
  const isIndeterminate = optimisticValue === 'indeterminate';
@@ -729,7 +729,7 @@ describe('CommandPalette', () => {
729
729
  });
730
730
  });
731
731
 
732
- it('does not announce on initial open (bootstrap)', async () => {
732
+ it('announces no result count on initial open (bootstrap)', async () => {
733
733
  render(
734
734
  <CommandPalette
735
735
  isOpen={true}
@@ -742,7 +742,9 @@ describe('CommandPalette', () => {
742
742
  await act(async () => {
743
743
  await new Promise(resolve => setTimeout(resolve, 50));
744
744
  });
745
- expect(politeRegion()?.textContent ?? '').toBe('');
745
+ // The bootstrap's busy start may say "Loading" (spec:AST-086 FR3, FR8);
746
+ // its results are never counted aloud.
747
+ expect(['', 'Loading']).toContain(politeRegion()?.textContent ?? '');
746
748
  });
747
749
  });
748
750
  });
@@ -318,11 +318,11 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
318
318
  const searchVersionRef = useRef(0);
319
319
  const emptyStateRef = useRef<HTMLDivElement>(null);
320
320
 
321
- // Announce search status to screen readers through the shared polite live
321
+ // Announce search results to screen readers through the shared polite live
322
322
  // region (comboboxes-7 announce path, mirroring Selector / BaseTypeahead).
323
- // The busy spinner and the empty states are otherwise purely visual, so a
324
- // screen-reader user typing a query would hear nothing when loading starts
325
- // or results disappear.
323
+ // The empty states are otherwise purely visual, so a screen-reader user
324
+ // typing a query would hear nothing when results disappear. The busy start
325
+ // is announced by CommandPaletteInput.
326
326
  const announce = useAnnounce();
327
327
 
328
328
  const value = controlledValue ?? internalValue;
@@ -407,13 +407,8 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
407
407
  startTransition(async () => {
408
408
  const isBootstrap = query === '';
409
409
 
410
- // Loading started for a user query: tell screen-reader users the
411
- // spinner appeared. The polite region coalesces rapid updates, so for
412
- // fast sources the result-count announcement below simply replaces
413
- // this instead of stacking one "Loading" per keystroke.
414
- if (!isBootstrap) {
415
- announce(t('@astryx.commandPalette.loading'));
416
- }
410
+ // The busy start ("Loading") is announced by CommandPaletteInput,
411
+ // from a region inside the palette's dialog (spec:AST-086 FR8, FR3).
417
412
 
418
413
  // Client-filter previous results for instant narrowing while fetch is in flight
419
414
  if (!isBootstrap && searchResults.length > 0) {
@@ -377,7 +377,9 @@ describe('CommandPaletteInput popup context', () => {
377
377
  </CommandPaletteContext>,
378
378
  );
379
379
 
380
- const spinner = screen.getByRole('status');
380
+ // The Spinner is decorative (spec:AST-086 FR8), so find it by its class.
381
+ const spinner = document.querySelector('.astryx-spinner') as HTMLElement;
382
+ expect(spinner).toHaveAttribute('aria-hidden', 'true');
381
383
  const trailingContent = screen.getByTestId('trailing-content');
382
384
  expect(trailingContent.parentElement?.firstElementChild).toContainElement(
383
385
  spinner,
@@ -36,6 +36,7 @@ import {
36
36
  import {useTranslator} from '../i18n';
37
37
 
38
38
  import {useMergedRefs} from '../hooks/useMergedRefs';
39
+ import {useBusyAnnouncement} from '../hooks/useBusyAnnouncement';
39
40
  const styles = stylex.create({
40
41
  wrapper: {
41
42
  display: 'flex',
@@ -190,6 +191,14 @@ export function CommandPaletteInput({
190
191
  const hasListbox = ctx ? ctx.selectableItems.length > 0 : true;
191
192
  const dialogContext = useDialogContext();
192
193
  const inputRef = useRef<HTMLInputElement>(null);
194
+ // The search Spinner is decorative; the input announces each busy start,
195
+ // including the load on opening or clearing the query. Inside the palette's
196
+ // native modal it does so from a region in the dialog (spec:AST-086 FR8, FR3).
197
+ useBusyAnnouncement(
198
+ ctx?.isBusy === true,
199
+ t('@astryx.commandPalette.loading'),
200
+ inputRef,
201
+ );
193
202
  const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
194
203
 
195
204
  // Text-entry controls match :focus-visible after pointer focus in Chromium.
@@ -335,7 +344,7 @@ export function CommandPaletteInput({
335
344
  <span {...stylex.props(styles.end)}>
336
345
  {ctx?.isBusy && (
337
346
  <span {...stylex.props(styles.icon, styles.spinner)}>
338
- <Spinner size="sm" />
347
+ <Spinner size="sm" aria-hidden="true" />
339
348
  </span>
340
349
  )}
341
350
  {hasEndContent && endContent}
@@ -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