@astryxdesign/core 0.7.1 → 0.7.2-canary.07f97c6
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/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/Drawer/Drawer.d.ts +191 -0
- package/dist/Drawer/Drawer.d.ts.map +1 -0
- package/dist/Drawer/Drawer.js +382 -0
- package/dist/Drawer/DrawerHeader.d.ts +85 -0
- package/dist/Drawer/DrawerHeader.d.ts.map +1 -0
- package/dist/Drawer/DrawerHeader.js +134 -0
- package/dist/Drawer/index.d.ts +11 -0
- package/dist/Drawer/index.d.ts.map +1 -0
- package/dist/Drawer/index.js +11 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts +42 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
- package/dist/Drawer/useDrawerDialogPresence.js +236 -0
- package/dist/DropdownMenu/DropdownMenu.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +4 -4
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +10 -2
- package/dist/Layer/layerViewportInset.stylex.d.ts +6 -2
- package/dist/Layer/layerViewportInset.stylex.d.ts.map +1 -1
- package/dist/Layer/layerViewportInset.stylex.js +6 -2
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +13 -7
- package/dist/Layer/useLayerDismissal.d.ts +1 -1
- package/dist/Layer/useLayerDismissal.js +1 -1
- package/dist/Layout/container.stylex.d.ts +5 -5
- package/dist/Layout/container.stylex.js +4 -4
- package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
- package/dist/Markdown/plugins/frontmatter.js +7 -3
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +22 -8
- package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/Popover/Popover.js +2 -2
- package/dist/PowerSearch/PowerSearch.js +1 -1
- package/dist/Selector/hooks.d.ts.map +1 -1
- package/dist/Selector/hooks.js +6 -2
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -1
- package/dist/TabList/TabMenu.d.ts +2 -2
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +6 -3
- package/dist/Text/useTruncation.d.ts +7 -5
- package/dist/Text/useTruncation.d.ts.map +1 -1
- package/dist/Text/useTruncation.js +16 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +26 -0
- package/dist/astryx.css +21 -10
- package/dist/hooks/useEntryAnimation.d.ts +2 -1
- package/dist/hooks/useEntryAnimation.d.ts.map +1 -1
- package/dist/hooks/useEntryAnimation.js +14 -12
- package/dist/hooks/useIsHydrating.d.ts +6 -0
- package/dist/hooks/useIsHydrating.d.ts.map +1 -0
- package/dist/hooks/useIsHydrating.js +30 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +112 -1
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +0 -3
- package/dist/utils/focusOutline.stylex.d.ts +4 -0
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +4 -0
- package/dist/utils/sharedMutationObserver.d.ts +21 -0
- package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
- package/dist/utils/sharedMutationObserver.js +69 -0
- package/locales/en.json +444 -0
- package/locales/pseudo.json +333 -0
- package/package.json +9 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/Drawer/Drawer.doc.mjs +472 -0
- package/src/Drawer/Drawer.spec.md +357 -0
- package/src/Drawer/Drawer.test.tsx +1300 -0
- package/src/Drawer/Drawer.tsx +613 -0
- package/src/Drawer/DrawerDirection.test.tsx +127 -0
- package/src/Drawer/DrawerHeader.doc.mjs +293 -0
- package/src/Drawer/DrawerHeader.test.tsx +102 -0
- package/src/Drawer/DrawerHeader.tsx +235 -0
- package/src/Drawer/__tests__/DrawerAudit.a11y.chromium.spec.ts +401 -0
- package/src/Drawer/__tests__/DrawerHeaderAudit.a11y.chromium.spec.ts +297 -0
- package/src/Drawer/index.ts +13 -0
- package/src/Drawer/useDrawerDialogPresence.ts +284 -0
- package/src/DropdownMenu/DropdownMenu.tsx +2 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +2 -2
- package/src/HoverCard/HoverCard.test.tsx +58 -0
- package/src/HoverCard/useHoverCard.tsx +10 -2
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/Layer/layerViewportInset.source-build.test.mjs +90 -0
- package/src/Layer/layerViewportInset.stylex.ts +6 -2
- package/src/Layer/layerViewportInset.test.ts +1 -0
- package/src/Layer/useLayer.tsx +24 -13
- package/src/Layer/useLayerDismissal.ts +1 -1
- package/src/Layout/container.stylex.ts +6 -6
- package/src/List/ListItem.doc.mjs +8 -5
- package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
- package/src/Markdown/plugins/frontmatter.ts +7 -3
- package/src/MultiSelector/MultiSelector.test.tsx +159 -0
- package/src/MultiSelector/MultiSelector.tsx +40 -7
- package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/Popover/Popover.test.tsx +2 -2
- package/src/Popover/Popover.tsx +4 -4
- package/src/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/Selector/hooks.ts +6 -2
- package/src/Selector/useCombobox.doc.mjs +237 -0
- package/src/Switch/Switch.test.tsx +49 -0
- package/src/Switch/Switch.tsx +11 -0
- package/src/TabList/TabList.test.tsx +67 -0
- package/src/TabList/TabMenu.doc.mjs +4 -3
- package/src/TabList/TabMenu.tsx +7 -4
- package/src/Text/useTruncation.test.tsx +69 -0
- package/src/Text/useTruncation.ts +16 -5
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- package/src/Tooltip/Tooltip.test.tsx +31 -0
- package/src/Tooltip/useTooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +11 -0
- package/src/Typeahead/BaseTypeahead.tsx +26 -0
- package/src/Typeahead/Typeahead.test.tsx +260 -2
- package/src/hooks/useEntryAnimation.doc.mjs +4 -4
- package/src/hooks/useEntryAnimation.ssr.test.tsx +143 -0
- package/src/hooks/useEntryAnimation.ts +15 -16
- package/src/hooks/useIsHydrating.test.tsx +70 -0
- package/src/hooks/useIsHydrating.ts +31 -0
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +121 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
- package/src/index.ts +1 -0
- package/src/theme/derivedVarRegistry.test.ts +0 -3
- package/src/theme/derivedVarRegistry.ts +0 -3
- package/src/utils/focusOutline.stylex.ts +4 -0
- package/src/utils/sharedMutationObserver.test.ts +53 -0
- package/src/utils/sharedMutationObserver.ts +78 -0
|
@@ -183,6 +183,37 @@ describe('Tooltip', () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
describe('icon-only trigger', () => {
|
|
187
|
+
// A rendered icon is an SVGElement, and ShowPopoverOptions.source is
|
|
188
|
+
// declared as an HTMLElement. Handing the icon to showPopover throws
|
|
189
|
+
// during dictionary conversion, before the popover opens, so the hint
|
|
190
|
+
// never appeared. (#7226)
|
|
191
|
+
it('opens an icon trigger without passing an SVGElement as the source', async () => {
|
|
192
|
+
const showSpy = vi.mocked(HTMLElement.prototype.showPopover);
|
|
193
|
+
showSpy.mockClear();
|
|
194
|
+
|
|
195
|
+
render(
|
|
196
|
+
<Tooltip content="Shared memory" isDefaultOpen>
|
|
197
|
+
<svg data-testid="icon" aria-hidden="true" />
|
|
198
|
+
</Tooltip>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(showSpy).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const [options] = showSpy.mock.calls[0];
|
|
206
|
+
// Compared as a boolean on purpose: a plain `toBeUndefined` failure makes
|
|
207
|
+
// the diff printer walk the SVGElement, which it cannot inspect.
|
|
208
|
+
expect(options?.source === undefined).toBe(true);
|
|
209
|
+
|
|
210
|
+
// The icon is still the trigger the ARIA tooltip pattern is wired to.
|
|
211
|
+
expect(screen.getByTestId('icon').getAttribute('aria-describedby')).toBe(
|
|
212
|
+
screen.getByRole('tooltip', {hidden: true}).id,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
186
217
|
describe('WCAG 1.4.13 — content on hover or focus', () => {
|
|
187
218
|
it('dismisses on Escape while visible (dismissible)', async () => {
|
|
188
219
|
const onOpenChange = vi.fn();
|
|
@@ -147,6 +147,19 @@ describe('useTooltip — show delay', () => {
|
|
|
147
147
|
|
|
148
148
|
expect(onShow).not.toHaveBeenCalled();
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
it('drops a pending show when isEnabled turns false before the delay elapses', () => {
|
|
152
|
+
const onShow = vi.fn();
|
|
153
|
+
const {rerender} = render(<Harness onShow={onShow} />);
|
|
154
|
+
|
|
155
|
+
fireEvent.mouseEnter(trigger());
|
|
156
|
+
tick(100);
|
|
157
|
+
rerender(<Harness isEnabled={false} onShow={onShow} />);
|
|
158
|
+
tick(1000);
|
|
159
|
+
|
|
160
|
+
expect(onShow).not.toHaveBeenCalled();
|
|
161
|
+
expect(tooltipElement().matches(':popover-open')).toBe(false);
|
|
162
|
+
});
|
|
150
163
|
});
|
|
151
164
|
|
|
152
165
|
describe('useTooltip — touch suppression', () => {
|
|
@@ -352,6 +352,17 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
|
|
|
352
352
|
}, delay);
|
|
353
353
|
}, [isEnabled, isOpen, clearTimeouts, showLayer, delay]);
|
|
354
354
|
|
|
355
|
+
// A show scheduled while the triggers were enabled must not outlive them:
|
|
356
|
+
// disabling also detaches the leave listener that would have cancelled it,
|
|
357
|
+
// so the timer would open a tooltip that nothing ever closes. Only the
|
|
358
|
+
// pending show is dropped; a pending hide still runs.
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (!isEnabled && showTimeoutRef.current) {
|
|
361
|
+
clearTimeout(showTimeoutRef.current);
|
|
362
|
+
showTimeoutRef.current = null;
|
|
363
|
+
}
|
|
364
|
+
}, [isEnabled]);
|
|
365
|
+
|
|
355
366
|
// Schedule hide with delay (suppressed when isOpen is true).
|
|
356
367
|
// A small hover bridge (when hideDelay is 0) lets the pointer travel from the
|
|
357
368
|
// trigger onto the tooltip surface without the tooltip vanishing — required
|
|
@@ -624,6 +624,32 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
624
624
|
sourceGenRef.current = ++searchGenRef.current;
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
+
// The previous source is remembered in state, not a ref, so a render React
|
|
628
|
+
// discards cannot consume the comparison without its state updates.
|
|
629
|
+
const [sourceOfState, setSourceOfState] = useState(searchSource);
|
|
630
|
+
if (sourceOfState !== searchSource) {
|
|
631
|
+
setSourceOfState(searchSource);
|
|
632
|
+
if (isLoading) {
|
|
633
|
+
setIsLoading(false);
|
|
634
|
+
setHasSearched(false);
|
|
635
|
+
}
|
|
636
|
+
loadingRef.current = false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// When the search source is replaced, any in-flight load from the previous
|
|
640
|
+
// source is abandoned (generation bumped above). Notify the wrapper's busy lane
|
|
641
|
+
// in an effect so external store listeners run safely after commit.
|
|
642
|
+
const isInitialMountRef = useRef(true);
|
|
643
|
+
useEffect(() => {
|
|
644
|
+
if (isInitialMountRef.current) {
|
|
645
|
+
isInitialMountRef.current = false;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!loadingRef.current) {
|
|
649
|
+
onLoadingChangeRef.current?.(false);
|
|
650
|
+
}
|
|
651
|
+
}, [searchSource]);
|
|
652
|
+
|
|
627
653
|
// Layer for dropdown
|
|
628
654
|
const handleLayerShow = useCallback(() => {
|
|
629
655
|
onOpenChange?.(true);
|
|
@@ -17,10 +17,20 @@ import {
|
|
|
17
17
|
beforeAll,
|
|
18
18
|
afterAll,
|
|
19
19
|
beforeEach,
|
|
20
|
+
afterEach,
|
|
20
21
|
} from 'vitest';
|
|
21
22
|
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
|
|
22
23
|
import userEvent from '@testing-library/user-event';
|
|
23
|
-
import {
|
|
24
|
+
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
25
|
+
import {
|
|
26
|
+
createRef,
|
|
27
|
+
Profiler,
|
|
28
|
+
startTransition,
|
|
29
|
+
StrictMode,
|
|
30
|
+
Suspense,
|
|
31
|
+
useState,
|
|
32
|
+
type ProfilerOnRenderCallback,
|
|
33
|
+
} from 'react';
|
|
24
34
|
import {Typeahead} from './Typeahead';
|
|
25
35
|
import {readFile} from 'node:fs/promises';
|
|
26
36
|
import {resolve} from 'node:path';
|
|
@@ -70,6 +80,10 @@ afterAll(() => {
|
|
|
70
80
|
(HTMLElement.prototype as any).matches = originalMatches;
|
|
71
81
|
});
|
|
72
82
|
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
__resetLiveRegionsForTest();
|
|
85
|
+
});
|
|
86
|
+
|
|
73
87
|
// Test data
|
|
74
88
|
const fruits: SearchableItem[] = [
|
|
75
89
|
{id: '1', label: 'Apple'},
|
|
@@ -964,6 +978,193 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
|
|
|
964
978
|
).not.toBeInTheDocument();
|
|
965
979
|
});
|
|
966
980
|
|
|
981
|
+
it('clears the loading state when the search source is replaced during an in-flight load', async () => {
|
|
982
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
983
|
+
const bootstrap = vi.fn(
|
|
984
|
+
async () =>
|
|
985
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
986
|
+
settle = resolve;
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
const initialSource: SearchSource = {search: () => [], bootstrap};
|
|
990
|
+
const replacementSource: SearchSource = {
|
|
991
|
+
search: () => [],
|
|
992
|
+
bootstrap: () => [],
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const {rerender} = render(
|
|
996
|
+
<BaseTypeahead
|
|
997
|
+
searchSource={initialSource}
|
|
998
|
+
value={null}
|
|
999
|
+
onChange={() => {}}
|
|
1000
|
+
hasEntriesOnFocus
|
|
1001
|
+
/>,
|
|
1002
|
+
);
|
|
1003
|
+
const input = screen.getByRole('combobox');
|
|
1004
|
+
|
|
1005
|
+
fireEvent.focus(input);
|
|
1006
|
+
await waitFor(() => {
|
|
1007
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
rerender(
|
|
1011
|
+
<BaseTypeahead
|
|
1012
|
+
searchSource={replacementSource}
|
|
1013
|
+
value={null}
|
|
1014
|
+
onChange={() => {}}
|
|
1015
|
+
hasEntriesOnFocus
|
|
1016
|
+
/>,
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
await waitFor(() => {
|
|
1020
|
+
expect(
|
|
1021
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1022
|
+
).not.toBeInTheDocument();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
await act(async () => {
|
|
1026
|
+
settle([]);
|
|
1027
|
+
await Promise.resolve();
|
|
1028
|
+
});
|
|
1029
|
+
expect(
|
|
1030
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1031
|
+
).not.toBeInTheDocument();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('keeps the loading state under StrictMode for a pending autofocus bootstrap', async () => {
|
|
1035
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1036
|
+
const bootstrap = vi.fn(
|
|
1037
|
+
async () =>
|
|
1038
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1039
|
+
settle = resolve;
|
|
1040
|
+
}),
|
|
1041
|
+
);
|
|
1042
|
+
render(
|
|
1043
|
+
<StrictMode>
|
|
1044
|
+
<Typeahead
|
|
1045
|
+
label="Test"
|
|
1046
|
+
searchSource={{search: () => [], bootstrap}}
|
|
1047
|
+
value={null}
|
|
1048
|
+
onChange={() => {}}
|
|
1049
|
+
hasAutoFocus
|
|
1050
|
+
hasEntriesOnFocus
|
|
1051
|
+
/>
|
|
1052
|
+
</StrictMode>,
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1056
|
+
|
|
1057
|
+
await act(async () => {
|
|
1058
|
+
settle([]);
|
|
1059
|
+
await Promise.resolve();
|
|
1060
|
+
});
|
|
1061
|
+
expect(
|
|
1062
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1063
|
+
).not.toBeInTheDocument();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
let settlePendingTransitionA: (items: SearchableItem[]) => void = () => {};
|
|
1067
|
+
const stableTransitionSourceA: SearchSource = {
|
|
1068
|
+
search: vi.fn(
|
|
1069
|
+
async () =>
|
|
1070
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1071
|
+
settlePendingTransitionA = resolve;
|
|
1072
|
+
}),
|
|
1073
|
+
),
|
|
1074
|
+
bootstrap: () => [],
|
|
1075
|
+
};
|
|
1076
|
+
const stableTransitionSourceB: SearchSource = {
|
|
1077
|
+
search: vi.fn(async () => []),
|
|
1078
|
+
bootstrap: () => [],
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
it('clears loading and aria-busy when source is replaced in a transition that suspends and retries', async () => {
|
|
1082
|
+
let isSuspended = false;
|
|
1083
|
+
let resolveSuspension: () => void = () => {};
|
|
1084
|
+
let pendingSuspension: Promise<void> | null = null;
|
|
1085
|
+
|
|
1086
|
+
function SuspendingSibling({shouldSuspend}: {shouldSuspend: boolean}) {
|
|
1087
|
+
if (shouldSuspend && isSuspended && pendingSuspension) {
|
|
1088
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
1089
|
+
throw pendingSuspension;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let updateState!: (source: SearchSource, shouldSuspend: boolean) => void;
|
|
1095
|
+
|
|
1096
|
+
function TransitionContainer() {
|
|
1097
|
+
const [source, setSource] = useState<SearchSource>(
|
|
1098
|
+
stableTransitionSourceA,
|
|
1099
|
+
);
|
|
1100
|
+
const [shouldSuspend, setShouldSuspend] = useState(false);
|
|
1101
|
+
updateState = (newSource, suspend) => {
|
|
1102
|
+
setSource(newSource);
|
|
1103
|
+
setShouldSuspend(suspend);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
return (
|
|
1107
|
+
<Suspense fallback={<div>Suspended...</div>}>
|
|
1108
|
+
<Typeahead
|
|
1109
|
+
label="Test"
|
|
1110
|
+
searchSource={source}
|
|
1111
|
+
value={null}
|
|
1112
|
+
onChange={() => {}}
|
|
1113
|
+
debounceMs={0}
|
|
1114
|
+
/>
|
|
1115
|
+
<SuspendingSibling shouldSuspend={shouldSuspend} />
|
|
1116
|
+
</Suspense>
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
render(<TransitionContainer />);
|
|
1121
|
+
const input = screen.getByRole('combobox');
|
|
1122
|
+
|
|
1123
|
+
// Trigger a load so aria-busy="true" is set on the combobox
|
|
1124
|
+
fireEvent.change(input, {target: {value: 'query'}});
|
|
1125
|
+
await waitFor(() => {
|
|
1126
|
+
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1127
|
+
});
|
|
1128
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1129
|
+
|
|
1130
|
+
// Create the pending promise for suspension
|
|
1131
|
+
isSuspended = true;
|
|
1132
|
+
pendingSuspension = new Promise<void>(resolve => {
|
|
1133
|
+
resolveSuspension = () => {
|
|
1134
|
+
isSuspended = false;
|
|
1135
|
+
resolve();
|
|
1136
|
+
};
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// Inside startTransition, switch from source A to source B while sibling suspends
|
|
1140
|
+
act(() => {
|
|
1141
|
+
startTransition(() => {
|
|
1142
|
+
updateState(stableTransitionSourceB, true);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// The render suspended, so the previous committed state remains
|
|
1147
|
+
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
1148
|
+
|
|
1149
|
+
// Resolve the pending promise and let the transition commit
|
|
1150
|
+
await act(async () => {
|
|
1151
|
+
resolveSuspension();
|
|
1152
|
+
await pendingSuspension;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Settle the abandoned load
|
|
1156
|
+
await act(async () => {
|
|
1157
|
+
settlePendingTransitionA([]);
|
|
1158
|
+
await Promise.resolve();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// The combobox must not keep aria-busy and the spinner must be gone
|
|
1162
|
+
expect(input).not.toHaveAttribute('aria-busy');
|
|
1163
|
+
expect(
|
|
1164
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1165
|
+
).not.toBeInTheDocument();
|
|
1166
|
+
});
|
|
1167
|
+
|
|
967
1168
|
it('shows bootstrap results on mouse click', async () => {
|
|
968
1169
|
render(
|
|
969
1170
|
<BaseTypeahead
|
|
@@ -1323,6 +1524,63 @@ describe('BaseTypeahead hasSearched reset', () => {
|
|
|
1323
1524
|
// "No results found" should disappear since hasSearched is reset
|
|
1324
1525
|
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
|
|
1325
1526
|
});
|
|
1527
|
+
|
|
1528
|
+
it('resets hasSearched and does not announce "No results found" when search source is replaced during a pending search', async () => {
|
|
1529
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1530
|
+
const initialSource: SearchSource = {
|
|
1531
|
+
search: vi.fn(
|
|
1532
|
+
async () =>
|
|
1533
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1534
|
+
settle = resolve;
|
|
1535
|
+
}),
|
|
1536
|
+
),
|
|
1537
|
+
bootstrap: () => [],
|
|
1538
|
+
};
|
|
1539
|
+
const replacementSource: SearchSource = {
|
|
1540
|
+
search: vi.fn(async () => []),
|
|
1541
|
+
bootstrap: () => [],
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
const {rerender} = render(
|
|
1545
|
+
<BaseTypeahead
|
|
1546
|
+
searchSource={initialSource}
|
|
1547
|
+
value={null}
|
|
1548
|
+
onChange={() => {}}
|
|
1549
|
+
debounceMs={0}
|
|
1550
|
+
/>,
|
|
1551
|
+
);
|
|
1552
|
+
const input = screen.getByRole('combobox');
|
|
1553
|
+
|
|
1554
|
+
// Type a query that starts an asynchronous search
|
|
1555
|
+
fireEvent.change(input, {target: {value: 'abc'}});
|
|
1556
|
+
expect(initialSource.search).toHaveBeenCalledWith('abc');
|
|
1557
|
+
|
|
1558
|
+
// Replace the search source while the search is still pending
|
|
1559
|
+
rerender(
|
|
1560
|
+
<BaseTypeahead
|
|
1561
|
+
searchSource={replacementSource}
|
|
1562
|
+
value={null}
|
|
1563
|
+
onChange={() => {}}
|
|
1564
|
+
debounceMs={0}
|
|
1565
|
+
/>,
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
// Settle the abandoned search
|
|
1569
|
+
await act(async () => {
|
|
1570
|
+
settle([]);
|
|
1571
|
+
await Promise.resolve();
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
await act(
|
|
1575
|
+
async () =>
|
|
1576
|
+
new Promise<void>(resolve => requestAnimationFrame(() => resolve())),
|
|
1577
|
+
);
|
|
1578
|
+
expect(
|
|
1579
|
+
document.querySelector('[data-astryx-live-region="polite"]')
|
|
1580
|
+
?.textContent ?? '',
|
|
1581
|
+
).not.toContain('No results found');
|
|
1582
|
+
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
|
|
1583
|
+
});
|
|
1326
1584
|
});
|
|
1327
1585
|
|
|
1328
1586
|
describe('BaseTypeahead popover after selection', () => {
|
|
@@ -2313,7 +2571,7 @@ describe('Typeahead consumer key handler order', () => {
|
|
|
2313
2571
|
currentTarget = event.currentTarget;
|
|
2314
2572
|
event.preventDefault();
|
|
2315
2573
|
});
|
|
2316
|
-
|
|
2574
|
+
render(
|
|
2317
2575
|
<Typeahead
|
|
2318
2576
|
label="Fruit"
|
|
2319
2577
|
searchSource={fruitSource}
|
|
@@ -18,12 +18,12 @@ export const docs = {
|
|
|
18
18
|
{
|
|
19
19
|
name: 'entryStyle',
|
|
20
20
|
type: 'StyleXStyles | null',
|
|
21
|
-
description: 'A StyleX style object for the entry animation, or null if the element was rendered on initial page load (no animation needed).',
|
|
21
|
+
description: 'A StyleX style object for the entry animation, or null if the element was rendered on initial page load or hydrated from server HTML (no animation needed).',
|
|
22
22
|
},
|
|
23
23
|
],
|
|
24
24
|
usage: {
|
|
25
25
|
description:
|
|
26
|
-
'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing.
|
|
26
|
+
'Returns a StyleX style for animating an element on mount. Only animates when the element is dynamically inserted after the initial page paint; elements rendered on page load are not animated. Uses Astryx motion tokens (duration, easing) for consistent animation timing. Server-rendered elements hydrate without the entry style and never animate; elements mounted after hydration animate.',
|
|
27
27
|
bestPractices: [
|
|
28
28
|
{ guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, or expanding sections.' },
|
|
29
29
|
{ guidance: true, description: 'Spread the returned style into stylex.props() alongside other styles.' },
|
|
@@ -39,7 +39,7 @@ export const docs = {
|
|
|
39
39
|
/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
|
|
40
40
|
export const docsDense = {
|
|
41
41
|
description:
|
|
42
|
-
'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing.
|
|
42
|
+
'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Server-rendered elements hydrate without entry style, never animate; elements mounted after hydration animate.',
|
|
43
43
|
paramDescriptions: {
|
|
44
44
|
preset: 'animation preset applied on mount.',
|
|
45
45
|
},
|
|
@@ -48,7 +48,7 @@ export const docsDense = {
|
|
|
48
48
|
},
|
|
49
49
|
usage: {
|
|
50
50
|
description:
|
|
51
|
-
'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing.
|
|
51
|
+
'Returns StyleX style for animating element on mount. Only animates when element dynamically inserted after initial page paint; elements rendered on page load not animated. Uses Astryx motion tokens (duration, easing) for consistent timing. Server-rendered elements hydrate without entry style, never animate; elements mounted after hydration animate.',
|
|
52
52
|
bestPractices: [
|
|
53
53
|
{ guidance: true, description: 'Use for conditionally rendered elements like validation messages, toasts, expanding sections.' },
|
|
54
54
|
{ guidance: true, description: 'Spread returned style into stylex.props() alongside other styles.' },
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useEntryAnimation.ssr.test.tsx
|
|
5
|
+
* @input FieldStatus server markup, React hydration, and client mounts
|
|
6
|
+
* @output Proves server-rendered entry-animated elements hydrate without mismatches
|
|
7
|
+
* @position SSR acceptance test for useEntryAnimation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {act, useState} from 'react';
|
|
11
|
+
import {afterEach, describe, expect, it, vi} from 'vitest';
|
|
12
|
+
import {createRoot, hydrateRoot} from 'react-dom/client';
|
|
13
|
+
import {renderToString} from 'react-dom/server';
|
|
14
|
+
|
|
15
|
+
// Each test imports a fresh copy of FieldStatus with the hook's first-frame
|
|
16
|
+
// callback held back, so server markup renders before the page "paints" (as on
|
|
17
|
+
// a real server) and `paint()` then runs that frame.
|
|
18
|
+
async function importFieldStatus() {
|
|
19
|
+
const frames: FrameRequestCallback[] = [];
|
|
20
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
21
|
+
frames.push(callback),
|
|
22
|
+
);
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
const {FieldStatus} = await import('../FieldStatus');
|
|
25
|
+
vi.unstubAllGlobals();
|
|
26
|
+
const paint = () => {
|
|
27
|
+
frames.splice(0).forEach(callback => callback(performance.now()));
|
|
28
|
+
};
|
|
29
|
+
return {FieldStatus, paint};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mountServerHTML(html: string): HTMLDivElement {
|
|
33
|
+
const container = document.createElement('div');
|
|
34
|
+
container.innerHTML = html;
|
|
35
|
+
document.body.appendChild(container);
|
|
36
|
+
return container;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function classesOf(element: Element | null): string[] {
|
|
40
|
+
return (element?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
document.body.replaceChildren();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('useEntryAnimation — SSR', () => {
|
|
50
|
+
it('hydrates server-rendered FieldStatus after the first paint without a mismatch', async () => {
|
|
51
|
+
const {FieldStatus, paint} = await importFieldStatus();
|
|
52
|
+
const tree = <FieldStatus type="error" message="x" />;
|
|
53
|
+
const serverHTML = renderToString(tree);
|
|
54
|
+
const container = mountServerHTML(serverHTML);
|
|
55
|
+
const serverClassName = container.firstElementChild?.className;
|
|
56
|
+
paint();
|
|
57
|
+
|
|
58
|
+
const consoleError = vi
|
|
59
|
+
.spyOn(console, 'error')
|
|
60
|
+
.mockImplementation(() => {});
|
|
61
|
+
const recoverableErrors: unknown[] = [];
|
|
62
|
+
let root!: ReturnType<typeof hydrateRoot>;
|
|
63
|
+
await act(async () => {
|
|
64
|
+
root = hydrateRoot(container, tree, {
|
|
65
|
+
onRecoverableError: error => recoverableErrors.push(error),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
consoleError.mock.calls.filter(call =>
|
|
71
|
+
String(call[0] ?? '')
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.includes('hydrat'),
|
|
74
|
+
),
|
|
75
|
+
).toEqual([]);
|
|
76
|
+
expect(recoverableErrors).toEqual([]);
|
|
77
|
+
expect(container.firstElementChild?.className).toBe(serverClassName);
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
root.unmount();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('still animates a client-only mount after the first paint', async () => {
|
|
85
|
+
const {FieldStatus, paint} = await importFieldStatus();
|
|
86
|
+
const staticClasses = classesOf(
|
|
87
|
+
mountServerHTML(renderToString(<FieldStatus type="error" message="x" />))
|
|
88
|
+
.firstElementChild,
|
|
89
|
+
);
|
|
90
|
+
paint();
|
|
91
|
+
|
|
92
|
+
const container = document.createElement('div');
|
|
93
|
+
document.body.appendChild(container);
|
|
94
|
+
const root = createRoot(container);
|
|
95
|
+
await act(async () => {
|
|
96
|
+
root.render(<FieldStatus type="error" message="x" />);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const clientClasses = classesOf(container.firstElementChild);
|
|
100
|
+
expect(clientClasses).toEqual(expect.arrayContaining(staticClasses));
|
|
101
|
+
expect(clientClasses.length).toBeGreaterThan(staticClasses.length);
|
|
102
|
+
|
|
103
|
+
await act(async () => {
|
|
104
|
+
root.unmount();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('animates a FieldStatus mounted after hydration', async () => {
|
|
109
|
+
const {FieldStatus, paint} = await importFieldStatus();
|
|
110
|
+
let showSecond!: () => void;
|
|
111
|
+
function Form() {
|
|
112
|
+
const [second, setSecond] = useState(false);
|
|
113
|
+
showSecond = () => setSecond(true);
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<FieldStatus type="error" message="first" />
|
|
117
|
+
{second ? <FieldStatus type="error" message="second" /> : null}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const container = mountServerHTML(renderToString(<Form />));
|
|
122
|
+
const staticClasses = classesOf(container.firstElementChild);
|
|
123
|
+
paint();
|
|
124
|
+
|
|
125
|
+
let root!: ReturnType<typeof hydrateRoot>;
|
|
126
|
+
await act(async () => {
|
|
127
|
+
root = hydrateRoot(container, <Form />);
|
|
128
|
+
});
|
|
129
|
+
await act(async () => {
|
|
130
|
+
showSecond();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const mounted = container.children[1] ?? null;
|
|
134
|
+
expect(mounted).toHaveTextContent('second');
|
|
135
|
+
const mountedClasses = classesOf(mounted);
|
|
136
|
+
expect(mountedClasses).toEqual(expect.arrayContaining(staticClasses));
|
|
137
|
+
expect(mountedClasses.length).toBeGreaterThan(staticClasses.length);
|
|
138
|
+
|
|
139
|
+
await act(async () => {
|
|
140
|
+
root.unmount();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -3,29 +3,27 @@
|
|
|
3
3
|
'use client';
|
|
4
4
|
/**
|
|
5
5
|
* @file useEntryAnimation.ts
|
|
6
|
-
* @input Uses React, StyleX, theme tokens
|
|
6
|
+
* @input Uses React, StyleX, theme tokens, useIsHydrating
|
|
7
7
|
* @output Exports useEntryAnimation hook for mount-only entry animations
|
|
8
8
|
* @position Hook utility; consumed by FieldStatus and available to consumers
|
|
9
9
|
*
|
|
10
10
|
* Provides entry animations for conditionally rendered elements.
|
|
11
11
|
* Only animates when the element is dynamically mounted after the initial
|
|
12
12
|
* page paint — statically rendered elements on page load are not animated.
|
|
13
|
+
* Server-rendered elements hydrate without the entry style and never play it
|
|
14
|
+
* (spec:AST-013 FR13).
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import {useState} from 'react';
|
|
16
18
|
import * as stylex from '@stylexjs/stylex';
|
|
17
19
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
18
20
|
import {durationVars, easeVars, spacingVars} from '../theme/tokens.stylex';
|
|
21
|
+
import {useIsHydrating} from './useIsHydrating';
|
|
19
22
|
|
|
20
|
-
// Track whether the initial page paint has completed
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// NOTE: This module is marked 'use client' so it never runs on the server.
|
|
25
|
-
// If the directive is removed, this flag will always be false during SSR,
|
|
26
|
-
// and useState(() => initialPaintComplete) will capture false — meaning
|
|
27
|
-
// animations won't run after hydration. Keep 'use client' or add an
|
|
28
|
-
// effect-based fallback if SSR support is needed.
|
|
23
|
+
// Track whether the initial page paint has completed, so elements a
|
|
24
|
+
// client-only root renders on page load do not animate; only dynamically
|
|
25
|
+
// inserted ones should. Server-rendered elements are covered by
|
|
26
|
+
// useIsHydrating instead: hydration can run after this flag turns true.
|
|
29
27
|
let initialPaintComplete = false;
|
|
30
28
|
if (typeof window !== 'undefined') {
|
|
31
29
|
requestAnimationFrame(() => {
|
|
@@ -99,16 +97,14 @@ const styles = stylex.create({
|
|
|
99
97
|
});
|
|
100
98
|
|
|
101
99
|
export type EntryAnimationPreset =
|
|
102
|
-
| '
|
|
103
|
-
| 'slideUp'
|
|
104
|
-
| 'fadeIn'
|
|
105
|
-
| 'scaleIn';
|
|
100
|
+
'slideDown' | 'slideUp' | 'fadeIn' | 'scaleIn';
|
|
106
101
|
|
|
107
102
|
/**
|
|
108
103
|
* Returns a StyleX style for animating an element on mount.
|
|
109
104
|
*
|
|
110
105
|
* Only animates when the element is dynamically inserted after the initial
|
|
111
|
-
* page paint. Elements rendered on page load
|
|
106
|
+
* page paint. Elements rendered on page load, including server-rendered
|
|
107
|
+
* elements while they hydrate, are not animated.
|
|
112
108
|
*
|
|
113
109
|
* @example
|
|
114
110
|
* ```
|
|
@@ -119,6 +115,9 @@ export type EntryAnimationPreset =
|
|
|
119
115
|
export function useEntryAnimation(
|
|
120
116
|
preset: EntryAnimationPreset = 'slideDown',
|
|
121
117
|
): StyleXStyles | null {
|
|
122
|
-
const
|
|
118
|
+
const isHydrating = useIsHydrating();
|
|
119
|
+
// Captured once: a hydrated element must not start animating when React
|
|
120
|
+
// re-renders it with the client snapshot after hydration.
|
|
121
|
+
const [animate] = useState(() => !isHydrating && initialPaintComplete);
|
|
123
122
|
return animate ? styles[preset] : null;
|
|
124
123
|
}
|