@astryxdesign/core 0.7.1 → 0.7.2-canary.b06a093
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/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/PowerSearch/PowerSearch.js +1 -1
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +6 -0
- 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 +1 -0
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +19 -0
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
- package/locales/en.json +76 -0
- package/locales/pseudo.json +57 -0
- package/package.json +4 -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/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- 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/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/Switch/Switch.test.tsx +28 -0
- package/src/Switch/Switch.tsx +6 -0
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- 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/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 +21 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Pagination.tsx
|
|
7
7
|
* @input Uses React, StyleX, Button, Icon, Selector, Text; page number buttons delegate to Button.
|
|
8
|
+
* Compact first/last buttons cancel the inside control gap with logical token margins.
|
|
8
9
|
* Prev/next and first/last chevrons mirror under RTL via the shared rtlStyles.mirror
|
|
9
|
-
* (CSS scaleX), not a JS direction read. The input
|
|
10
|
-
* (first/last) icons.
|
|
10
|
+
* (CSS scaleX), not a JS direction read. The input and compact variants use the
|
|
11
|
+
* chevronsLeft/chevronsRight (first/last) icons.
|
|
11
12
|
* @output Exports Pagination component, PaginationProps, PaginationVariant, PaginationSize types
|
|
12
13
|
* @position Core implementation; consumed by index.ts, tested by Pagination.test.tsx
|
|
13
14
|
*
|
|
@@ -105,7 +106,8 @@ export interface PaginationProps extends Omit<
|
|
|
105
106
|
* Visual variant controlling what appears between prev/next buttons.
|
|
106
107
|
* - pages: Page number buttons with ellipsis (default)
|
|
107
108
|
* - count: "X–Y of Z" text
|
|
108
|
-
* - compact: "Page X of Y" text
|
|
109
|
+
* - compact: "Page X of Y" text. First/last buttons are opt-in (see
|
|
110
|
+
* hasFirstLast).
|
|
109
111
|
* - dots: Dot indicators
|
|
110
112
|
* - input: An editable number box rendering "Page [ n ] / N". The leading
|
|
111
113
|
* noun is controlled by `pageLabel`. First/last double-chevron buttons
|
|
@@ -122,9 +124,11 @@ export interface PaginationProps extends Omit<
|
|
|
122
124
|
pageLabel?: string;
|
|
123
125
|
/**
|
|
124
126
|
* Whether to show first/last («/») double-chevron buttons flanking
|
|
125
|
-
* prev/next.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
127
|
+
* prev/next. Applies to the `input` variant (shown unless false) and the
|
|
128
|
+
* `compact` variant (shown only when true); other variants ignore it.
|
|
129
|
+
* The last button needs a known total — both are omitted when the page count
|
|
130
|
+
* is unknown (cursor/hasMore pagination).
|
|
131
|
+
* @default true for `input`, false for `compact`
|
|
128
132
|
*/
|
|
129
133
|
hasFirstLast?: boolean;
|
|
130
134
|
/**
|
|
@@ -181,6 +185,12 @@ const styles = stylex.create({
|
|
|
181
185
|
alignItems: 'center',
|
|
182
186
|
gap: spacingVars['--spacing-1'],
|
|
183
187
|
},
|
|
188
|
+
compactFirst: {
|
|
189
|
+
marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
190
|
+
},
|
|
191
|
+
compactLast: {
|
|
192
|
+
marginInlineStart: `calc(-1 * ${spacingVars['--spacing-1']})`,
|
|
193
|
+
},
|
|
184
194
|
ellipsis: {
|
|
185
195
|
display: 'inline-flex',
|
|
186
196
|
alignItems: 'center',
|
|
@@ -369,7 +379,7 @@ export function Pagination({
|
|
|
369
379
|
onPageSizeChange,
|
|
370
380
|
variant = 'pages',
|
|
371
381
|
pageLabel,
|
|
372
|
-
hasFirstLast
|
|
382
|
+
hasFirstLast: hasFirstLastProp,
|
|
373
383
|
step: stepProp = 1,
|
|
374
384
|
siblingCount = 1,
|
|
375
385
|
size = 'md',
|
|
@@ -572,10 +582,14 @@ export function Pagination({
|
|
|
572
582
|
const buttonSize = size === 'sm' ? 'sm' : 'md';
|
|
573
583
|
const isSm = size === 'sm';
|
|
574
584
|
|
|
575
|
-
// First/last buttons
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
585
|
+
// First/last buttons are on by default in the input variant and opt-in for
|
|
586
|
+
// compact; other variants never render them. They also need a known page
|
|
587
|
+
// count (the last button would have no target otherwise).
|
|
588
|
+
const hasFirstLast =
|
|
589
|
+
variant === 'input'
|
|
590
|
+
? (hasFirstLastProp ?? true)
|
|
591
|
+
: variant === 'compact' && hasFirstLastProp === true;
|
|
592
|
+
const showFirstLast = hasFirstLast && computedTotalPages != null;
|
|
579
593
|
|
|
580
594
|
const renderIndicator = () => {
|
|
581
595
|
switch (variant) {
|
|
@@ -819,6 +833,7 @@ export function Pagination({
|
|
|
819
833
|
onClick={handleFirst}
|
|
820
834
|
isDisabled={isDisabled || !hasPrevious}
|
|
821
835
|
isIconOnly
|
|
836
|
+
xstyle={variant === 'compact' && styles.compactFirst}
|
|
822
837
|
/>
|
|
823
838
|
)}
|
|
824
839
|
|
|
@@ -874,6 +889,7 @@ export function Pagination({
|
|
|
874
889
|
onClick={handleLast}
|
|
875
890
|
isDisabled={isDisabled || !hasNext}
|
|
876
891
|
isIconOnly
|
|
892
|
+
xstyle={variant === 'compact' && styles.compactLast}
|
|
877
893
|
/>
|
|
878
894
|
)}
|
|
879
895
|
</div>
|
|
@@ -165,6 +165,91 @@ describe('PowerSearch', () => {
|
|
|
165
165
|
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
it('clears all added filters when the clear button is clicked', async () => {
|
|
169
|
+
const user = userEvent.setup();
|
|
170
|
+
render(<PowerSearchWrapper config={config} />);
|
|
171
|
+
const input = screen.getByRole('combobox', {name: 'Search'});
|
|
172
|
+
await user.type(input, 'status open');
|
|
173
|
+
await user.click(
|
|
174
|
+
await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
|
|
175
|
+
);
|
|
176
|
+
await user.type(input, 'title login');
|
|
177
|
+
await user.click(
|
|
178
|
+
await screen.findByRole('option', {
|
|
179
|
+
name: 'Title contains "login"',
|
|
180
|
+
hidden: true,
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
await user.type(input, 'status closed');
|
|
185
|
+
await user.click(
|
|
186
|
+
await screen.findByRole('option', {
|
|
187
|
+
name: 'Status is Closed',
|
|
188
|
+
hidden: true,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(3);
|
|
193
|
+
|
|
194
|
+
await user.click(screen.getByRole('button', {name: 'Clear all'}));
|
|
195
|
+
|
|
196
|
+
expect(screen.queryAllByRole('button', {name: /^Remove /})).toHaveLength(0);
|
|
197
|
+
expect(
|
|
198
|
+
screen.queryByRole('button', {name: 'Clear all'}),
|
|
199
|
+
).not.toBeInTheDocument();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('clicking the remove button on a filter removes only that filter', async () => {
|
|
203
|
+
const user = userEvent.setup();
|
|
204
|
+
render(<PowerSearchWrapper config={config} />);
|
|
205
|
+
const input = screen.getByRole('combobox', {name: 'Search'});
|
|
206
|
+
await user.type(input, 'status open');
|
|
207
|
+
await user.click(
|
|
208
|
+
await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
|
|
209
|
+
);
|
|
210
|
+
await user.type(input, 'title login');
|
|
211
|
+
await user.click(
|
|
212
|
+
await screen.findByRole('option', {
|
|
213
|
+
name: 'Title contains "login"',
|
|
214
|
+
hidden: true,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
|
|
219
|
+
|
|
220
|
+
await user.click(
|
|
221
|
+
screen.getAllByRole('button', {name: 'Remove Status: is'})[0],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
|
|
225
|
+
expect(screen.getByText('login')).toBeInTheDocument();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('Backspace on an empty input removes only the last filter', async () => {
|
|
229
|
+
const user = userEvent.setup();
|
|
230
|
+
render(<PowerSearchWrapper config={config} />);
|
|
231
|
+
const input = screen.getByRole('combobox', {name: 'Search'});
|
|
232
|
+
await user.type(input, 'status open');
|
|
233
|
+
await user.click(
|
|
234
|
+
await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
|
|
235
|
+
);
|
|
236
|
+
await user.type(input, 'title login');
|
|
237
|
+
await user.click(
|
|
238
|
+
await screen.findByRole('option', {
|
|
239
|
+
name: 'Title contains "login"',
|
|
240
|
+
hidden: true,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
|
|
245
|
+
|
|
246
|
+
await user.click(input);
|
|
247
|
+
await user.keyboard('{Backspace}');
|
|
248
|
+
|
|
249
|
+
expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
|
|
250
|
+
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
|
|
168
253
|
describe('token value truncation (#4759)', () => {
|
|
169
254
|
it('truncates token values by characters, not code units', () => {
|
|
170
255
|
const truncConfig: PowerSearchConfig = {
|
|
@@ -733,8 +733,8 @@ export function PowerSearch({
|
|
|
733
733
|
} else if (change.type === 'remove' && change.item) {
|
|
734
734
|
const auxData = change.item.auxiliaryData as PowerSearchAuxData;
|
|
735
735
|
if (auxData?.filterIndex != null) {
|
|
736
|
-
const newFilters = filters.filter(
|
|
737
|
-
(
|
|
736
|
+
const newFilters = filters.filter((_, i) =>
|
|
737
|
+
_items.some(item => item.auxiliaryData?.filterIndex === i),
|
|
738
738
|
);
|
|
739
739
|
onChange(newFilters, 'remove', auxData.filterIndex);
|
|
740
740
|
}
|
|
@@ -834,6 +834,34 @@ describe('Switch', () => {
|
|
|
834
834
|
expect(root).toHaveAttribute('id', 'switch-1');
|
|
835
835
|
expect(root).toHaveAttribute('aria-label', 'Toggle notifications');
|
|
836
836
|
});
|
|
837
|
+
|
|
838
|
+
it('composes a caller aria-describedby onto the switch control', () => {
|
|
839
|
+
const {container} = render(
|
|
840
|
+
<>
|
|
841
|
+
<p id="external-hint">Applies to every repository</p>
|
|
842
|
+
<Switch
|
|
843
|
+
label="Notifications"
|
|
844
|
+
description="Sends a daily digest"
|
|
845
|
+
value={false}
|
|
846
|
+
onChange={() => {}}
|
|
847
|
+
data-testid="my-switch"
|
|
848
|
+
aria-describedby="external-hint"
|
|
849
|
+
/>
|
|
850
|
+
</>,
|
|
851
|
+
);
|
|
852
|
+
const control = screen.getByRole('switch');
|
|
853
|
+
const description = screen.getByText('Sends a daily digest');
|
|
854
|
+
expect(control.getAttribute('aria-describedby')?.split(' ')).toEqual([
|
|
855
|
+
'external-hint',
|
|
856
|
+
description.id,
|
|
857
|
+
]);
|
|
858
|
+
expect(control).toHaveAccessibleDescription(
|
|
859
|
+
'Applies to every repository Sends a daily digest',
|
|
860
|
+
);
|
|
861
|
+
// The id reference lives on the control only, not on the field wrapper.
|
|
862
|
+
const root = container.querySelector('[data-testid="my-switch"]');
|
|
863
|
+
expect(root).not.toHaveAttribute('aria-describedby');
|
|
864
|
+
});
|
|
837
865
|
});
|
|
838
866
|
|
|
839
867
|
describe('pressed state', () => {
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -566,6 +566,7 @@ export function Switch({
|
|
|
566
566
|
className,
|
|
567
567
|
style,
|
|
568
568
|
ref,
|
|
569
|
+
'aria-describedby': ariaDescribedByProp,
|
|
569
570
|
...rest
|
|
570
571
|
}: SwitchProps) {
|
|
571
572
|
const t = useTranslator();
|
|
@@ -610,7 +611,12 @@ export function Switch({
|
|
|
610
611
|
// Only include descriptionID when the element actually renders.
|
|
611
612
|
// FieldLabel renders the description (with descriptionID) even when the
|
|
612
613
|
// label is visually hidden — it's sr-only, so keep it linked.
|
|
614
|
+
// A consumer's own `aria-describedby` describes the control, so it comes
|
|
615
|
+
// first here rather than landing on the field wrapper via `...rest`.
|
|
613
616
|
const describedByParts: string[] = [];
|
|
617
|
+
if (ariaDescribedByProp) {
|
|
618
|
+
describedByParts.push(ariaDescribedByProp);
|
|
619
|
+
}
|
|
614
620
|
if (description) {
|
|
615
621
|
describedByParts.push(descriptionID);
|
|
616
622
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
afterEach,
|
|
21
21
|
} from 'vitest';
|
|
22
22
|
import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
|
|
23
|
-
import {createRef, Profiler} from 'react';
|
|
23
|
+
import {createRef, Profiler, useState} from 'react';
|
|
24
24
|
import userEvent from '@testing-library/user-event';
|
|
25
25
|
import {Tokenizer} from './Tokenizer';
|
|
26
26
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
@@ -1790,6 +1790,59 @@ describe('Tokenizer end-lane reserve', () => {
|
|
|
1790
1790
|
expect(renderedTokens).toEqual([]);
|
|
1791
1791
|
});
|
|
1792
1792
|
|
|
1793
|
+
it('clears the busy spinner when a token is added while a bootstrap load is in flight', async () => {
|
|
1794
|
+
let settle: (items: SearchableItem[]) => void = () => {};
|
|
1795
|
+
const bootstrap = vi.fn(
|
|
1796
|
+
async () =>
|
|
1797
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
1798
|
+
settle = resolve;
|
|
1799
|
+
}),
|
|
1800
|
+
);
|
|
1801
|
+
const source: SearchSource = {search: () => [], bootstrap};
|
|
1802
|
+
|
|
1803
|
+
function Harness() {
|
|
1804
|
+
const [value, setValue] = useState<SearchableItem[]>([]);
|
|
1805
|
+
return (
|
|
1806
|
+
<>
|
|
1807
|
+
<Tokenizer
|
|
1808
|
+
label="Items"
|
|
1809
|
+
searchSource={source}
|
|
1810
|
+
value={value}
|
|
1811
|
+
onChange={setValue}
|
|
1812
|
+
hasEntriesOnFocus
|
|
1813
|
+
/>
|
|
1814
|
+
<button type="button" onClick={() => setValue([users[0]])}>
|
|
1815
|
+
Add Token
|
|
1816
|
+
</button>
|
|
1817
|
+
</>
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
render(<Harness />);
|
|
1822
|
+
const input = screen.getByRole('combobox');
|
|
1823
|
+
|
|
1824
|
+
fireEvent.focus(input);
|
|
1825
|
+
await waitFor(() => {
|
|
1826
|
+
expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
|
|
1830
|
+
|
|
1831
|
+
await waitFor(() => {
|
|
1832
|
+
expect(
|
|
1833
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1834
|
+
).not.toBeInTheDocument();
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
await act(async () => {
|
|
1838
|
+
settle([]);
|
|
1839
|
+
await Promise.resolve();
|
|
1840
|
+
});
|
|
1841
|
+
expect(
|
|
1842
|
+
screen.queryByRole('status', {name: 'Loading'}),
|
|
1843
|
+
).not.toBeInTheDocument();
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1793
1846
|
it('shares one observer across every field on the page', () => {
|
|
1794
1847
|
// Browsers batch per observer instance, so one observer per lane meant N
|
|
1795
1848
|
// callback dispatches a frame for N fields.
|
|
@@ -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);
|