@astryxdesign/core 0.7.3 → 0.7.4-canary.3f6ea7d
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/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +46 -7
- 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/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/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 +2 -5
- package/src/BottomSheet/BottomSheet.tsx +260 -47
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
- package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
- package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
- 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/useTriggerMenu.tsx +55 -6
- 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/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/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 +470 -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
|
@@ -5,11 +5,22 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BottomSheetSwitcherContext.ts
|
|
7
7
|
* @input Uses React context
|
|
8
|
-
* @output Internal phase and
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
|
@@ -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
|
|
@@ -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">
|