Search with DropdownMenu

Attaching a menu of suggested actions to a search input


In some cases, you may want to attach a DropdownMenu to a TextField or Search input - instead of using the Combobox component. A Combobox is meant for displaying a list of values to select from when populating a field - for example, a list of customer attributes or events. In this use case, however, we want to display a list of actions that run when selected, which is why you might choose a DropdownMenu.

The example below shows a search input, where the dropdown menu contains a dynamic list of options, depending on the field's value. When an option is selected, the menu item's action will modify the search term accordingly. Here, the dropdown actions adjust the search term to perform a wildcard search.

There are a few things to note in the example.

Notes on DropdownMenu props:

  • We control the DropdownMenu's open state to make sure it opens when we want it to. For example, we close it when the search is triggered, and reopen when typing
  • We pass in listNavigationOptions to customize the behavior of Floating UI's list navigation plugin:
    • virtual is set to true, which makes the focus "virtual". Since we want the focus to remain in the search field, we can't actually move focus to dropdown items
    • loop is set to true, which makes the virtual focus loop back to the first or last item when navigating with keyboard arrows past the last/first items
    • We control the currently focused item (activeIndex) so that we know which item's callback to run when the search is triggered (e.g. when pressing Enter)
  • We set middlewareOptions to { offset: { mainAxis: 0 } } to make sure the dropdown menu renders without a gap after the search field
  • We disable the typeahead functionality (withTypeahead) - it's not meant to be used with typeable elements like input
  • We disable the click plugin's keyboardHandlers option - otherwise we won't be able to type correctly (it would prevent us from typing spaces)
  • We set initialFocus to -1 and shouldReturnFocus to false to keep the dropdown menu from taking focus away from the search field
  • We set shouldTriggerOnFocus to true so that the menu opens when the search field is focused

Notes on Search props:

  • Some form field components, like Search, render a wrapper around the input element. The actual input element renders inside the wrapper's paddings. We want the input element to be the trigger for the DropdownMenu, but we want the menu to be positioned relative to the wrapper.
    • In React:
      • We pass in setReference to the wrapperRef prop, so that it's used as the reference for positioning the menu
      • We pass in setReference and getReferenceProps as usual, so that the input element is the trigger for the menu
    • In Ember:
      • We pass a custom modifier to wrapperModifier, which calls setPositionReference. We need the modifier because setPositionReference is a plain function and not a modifier itself
      • We call setReference on the element as usual, which sets the input as the trigger
import { Search, DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '@customerio/pluma-components/react';
import { useState, useMemo } from 'react';

const allDropdownOptions = [
  { 
    id: 'starting_with', 
    getLabel: (searchTerm) => `Search for values starting with ${searchTerm}`, 
    getTerm: (searchTerm) => `${searchTerm}*` 
  },
  {
    id: 'ending_with', 
    getLabel: (searchTerm) => `Search for values ending with ${searchTerm}`, 
    getTerm: (searchTerm) => `*${searchTerm}` 
  },
];

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');

  const dropdownOptions = useMemo(() => {
    if (searchTerm.trim().length === 0) {
      return [];
    }

    if (searchTerm.includes('*')) {
      return [];
    }

    return allDropdownOptions;
  }, [searchTerm]);

  const [focusedIndex, setFocusedIndex] = useState(null);

  const handleSearchInput = (value) => {
    // Update the search term when typing in input
    setSearchTerm(value);

    // Reset the focused index when the search input changes
    setFocusedIndex(null); 

    // If there are dropdown options but the menu is closed, open it
    if (dropdownOptions.length > 0 && !isOpen) {
      setIsOpen(true);
    }
  }

  const handleSearch = (value) => {
    let maybeFocusedOption = dropdownOptions[focusedIndex];
    if (maybeFocusedOption != null) {
      setSearchTerm(maybeFocusedOption.getTerm(value));
    } else {
      setSearchTerm(value);
    }

    // Close the menu when the search is triggered
    setIsOpen(false);
  }

  const handleDropdownItemClick = (item) => {
    setSearchTerm(item.getTerm(searchTerm));
  }


  const listNavigationOptions = {
    activeIndex: focusedIndex,
    onNavigate: (value) => {
      setFocusedIndex(value);
    },
    virtual: true,
    loop: true,
  };

	return <DropdownMenu
    isOpen={isOpen}
    onOpenChange={setIsOpen}
    listNavigationOptions={listNavigationOptions}
    middlewareOptions={{ offset: { mainAxis: 0 } }}
    withTypeahead={false}
    floatingPluginOptions={{ click: { keyboardHandlers: false } }}
    initialFocus={-1}
    shouldReturnFocus={false}
    shouldTriggerOnFocus={true}
  >
    {(context) => (
      <Search
        label="Search"
        placeholder="Search"
        value={searchTerm}
        onSearchInput={handleSearchInput}
        onSearch={handleSearch}
        shouldSearchOnClear={true}
        shouldSearchOnEnter={true}
        wrapperRef={context.reference.setPositionReference}
        ref={context.reference.setReference}
        {...context.reference.getReferenceProps()}
      />
    )}

    <DropdownMenuContent>
      {dropdownOptions.map((item) => (
        <DropdownMenuItem
          key={item.id}
          onClick={() => handleDropdownItemClick(item)}
        >
          {item.getLabel(searchTerm)}
        </DropdownMenuItem>
      ))}
    </DropdownMenuContent>
  </DropdownMenu>
}