Skip to content

Commit aa2c526

Browse files
committed
fix: listbox collection simplification
1 parent cf0859b commit aa2c526

File tree

3 files changed

+117
-242
lines changed

3 files changed

+117
-242
lines changed

src/components/fields/FilterListBox/FilterListBox.tsx

Lines changed: 24 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import React, {
1212
useState,
1313
} from 'react';
1414
import { useFilter, useKeyboard } from 'react-aria';
15-
import { Section as BaseSection, Item } from 'react-stately';
15+
import { Section as BaseSection, Item, useListState } from 'react-stately';
1616

1717
import { LoadingIcon } from '../../../icons';
1818
import { useProviderProps } from '../../../provider';
@@ -301,27 +301,23 @@ export const FilterListBox = forwardRef(function FilterListBox<
301301
}
302302
}
303303

304+
// Create a local collection for efficient key lookups and existence checks
305+
const localCollectionState = useListState({
306+
children: children as any,
307+
items: items as any,
308+
selectionMode: 'none' as any,
309+
});
310+
304311
// Collect original option keys to avoid duplicating them as custom values.
305312
const originalKeys = useMemo(() => {
306313
const keys = new Set<string>();
307-
308-
const collectKeys = (nodes: ReactNode): void => {
309-
React.Children.forEach(nodes, (child: any) => {
310-
if (!child || typeof child !== 'object') return;
311-
312-
if (child.type === Item) {
313-
if (child.key != null) keys.add(String(child.key));
314-
}
315-
316-
if (child.props?.children) {
317-
collectKeys(child.props.children);
318-
}
319-
});
320-
};
321-
322-
if (children) collectKeys(children);
314+
for (const item of localCollectionState.collection) {
315+
if (item.type === 'item') {
316+
keys.add(String(item.key));
317+
}
318+
}
323319
return keys;
324-
}, [children]);
320+
}, [localCollectionState.collection]);
325321

