Skip to content

Commit 13fb450

Browse files
authored
feat(FilterListBox): controllable search (#816)
1 parent 3f8c4b7 commit 13fb450

File tree

5 files changed

+375
-8
lines changed

5 files changed

+375
-8
lines changed

.changeset/tender-trees-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
Support controllable filtering in FilterListBox and FilterPicker.

src/components/fields/FilterListBox/FilterListBox.test.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,66 @@ describe('<FilterListBox />', () => {
354354
});
355355

356356
describe('Search functionality', () => {
357+
it('should work with controlled search and filter={false} for external filtering', async () => {
358+
const onSearchChange = jest.fn();
359+
360+
const { getByPlaceholderText, getByText, queryByText, rerender } = render(
361+
<FilterListBox
362+
label="Select a fruit"
363+
searchPlaceholder="Search..."
364+
searchValue=""
365+
filter={false}
366+
onSearchChange={onSearchChange}
367+
>
368+
{basicItems}
369+
</FilterListBox>,
370+
);
371+
372+
const searchInput = getByPlaceholderText('Search...');
373+
374+
// Initially all options should be visible
375+
expect(getByText('Apple')).toBeInTheDocument();
376+
expect(getByText('Banana')).toBeInTheDocument();
377+
expect(getByText('Cherry')).toBeInTheDocument();
378+
379+
// Type in search - should call onSearchChange but NOT filter internally
380+
await act(async () => {
381+
await userEvent.type(searchInput, 'app');
382+
});
383+
384+
// onSearchChange should be called for each character as user types
385+
expect(onSearchChange).toHaveBeenCalledTimes(3);
386+
expect(onSearchChange).toHaveBeenCalledWith('a');
387+
expect(onSearchChange).toHaveBeenCalledWith('p');
388+
389+
// All items should still be visible because filter={false} disables internal filtering
390+
expect(getByText('Apple')).toBeInTheDocument();
391+
expect(getByText('Banana')).toBeInTheDocument();
392+
expect(getByText('Cherry')).toBeInTheDocument();
393+
394+
// Simulate external filtering by providing only matching items
395+
const filteredItems = [
396+
<FilterListBox.Item key="apple">Apple</FilterListBox.Item>,
397+
];
398+
399+
rerender(
400+
<FilterListBox
401+
label="Select a fruit"
402+
searchPlaceholder="Search..."
403+
searchValue="app"
404+
filter={false}
405+
onSearchChange={onSearchChange}
406+
>
407+
{filteredItems}
408+
</FilterListBox>,
409+
);
410+
411+
// Now only Apple should be visible (externally filtered)
412+
expect(getByText('Apple')).toBeInTheDocument();
413+
expect(queryByText('Banana')).not.toBeInTheDocument();
414+
expect(queryByText('Cherry')).not.toBeInTheDocument();
415+
});
416+
357417
it('should filter options based on search input', async () => {
358418
const { getByPlaceholderText, getByText, queryByText } = render(
359419
<FilterListBox label="Select a fruit" searchPlaceholder="Search...">

src/components/fields/FilterListBox/FilterListBox.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React, {
55
ReactElement,
66
ReactNode,
77
RefObject,
8+
useCallback,
89
useLayoutEffect,
910
useMemo,
1011
useRef,
@@ -115,8 +116,11 @@ export interface CubeFilterListBoxProps<T>
115116
searchPlaceholder?: string;
116117
/** Whether the search input should have autofocus */
117118
autoFocus?: boolean;
118-
/** Custom filter function for determining if an option should be included in search results */
119-
filter?: FilterFn;
119+
/**
120+
* Custom filter function for determining if an option should be included in search results.
121+
* Pass `false` to disable internal filtering completely (useful for external filtering).
122+
*/
123+
filter?: FilterFn | false;
120124
/** Custom label to display when no results are found after filtering */
121125
emptyLabel?: ReactNode;
122126
/** Custom styles for the search input */
@@ -160,6 +164,18 @@ export interface CubeFilterListBoxProps<T>
160164
* These are merged with customValueProps for new custom values.
161165
*/
162166
newCustomValueProps?: Partial<CubeItemProps<T>>;
167+
168+
/**
169+
* Controlled search value. When provided, the search input becomes controlled.
170+
* Use with `onSearchChange` to manage the search state externally.
171+
*/
172+
searchValue?: string;
173+
174+
/**
175+
* Callback fired when the search input value changes.
176+
* Use with `searchValue` for controlled search input.
177+
*/
178+
onSearchChange?: (value: string) => void;
163179
}
164180

165181
const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
@@ -247,6 +263,8 @@ export const FilterListBox = forwardRef(function FilterListBox<
247263
allValueProps,
248264
customValueProps,
249265
newCustomValueProps,
266+
searchValue: controlledSearchValue,
267+
onSearchChange,
250268
...otherProps
251269
} = props;
252270

@@ -386,13 +404,30 @@ export const FilterListBox = forwardRef(function FilterListBox<
386404
(props as any)['aria-label'] ||
387405
(typeof label === 'string' ? label : undefined);
388406

389-
const [searchValue, setSearchValue] = useState('');
407+
// Controlled/uncontrolled search value pattern
408+
const [internalSearchValue, setInternalSearchValue] = useState('');
409+
const isSearchControlled = controlledSearchValue !== undefined;
410+
const searchValue = isSearchControlled
411+
? controlledSearchValue
412+
: internalSearchValue;
413+
414+
const handleSearchChange = useCallback(
415+
(value: string) => {
416+
if (!isSearchControlled) {
417+
setInternalSearchValue(value);
418+
}
419+
onSearchChange?.(value);
420+
},
421+
[isSearchControlled, onSearchChange],
422+
);
423+
390424
const { contains } = useFilter({ sensitivity: 'base' });
391425

392-
// Choose the text filter function: user-provided `filter` prop (if any)
426+
// Choose the text filter function: user-provided `filter` prop (if any),
393427
// or the default `contains` helper from `useFilter`.
428+
// When filter={false}, disable filtering completely.
394429
const textFilterFn = useMemo<FilterFn>(
395-
() => filter || contains,
430+
() => (filter === false ? () => true : filter || contains),
396431
[filter, contains],
397432
);
398433

@@ -774,7 +809,7 @@ export const FilterListBox = forwardRef(function FilterListBox<
774809
if (searchValue) {
775810
// Clear the current search if any text is present.
776811
e.preventDefault();
777-
setSearchValue('');
812+
handleSearchChange('');
778813
} else {
779814
// Notify parent that Escape was pressed on an empty input.
780815
if (onEscape) {
@@ -893,7 +928,7 @@ export const FilterListBox = forwardRef(function FilterListBox<
893928
}
894929
onChange={(e) => {
895930
const value = e.target.value;
896-
setSearchValue(value);
931+
handleSearchChange(value);
897932
}}
898933
{...keyboardProps}
899934
{...modAttrs(mods)}

0 commit comments

Comments
 (0)