@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.
- package/dist/BottomSheet/BottomSheet.d.ts +18 -6
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +268 -58
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +7 -11
- package/dist/Chat/ChatMessageList.d.ts.map +1 -1
- package/dist/Chat/ChatMessageList.js +8 -1
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +46 -7
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +6 -0
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPalette.js +6 -11
- package/dist/CommandPalette/CommandPaletteInput.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteInput.js +7 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +12 -0
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateInput/NativeDateField.d.ts.map +1 -1
- package/dist/DateInput/NativeDateField.js +6 -1
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +9 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +6 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +6 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +16 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -1
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +9 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +9 -1
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +9 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +7 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +9 -1
- package/dist/astryx.css +2 -0
- package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
- package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
- package/dist/hooks/useBusyAnnouncement.js +87 -0
- package/dist/hooks/useMenuPress.d.ts +2 -2
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +5 -1
- package/dist/hooks/useTypeahead.d.ts.map +1 -1
- package/dist/hooks/useTypeahead.js +7 -2
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +1 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
- package/locales/en.json +6 -2
- package/locales/pseudo.json +3 -0
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
- package/src/BottomSheet/BottomSheet.spec.md +3 -5
- package/src/BottomSheet/BottomSheet.tsx +260 -47
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +29 -4
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +35 -41
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +1051 -49
- package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
- package/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +461 -0
- package/src/Carousel/Carousel.test.tsx +8 -12
- package/src/Carousel/Carousel.tsx +6 -13
- package/src/Chat/ChatComposerInput.test.tsx +167 -1
- package/src/Chat/ChatMessageList.test.tsx +6 -2
- package/src/Chat/ChatMessageList.tsx +7 -1
- package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
- package/src/Chat/useTriggerMenu.tsx +55 -6
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/CommandPalette/CommandPalette.test.tsx +4 -2
- package/src/CommandPalette/CommandPalette.tsx +6 -11
- package/src/CommandPalette/CommandPaletteInput.test.tsx +3 -1
- package/src/CommandPalette/CommandPaletteInput.tsx +10 -1
- package/src/ComplexSelector/ComplexSelector.tsx +5 -1
- package/src/ContextMenu/ContextMenu.test.tsx +34 -0
- package/src/ContextMenu/ContextMenu.tsx +13 -1
- package/src/DateInput/DateInput.tsx +5 -1
- package/src/DateInput/DateInputTouch.test.tsx +45 -0
- package/src/DateInput/NativeDateField.test.tsx +10 -2
- package/src/DateInput/NativeDateField.tsx +5 -1
- package/src/DateInput/TouchDateField.tsx +14 -2
- package/src/DateRangeInput/DateRangeInput.tsx +5 -1
- package/src/DateTimeInput/DateTimeInput.tsx +5 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
- package/src/FileInput/FileInput.tsx +18 -2
- package/src/MultiSelector/MultiSelector.test.tsx +18 -4
- package/src/MultiSelector/MultiSelector.tsx +5 -1
- package/src/ProgressBar/ProgressBar.test.tsx +4 -21
- package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
- package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
- package/src/Selector/Selector.test.tsx +22 -5
- package/src/Selector/Selector.tsx +5 -1
- package/src/TextArea/TextArea.test.tsx +9 -1
- package/src/TextArea/TextArea.tsx +5 -1
- package/src/TextInput/TextInput.tsx +5 -1
- package/src/Thumbnail/Thumbnail.test.tsx +6 -1
- package/src/Thumbnail/Thumbnail.tsx +12 -3
- package/src/TimeInput/TimeInput.tsx +5 -1
- package/src/TimeInput/TouchTimeField.tsx +12 -2
- package/src/Tokenizer/Tokenizer.test.tsx +17 -12
- package/src/Tokenizer/Tokenizer.tsx +10 -1
- package/src/Typeahead/BaseTypeahead.tsx +14 -1
- package/src/Typeahead/Typeahead.test.tsx +32 -41
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/BusyOwnerAnnouncement.test.tsx +792 -0
- package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
- package/src/hooks/useBusyAnnouncement.ts +100 -0
- package/src/hooks/useMenuPress.test.tsx +27 -1
- package/src/hooks/useMenuPress.ts +12 -3
- package/src/hooks/useTypeahead.test.tsx +42 -1
- package/src/hooks/useTypeahead.ts +7 -2
- package/src/i18n/generated-locales/en.generated.ts +1 -0
- 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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
232
|
-
await page
|
|
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.
|
|
266
|
+
...(measures.loader
|
|
264
267
|
? {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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.
|
|
327
|
-
expect(observed.
|
|
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.
|
|
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
|
|
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
|
-
}, [
|
|
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('
|
|
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
|
-
|
|
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
|
|
321
|
+
// Announce search results to screen readers through the shared polite live
|
|
322
322
|
// region (comboboxes-7 announce path, mirroring Selector / BaseTypeahead).
|
|
323
|
-
// The
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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
|
-
//
|
|
411
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|