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

, Select Country
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
        classNames={{
          sheetWrapper: "z-10"
        }}
      >
        <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.

, Select currency
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
        classNames={{
          sheetWrapper: "z-10"
        }}
      >
        <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.

, Select Country
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
        classNames={{
          sheetWrapper: "z-10"
        }}
      >
        <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, and popoverMatchReferenceWidth props 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
              classNames={{
                sheetWrapper: "z-10"
              }}
            >
              <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.

, Australia +61
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
        classNames={{
          sheetWrapper: "z-10"
        }}
      >
        <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.

, Australia +61
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
        classNames={{
          sheetWrapper: "z-10"
        }}
      >
        <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.

Style API

Our design system components include style props that allow you to easily customize different parts of each component to match your design needs.

Please refer to the Style API documentation for more insights.