326322
// State to keep track of custom (user-entered) items that were selected.
327323
const [customKeys, setCustomKeys] = useState<Set<string>>(new Set());
@@ -521,39 +517,19 @@ export const FilterListBox = forwardRef(function FilterListBox<
521517
if (!term) return childrenToProcess;
522518

523519
// Helper to determine if the term is already present (exact match on rendered textValue or the key).
524-
const doesTermExist = (nodes: ReactNode): boolean => {
525-
let exists = false;
526-
527-
const checkNodes = (childNodes: ReactNode): void => {
528-
React.Children.forEach(childNodes, (child: any) => {
529-
if (!child || typeof child !== 'object') return;
530-
531-
// Check items directly
532-
if (child.type === Item) {
533-
const childText =
534-
child.props.textValue ||
535-
(typeof child.props.children === 'string'
536-
? child.props.children
537-
: '') ||
538-
String(child.props.children ?? '');
539-
540-
if (term === childText || String(child.key) === term) {
541-
exists = true;
542-
}
543-
}
544-
545-
// Recurse into sections or other wrappers
546-
if (child.props?.children) {
547-
checkNodes(child.props.children);
520+
const doesTermExist = (term: string): boolean => {
521+
for (const item of localCollectionState.collection) {
522+
if (item.type === 'item') {
523+
const textValue = item.textValue || String(item.rendered || '');
524+
if (term === textValue || String(item.key) === term) {
525+
return true;
548526
}
549-
});
550-
};
551-
552-
checkNodes(nodes);
553-
return exists;
527+
}
528+
}
529+
return false;
554530
};
555531

556-
if (doesTermExist(mergedChildren)) {
532+
if (doesTermExist(term)) {
557533
return childrenToProcess;
558534
}
559535

src/components/fields/FilterPicker/FilterPicker.test.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ describe('<FilterPicker />', () => {
2727
</FilterPicker.Section>,
2828
];
2929

30+
// Data arrays for testing sorting functionality (which requires items prop)
31+
const basicItemsData = [
32+
{ key: 'apple', label: 'Apple' },
33+
{ key: 'banana', label: 'Banana' },
34+
{ key: 'cherry', label: 'Cherry' },
35+
{ key: 'date', label: 'Date' },
36+
{ key: 'elderberry', label: 'Elderberry' },
37+
];
38+
39+
const sectionsItemsData = [
40+
{
41+
key: 'fruits',
42+
label: 'Fruits',
43+
children: [
44+
{ key: 'apple', label: 'Apple' },
45+
{ key: 'banana', label: 'Banana' },
46+
{ key: 'cherry', label: 'Cherry' },
47+
],
48+
},
49+
{
50+
key: 'vegetables',
51+
label: 'Vegetables',
52+
children: [
53+
{ key: 'carrot', label: 'Carrot' },
54+
{ key: 'broccoli', label: 'Broccoli' },
55+
{ key: 'spinach', label: 'Spinach' },
56+
],
57+
},
58+
];
59+
3060
describe('Basic functionality', () => {
3161
it('should render trigger button with placeholder', () => {
3262
const { getByRole } = renderWithRoot(
@@ -180,11 +210,15 @@ describe('<FilterPicker />', () => {
180210
it('should sort selected items to top when popover reopens in multiple mode', async () => {
181211
const { getByRole, getByText } = renderWithRoot(
182212
<FilterPicker
213+
sortSelectedToTop
183214
label="Select fruits"
184215
placeholder="Choose fruits..."
185216
selectionMode="multiple"
217+
items={basicItemsData}
186218
>
187-
{basicItems}
219+
{(item) => (
220+
<FilterPicker.Item key={item.key}>{item.label}</FilterPicker.Item>
221+
)}
188222
</FilterPicker>,
189223
);
190224

@@ -225,11 +259,21 @@ describe('<FilterPicker />', () => {
225259
it('should sort selected items to top within their sections', async () => {
226260
const { getByRole, getByText } = renderWithRoot(
227261
<FilterPicker
262+
sortSelectedToTop
228263
label="Select items"
229264
placeholder="Choose items..."
230265
selectionMode="multiple"
266+
items={sectionsItemsData}
231267
>
232-
{sectionsItems}
268+
{(section) => (
269+
<FilterPicker.Section key={section.key} title={section.label}>
270+
{section.children.map((item) => (
271+
<FilterPicker.Item key={item.key}>
272+
{item.label}
273+
</FilterPicker.Item>
274+
))}
275+
</FilterPicker.Section>
276+
)}
233277
</FilterPicker>,
234278
);
235279

@@ -297,11 +341,15 @@ describe('<FilterPicker />', () => {
297341
it('should maintain sorting when items are deselected and popover reopens', async () => {
298342
const { getByRole, getByText } = renderWithRoot(
299343
<FilterPicker
344+
sortSelectedToTop
300345
label="Select fruits"
301346
placeholder="Choose fruits..."
302347
selectionMode="multiple"
348+
items={basicItemsData}
303349
>
304-
{basicItems}
350+
{(item) => (
351+
<FilterPicker.Item key={item.key}>{item.label}</FilterPicker.Item>
352+
)}
305353
</FilterPicker>,
306354
);
307355

@@ -371,11 +419,15 @@ describe('<FilterPicker />', () => {
371419
it('should not reorder items when selecting additional items after reopening popover', async () => {
372420
const { getByRole, getByText } = renderWithRoot(
373421
<FilterPicker
422+
sortSelectedToTop
374423
label="Select fruits"
375424
placeholder="Choose fruits..."
376425
selectionMode="multiple"
426+
items={basicItemsData}
377427
>
378-
{basicItems}
428+
{(item) => (
429+
<FilterPicker.Item key={item.key}>{item.label}</FilterPicker.Item>
430+
)}
379431
</FilterPicker>,
380432
);
381433

@@ -428,11 +480,15 @@ describe('<FilterPicker />', () => {
428480
it('should work correctly in single selection mode', async () => {
429481
const { getByRole, getByText } = renderWithRoot(
430482
<FilterPicker
483+
sortSelectedToTop
431484
label="Select fruit"
432485
placeholder="Choose a fruit..."
433486
selectionMode="single"
487+
items={basicItemsData}
434488
>
435-
{basicItems}
489+
{(item) => (
490+
<FilterPicker.Item key={item.key}>{item.label}</FilterPicker.Item>
491+
)}
436492
</FilterPicker>,
437493
);
438494

@@ -848,7 +904,7 @@ describe('<FilterPicker />', () => {
848904
await userEvent.click(trigger);
849905
});
850906

851-
// Session 2: Reopen and verify both are visible and selected items are sorted to top
907+
// Session 2: Reopen and verify both custom and regular values are visible
852908
await act(async () => {
853909
await userEvent.click(trigger);
854910
});

0 commit comments

Comments
 (0)