FilterMenu
FilterMenu allows a user to choose from a list of options, which can be filtered. It is a composable component which consists of a trigger (for toggling a popover), a search input (for filtering results), a listbox (to display options), and a popover (which contains the listbox when opened). In small screens (tablet and mobile), the Popover will be displayed as a Sheet.
Quick Start
- Installation
npm install @adaptavant/eds-core- Import
import { FilterMenu } from '@adaptavant/eds-core';
Default
Basic example
const initialOptions = [
{ code: "61", id: "AUS", name: "Australia" },
{ code: "64", id: "NZL", name: "New Zealand" },
{ code: "91", id: "IND", name: "India" },
{ code: "48", id: "POL", name: "Poland" },
{ code: "44", id: "GBR", name: "Scotland (UK)" },
{ code: "1", id: "USA", name: "United States" },
];
const [selectedOption, setSelectedOption] = React.useState();
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((country) =>
country.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name, code }) {
return (
<Track as="span">
{name} +{code}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Select">
<FilterMenu>
<FilterMenuTrigger placeholder="Select Country">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption?.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Size
Customize the size of the FilterMenu by using the size prop for the Field.
The size can be either large or standard with standard being the default.
const initialOptions = [
{ code: "61", id: "AUS", name: "Australia" },
{ code: "64", id: "NZL", name: "New Zealand" },
{ code: "91", id: "IND", name: "India" },
{ code: "48", id: "POL", name: "Poland" },
{ code: "44", id: "GBR", name: "Scotland (UK)" },
{ code: "1", id: "USA", name: "United States" },
];
const [selectedOption, setSelectedOption] = React.useState();
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((country) =>
country.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name, code }) {
return (
<Track as="span">
{name} +{code}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Select" size="large">
<FilterMenu>
<FilterMenuTrigger placeholder="Select Country">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption?.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Currency Picker
Using FilterMenu's compound component API, we can easily build a CurrencyPicker component from scratch.
const countries = [
{ currency: 'US Dollar', currencyCode: 'USD', currencySymbol: '$', id: "ASM", name: "American Samoa" },
{ currency: 'East Caribbean dollar', currencyCode: 'XCD', currencySymbol: '$', id: "AIA", name: "Anguilla" },
{ currency: 'Australian dollar', currencyCode: 'AUD', currencySymbol: '$', id: "AUS", name: "Australia" },
{ currency: 'Central African CFA franc', currencyCode: 'XAF', currencySymbol: 'FCFA', id: "TCD", name: "Chad" },
{ currency: 'Falkland Islands pound', currencyCode: 'FKP', currencySymbol: '£', id: "FLK", name: "Falkland Islands (Malvinas)"},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: "GGY", name: "Guernsey"},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: "IMN", name: "Isle of Man"},
{ currency: 'Indian rupee', currencyCode: 'INR', currencySymbol: '₹', id: "IND", name: "India"},
{ currency: 'Lebanese pound', currencyCode: 'LBP', currencySymbol: '£', id: "LBN", name: "Lebanon"},
{ currency: 'New Zealand dollar', currencyCode: 'NZD', currencySymbol: '$', id: "NZL", name: "New Zealand"},
{ currency: 'United States dollar', currencyCode: 'USD', currencySymbol: '$', id: "TLS", name: "Timor-Leste"},
{ currency: 'Singapore dollar', currencyCode: 'SGD', currencySymbol: '$', id: "SGP", name: "Singapore"},
{ currency: 'United Arab Emirates dirham', currencyCode: 'AED', currencySymbol: 'إ.د', id: 'ARE', name: 'United Arab Emirates',},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: 'GBR', name: 'United Kingdom',},
{ currency: 'United States dollar', currencyCode: 'USD', currencySymbol: '$', id: 'USA', name: 'United States',},
];
const [selectedOption, setSelectedOption] = React.useState();
const [searchTerm, setSearchTerm] = React.useState('');
function onClear() {
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? countries : countries.filter((country) =>
Object.values(country).map((value) =>
value.toLowerCase()
).join(" ").includes(searchTerm.toLowerCase())
);
function escapeRegExp(string) {
return string.replace(/[+$]/g, '\\$&');
}
function highlightMatchedContent(text, searchTerm) {
if (!searchTerm) return text;
const escapedSearchTerm = escapeRegExp(searchTerm);
const regex = new RegExp(`(${escapedSearchTerm})`, 'gi');
const parts = text.trim().split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<span className="font-stronger" key={index}>
{part}
</span>
) : (
part
)
);
}
function OptionLabel({ name, currencyCode, currencySymbol }) {
return (
<Track
className="gap-1"
classNames={{
center: 'grow-0',
}}
railEnd={<>- {highlightMatchedContent(currencyCode, searchTerm)} {highlightMatchedContent(currencySymbol, searchTerm)}</>}
>
<Track classNames={{ center: 'line-clamp-1'}}>
{highlightMatchedContent(name, searchTerm)}
</Track>
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Currency Picker" className="w-60">
<FilterMenu popoverMatchReferenceWidth>
<FilterMenuTrigger placeholder="Select currency">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption?.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Country Code Picker
To build a CountryCodePicker component from scratch, follow the code example below.
The following snippet uses useFilteredOptions, a custom hook that manages the search function and returns a filteredOptions array. It also utilises useDeferredValue to improve performance of expensive state updates and spreads some common functions like onClear and onChange to the FilterMenuSearchInput inside a popover via getSearchInputProps.
If you prefer to use your own custom state management, refer to the CurrencyPicker example provided above.
const countries = [
{ currency: 'US Dollar', currencyCode: 'USD', currencySymbol: '$', id: "ASM", name: "American Samoa", dailCode: '1684' },
{ currency: 'East Caribbean dollar', currencyCode: 'XCD', currencySymbol: '$', id: "AIA", name: "Anguilla", dailCode: '1264'},
{ currency: 'Australian dollar', currencyCode: 'AUD', currencySymbol: '$', id: "AUS", name: "Australia", dailCode: '61'},
{ currency: 'Canadian dollar', currencyCode: 'CAD', currencySymbol: '$', id: "CAN", name: "Canada", dailCode: '1'},
{ currency: 'Central African CFA franc', currencyCode: 'XAF', currencySymbol: 'FCFA', id: "TCD", name: "Chad", dailCode: '235'},
{ currency: 'Falkland Islands pound', currencyCode: 'FKP', currencySymbol: '£', id: "FLK", name: "Falkland Islands (Malvinas)", dailCode: '500'},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: "GGY", name: "Guernsey", dailCode: '44'},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: "IMN", name: "Isle of Man", dailCode: '44'},
{ currency: 'Indian rupee', currencyCode: 'INR', currencySymbol: '₹', id: "IND", name: "India", dailCode: '91'},
{ currency: 'Lebanese pound', currencyCode: 'LBP', currencySymbol: '£', id: "LBN", name: "Lebanon", dailCode: '961'},
{ currency: 'New Zealand dollar', currencyCode: 'NZD', currencySymbol: '$', id: "NZL", name: "New Zealand", dailCode: '64'},
{ currency: 'United States dollar', currencyCode: 'USD', currencySymbol: '$', id: "TLS", name: "Timor-Leste", dailCode: '670'},
{ currency: 'Singapore dollar', currencyCode: 'SGD', currencySymbol: '$', id: "SGP", name: "Singapore", dailCode: '65'},
{ currency: 'United Arab Emirates dirham', currencyCode: 'AED', currencySymbol: 'إ.د', id: 'ARE', name: 'United Arab Emirates', dailCode: '971'},
{ currency: 'British pound', currencyCode: 'GBP', currencySymbol: '£', id: 'GBR', name: 'United Kingdom', dailCode: '44'},
{ currency: 'United States dollar', currencyCode: 'USD', currencySymbol: '$', id: 'USA', name: 'United States', dailCode: '1'},
];
const [selectedOption, setSelectedOption] = React.useState();
const { filteredOptions, getSearchInputProps, searchTerm } = useFilteredOptions({
initialOptions: countries,
searchFunction: ({ options, searchTerm }) => {
if (searchTerm === "") return options;
return options.filter((option) => {
const getOnlySearchableKeys = Object.fromEntries(Object.entries(option).filter(([key, index]) =>
key !== 'currencyCode' && key !== 'currency')
);
return Object.values(getOnlySearchableKeys).map((value) =>
value.toLowerCase()
).join(" ").includes(searchTerm.toLowerCase());
})
},
});
React.useLayoutEffect(() => {
const usersCountry = countries.find((country) => country.dailCode === '91');
if (usersCountry) {
setSelectedOption(usersCountry);
}
}, []);
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">No matching results</Text>
);
}
function escapeRegExp(string) {
return string.replace(/[+$]/g, '\\$&');
}
function highlightMatchedContent(text, searchTerm) {
if (!searchTerm) return text;
const escapedSearchTerm = escapeRegExp(searchTerm);
const regex = new RegExp(`(${escapedSearchTerm})`, 'gi');
const parts = text.trim().split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<span className="font-stronger" key={index}>
{part}
</span>
) : (
part
)
);
}
return (
<Field label="Select Country Code" classNames={{ label: 'whitespace-nowrap'}} className="w-20">
<FilterMenu popoverMaxWidth={280} popoverMaxHeight={300}>
<FilterMenuTrigger placeholder="Select Country"
{...(selectedOption
? {
children: `+${selectedOption.dailCode}`,
}
: null)
}
/>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
{...getSearchInputProps()}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<Track
className="gap-1"
classNames={{
center: 'grow-0 line-clamp-1',
}}
railEnd={<>+{highlightMatchedContent(option.dailCode, searchTerm)}</>}
>
{highlightMatchedContent(option.name, searchTerm)}
</Track>
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
- To get all the currency and country data needed to build this component, check out Business utilities package.
- The
popoverMaxWidth,popoverMaxHeight,popoverPlacement, andpopoverMatchReferenceWidthprops have the same effect on the FilterMenu component as the Dropdown Menu. - To control the size of the Trigger, use the width class on the
<Field>component. - To control the Trigger appearance to "subtle," wrap
<FilterMenu>with Inline Field.
Strategy
Use the strategy prop to change the way how popover element will be positioned. By default, the strategy is set to absolute, changes it to fixed when the FilterMenuTrigger is inside a sticky or fixed element.
This option leverages the floating-ui library, which powers the FilterMenuPopover functionality.
const initialOptions = [
{ code: "61", id: "AUS", name: "Australia" },
{ code: "64", id: "NZL", name: "New Zealand" },
{ code: "91", id: "IND", name: "India" },
{ code: "48", id: "POL", name: "Poland" },
{ code: "44", id: "GBR", name: "Scotland (UK)" },
{ code: "1", id: "USA", name: "United States" },
];
const [selectedOption, setSelectedOption] = React.useState(
initialOptions[0]
);
const [showFixedElement, setShowFixedElement] = React.useState(false);
const onButtonClick = () => {
setShowFixedElement((prevState) => !prevState)
}
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((country) =>
country.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name, code }) {
return (
<Track as="span">
{name} +{code}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Stack className="w-full gap-4">
{showFixedElement ? (
<div
className="
animate-[snackbar-transition_0.3s_cubic-bezier(0.16,_1,_0.3,_1)]
bg-neutral-secondary
fixed
flex
items-center
justify-between
mx-2
p-4
right-0
rounded-8px
shadow-40
sm:right-8
sm:w-[360px]
top-8
w-[calc(100%-16px)]
z-10
"
>
<Field label="Select Items">
<FilterMenu strategy="fixed">
<FilterMenuTrigger placeholder="Select Country">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
<button
className="
focus-visible:focus-ring
font-stronger
px-1
py-0.5
rounded-4px
text-body-12
text-primary
underline
underline-offset-2
"
onClick={onButtonClick}
>
Close
</button>
</div>
) : null}
<Button onClick={onButtonClick}>
Show fixed element
</Button>
</Stack>
);
Disabled
Utilize the isDisabled prop in the <Field/> to show that a entire FilterMenu isn't usable.
const initialOptions = [
{ code: "61", id: "AUS", name: "Australia" },
{ code: "64", id: "NZL", name: "New Zealand" },
{ code: "91", id: "IND", name: "India" },
{ code: "48", id: "POL", name: "Poland" },
{ code: "44", id: "GBR", name: "Scotland (UK)" },
{ code: "1", id: "USA", name: "United States" },
];
const [selectedOption, setSelectedOption] = React.useState(
initialOptions[0]
);
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((country) =>
country.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name, code }) {
return (
<Track as="span">
{name} +{code}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Select Items" isDisabled>
<FilterMenu>
<FilterMenuTrigger placeholder="Select country">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Disabled MenuItem
Utilize the isDisabled prop in the <FilterMenuItem /> component to indicate that a specific item is not selectable.
This enhances user experience by clearly displaying the disabled state. Additionally, for improved accessibility, keyboard navigation will bypass disabled MenuItems in the Listbox.
const initialOptions = [
{ code: "61", id: "AUS", name: "Australia", disabled: true},
{ code: "64", id: "NZL", name: "New Zealand", disabled: false},
{ code: "91", id: "IND", name: "India", disabled: false},
{ code: "48", id: "POL", name: "Poland", disabled: true},
{ code: "44", id: "GBR", name: "Scotland (UK)", disabled: false},
{ code: "1", id: "USA", name: "United States", disabled: true},
];
const [selectedOption, setSelectedOption] = React.useState(
initialOptions[0]
);
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((country) =>
country.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name, code }) {
return (
<Track as="span">
{name} +{code}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Select Items">
<FilterMenu>
<FilterMenuTrigger>
<OptionLabel {...selectedOption} />
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isDisabled={option.disabled}
isSelected={selectedOption.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Note: Disabled item can already be selected option but having any interactions on it won't be possible.
Without selection indicator
Set showSelectionIndicator={false} on the FilterMenuItem to hide the check icon that marks the selected item. This can be used if your particular use case does not require the check indicator for the selected item.
const initialOptions = [
{ id: "1", name: "Apricot" },
{ id: "2", name: "Kiwi" },
{ id: "3", name: "Strawberry" },
{ id: "4", name: "Raspberry" },
{ id: "5", name: "Grapes" },
{ id: "6", name: "Papaya" },
];
const [selectedOption, setSelectedOption] = React.useState();
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((fruit) =>
fruit.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name }) {
return (
<Track as="span">
{name}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field label="Select fruit">
<FilterMenu>
<FilterMenuTrigger placeholder="Select fruit">
{selectedOption ? <OptionLabel {...selectedOption} /> : null}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search fruit">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption?.id === option.id}
showSelectionIndicator={false}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
Custom trigger
You can provide a custom trigger to the FilterMenu by passing a callback function to its children. This function provides access to both the button props required for the trigger to function correctly and be labeled accessibly, as well as the open state of the menu. The below example shows the usage of the Badge component as a trigger for the FilterMenu.
const initialOptions = [
{ id: "1", name: "Active" },
{ id: "2", name: "Pending" },
{ id: "3", name: "In review" },
{ id: "4", name: "Approved" },
{ id: "5", name: "Rejected" },
{ id: "6", name: "Archived" },
];
const [selectedOption, setSelectedOption] = React.useState();
const [searchTerm, setSearchTerm] = React.useState('');
function onClear(){
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
const filteredOptions = searchTerm === "" ? initialOptions : initialOptions.filter((option) =>
option.name.toLowerCase().includes(searchTerm.toLowerCase())
);
function OptionLabel({ name }) {
return (
<Track as="span">
{name}
</Track>
);
}
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
return (
<Field
label="Select"
labelVisibility="hidden"
>
<FilterMenu>
{({ triggerProps }) => {
return (
<>
<Badge
{...triggerProps}
className="w-fit focus-within:focus-ring"
tabIndex={0}
>
{selectedOption ? (
<OptionLabel {...selectedOption} />
) : (
<>
<span className="sr-only">Select status</span>
<span>Badge as custom trigger</span>
</>
)}
</Badge>
<FilterMenuPopover>
<FilterMenuSearchField label="Search status">
<FilterMenuSearchInput
onClear={onClear}
onChange={handleInputOnChange}
value={searchTerm}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={<NoResults />}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption?.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
<OptionLabel {...option} />
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</>
);
}}
</FilterMenu>
</Field>
);
Multi-select example
You can customize the appearance and behavior of each menu item in FilterMenu by passing a render function as the child of FilterMenuItem.
Below is an example demonstrating how to build a multi-select account number filter by rendering a Checkbox inside each FilterMenuItem. A header checkbox at the top toggles all options at once and reflects an indeterminate state when only some are selected. Closing the FilterMenu can be prevented as well when you have such custom actions for the FilterMenuItem.
const accountNumberList = [
{ id: 1, value: '812324324' },
{ id: 2, value: '811224324' },
{ id: 3, value: '678924324' },
{ id: 4, value: '678924324' },
{ id: 5, value: '812324325' },
{ id: 6, value: '811224326' },
{ id: 7, value: '678924327' },
{ id: 8, value: '812324328' },
{ id: 9, value: '811224329' },
{ id: 10, value: '678924330' },
{ id: 11, value: '812324331' },
{ id: 12, value: '811224332' },
];
const [checkedItems, setCheckedItems] = React.useState({});
const [searchTerm, setSearchTerm] = React.useState('');
const enabledItems = accountNumberList.map(({ id }) => id);
const allChecked = enabledItems.every((id) => checkedItems[id]);
const someChecked = enabledItems.some((id) => checkedItems[id]) && !allChecked;
const checkedCount = enabledItems.filter((id) => checkedItems[id]).length;
function onClear() {
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
function handleItemChange(id) {
setCheckedItems((prevState) => ({
...prevState,
[id]: !prevState[id],
}));
}
function handleHeaderChange() {
const newCheckedState = !allChecked;
setCheckedItems((prevState) => {
const updatedState = { ...prevState };
enabledItems.forEach((id) => {
updatedState[id] = newCheckedState;
});
return updatedState;
});
}
const filteredOptions = searchTerm === "" ? accountNumberList : accountNumberList.filter((accountNumber) =>
accountNumber.value.toLowerCase().includes(searchTerm.toLowerCase())
);
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
function triggerContent() {
if (checkedCount === 0) return 'Select number';
if (allChecked) return 'All numbers';
if (checkedCount === 1) {
const selected = accountNumberList.find((account) => checkedItems[account.id]);
return selected?.value;
}
return `${checkedCount} numbers`;
}
return (
<Field label="Account number">
<FilterMenu popoverMaxHeight={210}>
<FilterMenuTrigger placeholder="Select account number">
{triggerContent()}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search account number">
<FilterMenuSearchInput
onChange={handleInputOnChange}
onClear={onClear}
placeholder="Search..."
value={searchTerm}
/>
</FilterMenuSearchField>
{filteredOptions.length ? (
<FilterMenuItem
className="shrink-0"
showSelectionIndicator={false}
onClick={() => {
handleHeaderChange();
return false;
}}
>
<Checkbox
checked={allChecked}
indeterminate={someChecked}
label="Select all"
className="pointer-events-none"
/>
</FilterMenuItem>
) : (
<NoResults />
)}
<FilterMenuListbox noResultsFallback={<NoResults />}>
{filteredOptions.map((account) => (
<FilterMenuItem
key={account.id}
showSelectionIndicator={false}
onClick={() => {
handleItemChange(account.id);
return false;
}}
>
<Checkbox
checked={Boolean(checkedItems[account.id])}
id={account.id.toString()}
label={account.value}
className="pointer-events-none"
/>
</FilterMenuItem>
))}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
FullScreen on Mobile
On mobile, enable fullScreenForMobile to render the sheet in full-screen with a default close button.\
const initialOptions = [
{ id: "1", value: "Item 1" },
{ id: "2", value: "Item 2" },
{ id: "3", value: "Item 3" },
];
const [selectedOption, setSelectedOption] = React.useState(initialOptions[0]);
const { filteredOptions, getSearchInputProps } = useFilteredOptions({
initialOptions,
searchFunction: ({ options, searchTerm }) => {
if (searchTerm === "") return options;
return options.filter((option) =>
option.value.toLowerCase().includes(searchTerm.toLowerCase())
);
},
});
return (
<Field label="Select Items">
<FilterMenu fullScreenForMobile>
<FilterMenuTrigger placeholder="Select">
{selectedOption.value}
</FilterMenuTrigger>
<FilterMenuPopover>
<FilterMenuSearchField label="Search Items">
<FilterMenuSearchInput
{...getSearchInputProps()}
placeholder="Search..."
/>
</FilterMenuSearchField>
<FilterMenuListbox
noResultsFallback={
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
}
options={filteredOptions}
>
{(option) => (
<FilterMenuItem
id={option.id}
isSelected={selectedOption.id === option.id}
onClick={() => {
setSelectedOption(option);
}}
>
{option.value}
</FilterMenuItem>
)}
</FilterMenuListbox>
</FilterMenuPopover>
</FilterMenu>
</Field>
);
API Reference
FilterMenu
| Prop | Default | Description |
|---|---|---|
children | _ | ((menuState: { isMenuOpen: boolean; triggerProps: TriggerProps }) => ReactNode) | ReactNodeAccepts either a React node or a render function. The render function provides the menu's state (isMenuOpen) and accessibility/interaction props (triggerProps) for the trigger. |
closeButtonPropsForMobile? | _ | { label: string, onClick: () => void, size?: IconButtonProps['size'] }Props for the close button that appears on mobile. |
mobileFriendly? | true | booleanIndicates whether the filter menu should be displayed as a sheet on mobile devices. |
popoverMatchReferenceWidth? | false | booleanMatch the width of the popover with the reference element. |
popoverMaxHeight? | 356 | numberThe max height of the filter menu popover. |
popoverMaxWidth? | 400 | numberThe max width of the filter menu popover. |
popoverOffset? | 4 | numberThe offset of the filter menu popover. |
popoverPlacement? | 'bottom-start' | 'bottom' | 'bottom-start' | 'bottom-end'The placement of the filter menu popover in relation to the trigger. |
strategy? | 'absolute' | 'absolute' | 'fixed'The strategy used to position the floating element. |
titleForMobile? | _ | stringIf titleForMobile is provided then the mobile sheet view will have a header with title rendered |
fullScreenForMobile | false | booleanEnables fullscreen mode for the mobile select menu, making it cover the entire viewport. |
FilterMenuTrigger
The FilterMenuTrigger component is a customizable trigger for a filter menu, built using the Button component. It inherits some Button props and adds few more additional functionalities.
| Prop | Default | Description |
|---|---|---|
children? | _ | ReactNodeThe content to be displayed inside the button. |
| _ |
|
iconEnd? | _ | ReactNodeThe icon to display after the button children. |
| _ |
|
|
|
|
|
|
|
| _ |
|
onBlur? | _ | functionFunction to call when the button loses focus. |
onFocus? | _ | functionFunction to invoke when the button receives focus. |
onKeyDown? | _ | functionFunction to invoke when a key is pressed while the button is focused. |
placeholder? | _ | stringDisplays a placeholder text when no children is provided. |
FilterMenuSearchField
The FilterMenuSearchField internally uses Field component. It inherits most of the Field props and adds few more functionalities.
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeThe content to be displayed inside the field. |
label | _ | ReactNodeLabel for the input. |
controlId? | _ | stringSpecifies the unique identifier for the form control within the component. See Field API for more details. |
| _ |
|
| _ |
|
| _ |
|
|
|
|
|
|
|
|
|
|
| _ |
|
FilterMenuSearchInput
The FilterMenuSearchInput component internally uses SearchInput component. It inherits most of the SearchInput props and adds few more functionalities.
| Prop | Default | Description |
|---|---|---|
| _ |
|
| _ |
|
| _ |
|
| _ |
|
| _ |
|
aria-haspopup? | _ | 'dialog' | 'grid' | 'listbox' | 'menu' | 'tree' | 'boolean'Indicates the availability of a popup related to the input. |
| _ |
|
| _ |
|
autoFocus? | _ | booleanAutomatically focuses the input element when it is rendered. |
defaultValue? | _ | stringSpecifies the initial value of the input for uncontrolled input. |
focusContainerRef? | _ | React.Ref<HTMLDivElement>Reference to the wrapper FocusContainer element. |
inputMode? | _ | 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'Hints to browsers about what kind of virtual keyboard to display. |
| _ |
|
onBlur? | _ | functionFunction to be invoked when the input loses focus. |
onChange? | _ | functionFunction to be invoked when a new item is selected. |
onFocus? | _ | functionFunction to be invoked when the input is focused. |
onKeyDown? | _ | functionFunction to be invoked when a key is pressed while the input is focused. |
onPaste? | _ | functionFunction to be invoked when content is pasted into the input. |
pattern? | _ | stringRegular expression pattern the input's value must match for validation. |
placeholder? | _ | stringPlaceholder text displayed when the input is empty. |
| _ |
|
| _ |
|
type? | _ | 'email' | 'password' | 'search' | 'tel' | 'text' | 'url'Type of the input element. |
value? | _ | stringSpecifies the current value for controlled inputs. |
FilterMenuPopover
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeContent of the filter menu popover. |
shouldUsePortal? | true | booleanDetermines whether the popover should be rendered in a React Portal. If true, the popover will be rendered outside the DOM hierarchy of the parent component. |
FilterMenuListbox
| Prop | Default | Description |
|---|---|---|
noResultsFallback? | _ | ReactNodeComponent to render when there are no options left in the filtered result. |
options? | _ | Array of ObjectsOptions to be rendered in the popover. Note: Each item in the object should have unique id of key property for better caching of children |
FilterMenuItem
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeThe content of the filter menu item. |
id? | _ | stringAn optional ID for the menu item. If not provided, an ID will be automatically generated. |
isDisabled? | false | booleanIndicates if the menu item is currently disabled. Used for styling and accessibility. Applies an aria-disabled attribute. |
isHighlighted? | false | booleanIndicates if the menu item is currently highlighted. Used for styling. Applies a data-highlighted attribute. Note: Controlled internally to support keyboard navigations. |
isSelected? | false | booleanIndicates if the menu item is currently selected. Used for styling. Applies an aria-selected=true and data-selected=true otherwise aria-selected=false |
onClick? | _ | () => boolean | voidFunction to be invoked when the item is clicked. |
| _ |
|
| _ |
|
showSelectionIndicator? | true | booleanControls whether to show the selection indicator (check icon) for selected items. |
|
|
|
verticalAlign? | 'middle' | 'bottom' | 'middle' | 'top'Determines how the rails and center are vertically aligned to each other. A typography or heading can be provided to center align icons and with text that may wrap. |