From 4c67e374c068b75274dec549491a35d7f7736fbf Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 7 Oct 2025 17:04:00 +0200 Subject: [PATCH] feat(FilterListBox): controllable search --- .changeset/tender-trees-sleep.md | 5 + .../FilterListBox/FilterListBox.test.tsx | 60 ++++ .../fields/FilterListBox/FilterListBox.tsx | 49 +++- .../FilterPicker/FilterPicker.stories.tsx | 265 +++++++++++++++++- .../fields/FilterPicker/FilterPicker.tsx | 4 + 5 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 .changeset/tender-trees-sleep.md diff --git a/.changeset/tender-trees-sleep.md b/.changeset/tender-trees-sleep.md new file mode 100644 index 000000000..a073518f5 --- /dev/null +++ b/.changeset/tender-trees-sleep.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Support controllable filtering in FilterListBox and FilterPicker. diff --git a/src/components/fields/FilterListBox/FilterListBox.test.tsx b/src/components/fields/FilterListBox/FilterListBox.test.tsx index 8630bb6ab..39c5e2c12 100644 --- a/src/components/fields/FilterListBox/FilterListBox.test.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.test.tsx @@ -354,6 +354,66 @@ describe('', () => { }); describe('Search functionality', () => { + it('should work with controlled search and filter={false} for external filtering', async () => { + const onSearchChange = jest.fn(); + + const { getByPlaceholderText, getByText, queryByText, rerender } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Initially all options should be visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('Cherry')).toBeInTheDocument(); + + // Type in search - should call onSearchChange but NOT filter internally + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + // onSearchChange should be called for each character as user types + expect(onSearchChange).toHaveBeenCalledTimes(3); + expect(onSearchChange).toHaveBeenCalledWith('a'); + expect(onSearchChange).toHaveBeenCalledWith('p'); + + // All items should still be visible because filter={false} disables internal filtering + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('Cherry')).toBeInTheDocument(); + + // Simulate external filtering by providing only matching items + const filteredItems = [ + Apple, + ]; + + rerender( + + {filteredItems} + , + ); + + // Now only Apple should be visible (externally filtered) + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); + expect(queryByText('Cherry')).not.toBeInTheDocument(); + }); + it('should filter options based on search input', async () => { const { getByPlaceholderText, getByText, queryByText } = render( diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index b41f6b138..9e10dbd4e 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -5,6 +5,7 @@ import React, { ReactElement, ReactNode, RefObject, + useCallback, useLayoutEffect, useMemo, useRef, @@ -115,8 +116,11 @@ export interface CubeFilterListBoxProps searchPlaceholder?: string; /** Whether the search input should have autofocus */ autoFocus?: boolean; - /** Custom filter function for determining if an option should be included in search results */ - filter?: FilterFn; + /** + * Custom filter function for determining if an option should be included in search results. + * Pass `false` to disable internal filtering completely (useful for external filtering). + */ + filter?: FilterFn | false; /** Custom label to display when no results are found after filtering */ emptyLabel?: ReactNode; /** Custom styles for the search input */ @@ -160,6 +164,18 @@ export interface CubeFilterListBoxProps * These are merged with customValueProps for new custom values. */ newCustomValueProps?: Partial>; + + /** + * Controlled search value. When provided, the search input becomes controlled. + * Use with `onSearchChange` to manage the search state externally. + */ + searchValue?: string; + + /** + * Callback fired when the search input value changes. + * Use with `searchValue` for controlled search input. + */ + onSearchChange?: (value: string) => void; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -247,6 +263,8 @@ export const FilterListBox = forwardRef(function FilterListBox< allValueProps, customValueProps, newCustomValueProps, + searchValue: controlledSearchValue, + onSearchChange, ...otherProps } = props; @@ -386,13 +404,30 @@ export const FilterListBox = forwardRef(function FilterListBox< (props as any)['aria-label'] || (typeof label === 'string' ? label : undefined); - const [searchValue, setSearchValue] = useState(''); + // Controlled/uncontrolled search value pattern + const [internalSearchValue, setInternalSearchValue] = useState(''); + const isSearchControlled = controlledSearchValue !== undefined; + const searchValue = isSearchControlled + ? controlledSearchValue + : internalSearchValue; + + const handleSearchChange = useCallback( + (value: string) => { + if (!isSearchControlled) { + setInternalSearchValue(value); + } + onSearchChange?.(value); + }, + [isSearchControlled, onSearchChange], + ); + const { contains } = useFilter({ sensitivity: 'base' }); - // Choose the text filter function: user-provided `filter` prop (if any) + // Choose the text filter function: user-provided `filter` prop (if any), // or the default `contains` helper from `useFilter`. + // When filter={false}, disable filtering completely. const textFilterFn = useMemo( - () => filter || contains, + () => (filter === false ? () => true : filter || contains), [filter, contains], ); @@ -774,7 +809,7 @@ export const FilterListBox = forwardRef(function FilterListBox< if (searchValue) { // Clear the current search if any text is present. e.preventDefault(); - setSearchValue(''); + handleSearchChange(''); } else { // Notify parent that Escape was pressed on an empty input. if (onEscape) { @@ -893,7 +928,7 @@ export const FilterListBox = forwardRef(function FilterListBox< } onChange={(e) => { const value = e.target.value; - setSearchValue(value); + handleSearchChange(value); }} {...keyboardProps} {...modAttrs(mods)} diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx index d813c8970..1a39c805e 100644 --- a/src/components/fields/FilterPicker/FilterPicker.stories.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { userEvent, within } from 'storybook/test'; import { @@ -1928,3 +1928,266 @@ export const MultipleControlled: Story = { }, }, }; + +export const ExternalFiltering: Story = { + render: () => { + const allFruits = [ + { + key: 'apple', + label: 'Apple', + description: 'Crisp and sweet red fruit', + }, + { + key: 'banana', + label: 'Banana', + description: 'Yellow tropical fruit rich in potassium', + }, + { + key: 'cherry', + label: 'Cherry', + description: 'Small red stone fruit with sweet flavor', + }, + { + key: 'date', + label: 'Date', + description: 'Sweet dried fruit from date palm', + }, + { + key: 'elderberry', + label: 'Elderberry', + description: 'Dark purple berry with tart flavor', + }, + { key: 'fig', label: 'Fig', description: 'Sweet fruit with soft flesh' }, + { + key: 'grape', + label: 'Grape', + description: 'Small sweet fruit that grows in clusters', + }, + { + key: 'honeydew', + label: 'Honeydew', + description: 'Sweet melon with pale green flesh', + }, + { + key: 'kiwi', + label: 'Kiwi', + description: 'Small oval fruit with fuzzy brown skin', + }, + { + key: 'lemon', + label: 'Lemon', + description: 'Yellow citrus fruit with sour taste', + }, + { + key: 'mango', + label: 'Mango', + description: 'Tropical stone fruit with sweet orange flesh', + }, + { + key: 'orange', + label: 'Orange', + description: 'Round citrus fruit with orange peel', + }, + ]; + + // Example 1: Using filter={false} with externally filtered items + const Example1 = () => { + const [externalSearch, setExternalSearch] = useState(''); + const [selectedKeys, setSelectedKeys] = useState([]); + + const filteredFruits = useMemo(() => { + if (!externalSearch.trim()) return allFruits; + return allFruits.filter((fruit) => + fruit.label.toLowerCase().includes(externalSearch.toLowerCase()), + ); + }, [externalSearch]); + + return ( + + + Approach 1: Disabled Internal Filtering (filter={'{false}'}) + + + Use this approach when you want to filter items outside the + component (e.g., server-side filtering, custom search logic) while + keeping the FilterPicker's search input for visual consistency. + + + + + External Search Input: + + setExternalSearch(e.target.value)} + /> + + + setSelectedKeys(keys as string[])} + > + {(fruit: (typeof filteredFruits)[number]) => ( + + {fruit.label} + + )} + + + + Showing {filteredFruits.length} of {allFruits.length} fruits • + Selected: {selectedKeys.length} + + + ); + }; + + // Example 2: Using controlled searchValue and onSearchChange + const Example2 = () => { + const [searchValue, setSearchValue] = useState(''); + const [selectedKeys, setSelectedKeys] = useState([]); + + // Simulate external processing (e.g., debouncing, API calls) + const [processedSearch, setProcessedSearch] = useState(''); + + // Simulate debounced search with a simple effect + useEffect(() => { + const timer = setTimeout(() => { + setProcessedSearch(searchValue); + }, 300); + return () => clearTimeout(timer); + }, [searchValue]); + + const filteredFruits = useMemo(() => { + if (!processedSearch.trim()) return allFruits; + return allFruits.filter((fruit) => + fruit.label.toLowerCase().includes(processedSearch.toLowerCase()), + ); + }, [processedSearch]); + + return ( + + + Approach 2: Controlled Search Input (searchValue + onSearchChange) + + + Use this approach when you need full control over the search input + value, such as for debouncing, synchronizing with external state, or + implementing server-side search. + + + + + Current search value:{' '} + {searchValue || '(empty)'} + + {searchValue !== processedSearch && ( + + Processing search... (debounced) + + )} + + + setSelectedKeys(keys as string[])} + onSearchChange={setSearchValue} + > + {(fruit: (typeof filteredFruits)[number]) => ( + + {fruit.label} + + )} + + + + Showing {filteredFruits.length} of {allFruits.length} fruits • + Selected: {selectedKeys.length} + + + ); + }; + + return ( + + + External Filtering Patterns + + FilterPicker provides two approaches for implementing external + filtering when you need to control the filtering logic outside the + component. + + + + + + + + When to Use Each Approach + + + filter={'{false}'}: Simpler approach when you + just need to pre-filter items without controlling the search input + itself. Good for server-side filtering or custom filter logic. + + + Controlled Search: Use when you need full control + over the search input (debouncing, external UI sync, clearing from + outside, or tracking search analytics). + + + Combine Both: You can use both together for + maximum control - controlled search input with external filtering. + + + + + ); + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates two approaches for external filtering: (1) using `filter={false}` to disable internal filtering while providing pre-filtered items, and (2) using controlled search input with `searchValue` and `onSearchChange` props for complete control over the search behavior.', + }, + }, + }, +}; diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 2106f4f8a..ab411d691 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -244,6 +244,8 @@ export const FilterPicker = forwardRef(function FilterPicker( onEscape, onOptionClick, isClearable, + searchValue, + onSearchChange, ...otherProps } = props; @@ -1083,6 +1085,7 @@ export const FilterPicker = forwardRef(function FilterPicker( } searchPlaceholder={searchPlaceholder} filter={filter} + searchValue={searchValue} listStyles={listStyles} optionStyles={optionStyles} sectionStyles={sectionStyles} @@ -1116,6 +1119,7 @@ export const FilterPicker = forwardRef(function FilterPicker( allValueProps={allValueProps} customValueProps={customValueProps} newCustomValueProps={newCustomValueProps} + onSearchChange={onSearchChange} onEscape={() => close()} onOptionClick={(key) => { // For FilterPicker, clicking the content area should close the popover