Skip to content

Commit 31fca19

Browse files
authored
[combobox] Fix stuck filtering with differing stringifiers (#3201)
1 parent c2271e4 commit 31fca19

File tree

2 files changed

+224
-26
lines changed

2 files changed

+224
-26
lines changed

packages/react/src/combobox/root/AriaCombobox.tsx

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
156156
const disabled = fieldDisabled || disabledProp;
157157
const name = fieldName ?? nameProp;
158158
const multiple = selectionMode === 'multiple';
159+
const single = selectionMode === 'single';
159160
const hasInputValue = inputValueProp !== undefined || defaultInputValueProp !== undefined;
160161
const hasItems = items !== undefined;
161162

@@ -180,18 +181,11 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
180181
if (filterProp !== undefined) {
181182
return filterProp;
182183
}
183-
if (selectionMode === 'single' && !queryChangedAfterOpen) {
184+
if (single && !queryChangedAfterOpen) {
184185
return createSingleSelectionCollatorFilter(collatorFilter, itemToStringLabel, selectedValue);
185186
}
186187
return createCollatorItemFilter(collatorFilter, itemToStringLabel);
187-
}, [
188-
filterProp,
189-
selectionMode,
190-
selectedValue,
191-
queryChangedAfterOpen,
192-
collatorFilter,
193-
itemToStringLabel,
194-
]);
188+
}, [filterProp, single, selectedValue, queryChangedAfterOpen, collatorFilter, itemToStringLabel]);
195189

196190
// If neither inputValue nor defaultInputValue are provided, derive it from the
197191
// selected value for single mode so the input reflects the selection on mount.
@@ -200,7 +194,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
200194
if (hasInputValue) {
201195
return defaultInputValueProp ?? '';
202196
}
203-
if (selectionMode === 'single') {
197+
if (single) {
204198
return stringifyAsLabel(selectedValue, itemToStringLabel);
205199
}
206200
return '';
@@ -221,8 +215,21 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
221215
state: 'open',
222216
});
223217

224-
const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim());
225218
const isGrouped = isGroupedItems(items);
219+
const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim());
220+
221+
const selectedLabelString = single ? stringifyAsLabel(selectedValue, itemToStringLabel) : '';
222+
223+
const shouldBypassFiltering =
224+
single &&
225+
!queryChangedAfterOpen &&
226+
query !== '' &&
227+
selectedLabelString !== '' &&
228+
selectedLabelString.length === query.length &&
229+
collatorFilter.contains(selectedLabelString, query);
230+
231+
const filterQuery = shouldBypassFiltering ? '' : query;
232+
const shouldIgnoreExternalFiltering = filteredItemsProp !== undefined && shouldBypassFiltering;
226233

