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