227234
const flatItems: readonly any[] = React.useMemo(() => {
228235
if (!items) {
@@ -237,7 +244,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
237244
}, [items, isGrouped]);
238245

239246
const filteredItems: Value[] | Group<Value>[] = React.useMemo(() => {
240-
if (filteredItemsProp) {
247+
if (filteredItemsProp && !shouldIgnoreExternalFiltering) {
241248
return filteredItemsProp as Value[] | Group<Value>[];
242249
}
243250

@@ -256,9 +263,9 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
256263
}
257264

258265
const candidateItems =
259-
query === ''
266+
filterQuery === ''
260267
? group.items
261-
: group.items.filter((item) => filter(item, query, itemToStringLabel));
268+
: group.items.filter((item) => filter(item, filterQuery, itemToStringLabel));
262269

263270
if (candidateItems.length === 0) {
264271
continue;
@@ -277,7 +284,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
277284
return resultingGroups;
278285
}
279286

280-
if (query === '') {
287+
if (filterQuery === '') {
281288
return limit > -1
282289
? flatItems.slice(0, limit)
283290
: // The cast here is done as `flatItems` is readonly.
@@ -294,13 +301,23 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
294301
if (limit > -1 && limitedItems.length >= limit) {
295302
break;
296303
}
297-
if (filter(item, query, itemToStringLabel)) {
304+
if (filter(item, filterQuery, itemToStringLabel)) {
298305
limitedItems.push(item);
299306
}
300307
}
301308

302309
return limitedItems;
303-
}, [filteredItemsProp, items, isGrouped, query, limit, filter, itemToStringLabel, flatItems]);
310+
}, [
311+
filteredItemsProp,
312+
shouldIgnoreExternalFiltering,
313+
items,
314+
isGrouped,
315+
filterQuery,
316+
limit,
317+
filter,
318+
itemToStringLabel,
319+
flatItems,
320+
]);
304321

305322
const flatFilteredItems: Value[] = React.useMemo(() => {
306323
if (isGrouped) {
@@ -511,13 +528,13 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
511528
}
512529

513530
if (!nextOpen && queryChangedAfterOpen) {
514-
if (selectionMode === 'single') {
531+
if (single) {
515532
setCloseQuery(query);
516533
// Avoid a flicker when closing the popup with an empty query.
517534
if (query === '') {
518535
setQueryChangedAfterOpen(false);
519536
}
520-
} else if (selectionMode === 'multiple') {
537+
} else if (multiple) {
521538
if (inline || inputInsidePopup) {
522539
setIndices({ activeIndex: null });
523540
} else {
@@ -548,7 +565,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
548565

549566
const shouldFillInput =
550567
(selectionMode === 'none' && popupRef.current && fillInputOnItemPress) ||
551-
(selectionMode === 'single' && !store.state.inputInsidePopup);
568+
(single && !store.state.inputInsidePopup);
552569

553570
if (shouldFillInput) {
554571
setInputValue(
@@ -558,7 +575,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
558575
}
559576

560577
if (
561-
selectionMode === 'single' &&
578+
single &&
562579
nextValue != null &&
563580
eventDetails.reason !== REASONS.inputChange &&
564581
queryChangedAfterOpen
@@ -650,7 +667,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
650667
// If the user typed a filter and didn't select in multiple mode, clear the input
651668
// after close completes to avoid mid-exit flicker and start fresh on next open.
652669
if (
653-
selectionMode === 'multiple' &&
670+
multiple &&
654671
inputRef.current &&
655672
inputRef.current.value !== '' &&
656673
!hadInputClearRef.current
@@ -661,7 +678,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
661678
// Single selection mode:
662679
// - If input is rendered inside the popup, clear it so the next open is blank
663680
// - If input is outside the popup, sync it to the selected value
664-
if (selectionMode === 'single') {
681+
if (single) {
665682
if (store.state.inputInsidePopup) {
666683
if (inputRef.current && inputRef.current.value !== '') {
667684
setInputValue('', createChangeEventDetails(REASONS.inputClear));
@@ -896,14 +913,14 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
896913
}
897914

898915
if (
899-
selectionMode === 'multiple' &&
916+
multiple &&
900917
store.state.selectedIndex !== null &&
901918
(!Array.isArray(selectedValue) || selectedValue.length === 0)
902919
) {
903920
setIndices({ activeIndex: null, selectedIndex: null });
904921
}
905922

906-
if (selectionMode === 'single' && !hasInputValue && !inputInsidePopup) {
923+
if (single && !hasInputValue && !inputInsidePopup) {
907924
const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel);
908925

909926
if (inputValue !== nextInputValue) {
@@ -928,7 +945,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
928945
});
929946

930947
useValueChanged(items, () => {
931-
if (selectionMode !== 'single' || hasInputValue || inputInsidePopup || queryChangedAfterOpen) {
948+
if (!single || hasInputValue || inputInsidePopup || queryChangedAfterOpen) {
932949
return;
933950
}
934951

packages/react/src/combobox/root/ComboboxRoot.test.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,187 @@ describe('<Combobox.Root />', () => {
20892089
expect(screen.queryByText('alphabet')).not.to.equal(null);
20902090
expect(screen.queryByText('alpine')).not.to.equal(null);
20912091
});
2092+
2093+
it('resets filtered results after selecting when using a custom search stringifier', async () => {
2094+
type Movie = { id: number; english: string; romaji: string };
2095+
const movies: Movie[] = [
2096+
{ id: 1, english: 'Spirited Away', romaji: 'Sen to Chihiro no Kamikakushi' },
2097+
{ id: 2, english: 'My Neighbor Totoro', romaji: 'Tonari no Totoro' },
2098+
{ id: 3, english: 'Princess Mononoke', romaji: 'Mononoke Hime' },
2099+
];
2100+
2101+
const stringifyMovie = (movie: Movie | null) =>
2102+
movie ? `${movie.english} ${movie.romaji}` : '';
2103+
2104+
function MultilingualFilterCombobox() {
2105+
const [value, setValue] = React.useState<Movie | null>(null);
2106+
const { contains } = Combobox.useFilter({ value });
2107+
2108+
const filter = React.useCallback(
2109+
(item: Movie | null, query: string) => {
2110+
if (!item) {
2111+
return false;
2112+
}
2113+
return contains(item, query, stringifyMovie);
2114+
},
2115+
[contains],
2116+
);
2117+
2118+
return (
2119+
<Combobox.Root
2120+
items={movies}
2121+
value={value}
2122+
onValueChange={setValue}
2123+
filter={filter}
2124+
itemToStringLabel={(movie) => movie?.english ?? ''}
2125+
>
2126+
<Combobox.Input data-testid="input" />
2127+
<Combobox.Portal>
2128+
<Combobox.Positioner>
2129+
<Combobox.Popup>
2130+
<Combobox.List>
2131+
{(movie: Movie) => (
2132+
<Combobox.Item key={movie.id} value={movie}>
2133+
{movie.english}
2134+
</Combobox.Item>
2135+
)}
2136+
</Combobox.List>
2137+
</Combobox.Popup>
2138+
</Combobox.Positioner>
2139+
</Combobox.Portal>
2140+
</Combobox.Root>
2141+
);
2142+
}
2143+
2144+
const { user } = await render(<MultilingualFilterCombobox />);
2145+
const input = screen.getByRole('combobox');
2146+
2147+
await user.click(input);
2148+
await screen.findByRole('listbox');
2149+
2150+
await user.type(input, 'tonari');
2151+
2152+
await waitFor(() => {
2153+
expect(screen.queryByRole('option', { name: 'Spirited Away' })).to.equal(null);
2154+
});
2155+
2156+
await user.click(screen.getByRole('option', { name: 'My Neighbor Totoro' }));
2157+
2158+
await waitFor(() => {
2159+
expect(screen.queryByRole('listbox')).to.equal(null);
2160+
});
2161+
2162+
await user.click(input);
2163+
await screen.findByRole('listbox');
2164+
2165+
await waitFor(() => {
2166+
expect(screen.queryByRole('option', { name: 'Spirited Away' })).not.to.equal(null);
2167+
});
2168+
});
2169+
2170+
it('resets external filteredItems when reopening after a selection', async () => {
2171+
interface TestItem {
2172+
id: number;
2173+
label: string;
2174+
label2: string;
2175+
}
2176+
2177+
const testItems: TestItem[] = [
2178+
{
2179+
id: 1,
2180+
label: 'apple',
2181+
label2: 'one',
2182+
},
2183+
{
2184+
id: 2,
2185+
label: 'orange',
2186+
label2: 'two',
2187+
},
2188+
{
2189+
id: 3,
2190+
label: 'banana',
2191+
label2: 'three',
2192+
},
2193+
];
2194+
2195+
function getItemLabelToFilter(item: TestItem | null) {
2196+
return item ? `${item.label} ${item.label2}` : '';
2197+
}
2198+
2199+
function getItemLabelToDisplay(item: TestItem | null) {
2200+
return item ? item.label || item.label2 : '';
2201+
}
2202+
2203+
function FilteredItemsCombobox() {
2204+
const [searchValue, setSearchValue] = React.useState('');
2205+
const [value, setValue] = React.useState<TestItem | null>(null);
2206+
2207+
const deferredSearchValue = React.useDeferredValue(searchValue);
2208+
2209+
const { contains } = Combobox.useFilter({ value });
2210+
2211+
const resolvedSearchValue =
2212+
searchValue === '' || deferredSearchValue === '' ? searchValue : deferredSearchValue;
2213+
2214+
const filteredItems = React.useMemo(() => {
2215+
return testItems.filter((item) =>
2216+
contains(item, resolvedSearchValue, getItemLabelToFilter),
2217+
);
2218+
}, [contains, resolvedSearchValue]);
2219+
2220+
return (
2221+
<Combobox.Root
2222+
items={testItems}
2223+
filteredItems={filteredItems}
2224+
inputValue={searchValue}
2225+
onInputValueChange={setSearchValue}
2226+
value={value}
2227+
onValueChange={setValue}
2228+
itemToStringLabel={getItemLabelToDisplay}
2229+
isItemEqualToValue={(item, v) => item?.id === v?.id}
2230+
>
2231+
<Combobox.Input />
2232+
<Combobox.Portal>
2233+
<Combobox.Positioner sideOffset={4}>
2234+
<Combobox.Popup>
2235+
<Combobox.Empty>No items found.</Combobox.Empty>
2236+
<Combobox.List>
2237+
{(item: TestItem) => (
2238+
<Combobox.Item key={item.id} value={item}>
2239+
<Combobox.ItemIndicator></Combobox.ItemIndicator>
2240+
{item.label}
2241+
</Combobox.Item>
2242+
)}
2243+
</Combobox.List>
2244+
</Combobox.Popup>
2245+
</Combobox.Positioner>
2246+
</Combobox.Portal>
2247+
</Combobox.Root>
2248+
);
2249+
}
2250+
2251+
const { user } = await render(<FilteredItemsCombobox />);
2252+
const input = screen.getByRole('combobox');
2253+
2254+
await user.click(input);
2255+
await user.type(input, 'one');
2256+
2257+
await waitFor(() => {
2258+
expect(screen.queryByRole('option', { name: 'orange' })).to.equal(null);
2259+
});
2260+
2261+
await user.click(screen.getByRole('option', { name: 'apple' }));
2262+
2263+
await waitFor(() => {
2264+
expect(screen.queryByRole('listbox')).to.equal(null);
2265+
});
2266+
2267+
await user.click(input);
2268+
2269+
await waitFor(() => {
2270+
expect(screen.queryByRole('option', { name: 'orange' })).not.to.equal(null);
2271+
});
2272+
});
20922273
});
20932274

20942275
describe('prop: openOnInputClick', () => {

0 commit comments

Comments
 (0)