BalanceReckon Design System
Overlays

Combobox

Allows the user to browse, search and make a single selection from a large list of values.

import { Combobox, defaultItemToLabel, defaultItemToValue, comboboxDynamicTextContainerItemHeight, stateChangeTypesEnum } from '@balance-web/combobox';
Loading demo…

Usage

The Combobox is a controlled component. You "own" the state, which is managed by providing handlers to the component.

NOTE: All handlers must be memoized with React.useCallback() for performance.

NOTE: Pass the window object to the environment prop in Storybook (or when using inside an iframe) to prevent interaction issues.

Basic

The following props are required:

  • inputValue — The input value
  • items — The array of items for the user to select from
  • onChange — Handle changes to the selected item
  • onInputChange — Handle changes to the input value
  • value — The selected item
Loading demo…

Clear

Optionally provide an onClear handler, allowing users to "clear" their selection.

NOTE: Like all handlers, onClear must be memoized with React.useCallback() for performance.

Loading demo…

Data shape

By default the Combobox assumes that items will take the following shape:

type Item = {
  label: string;
  value: string | number;
  disabled?: boolean;
};

If your items are already shaped this way, you don't need to provide anything — Combobox defaults itemToLabel/itemToValue to reading label/ value directly off each item (defaultItemToLabel/defaultItemToValue, exported if you want the same behaviour in your own code).

However, if your items have a different shape, you can provide "transform" functions to let the Combobox know how to handle your data:

  • itemToDisabled
  • itemToLabel
  • itemToValue

NOTE: All "transform" functions must be memoized with React.useCallback() for performance.

Loading demo…

Appearance

For brevity, search and selection are not implemented in the "appearance" examples.

Disabled

When "disabled" the Combobox will not be interactive and take on a dim appearance.

Loading demo…

Invalid

When the parent Field has an "invalidMessage" the Combobox controls will take on a "critical" appearance.

Loading demo…

Size

The Combobox suppports a size property, which influences the input controls and the default item height. For dense interfaces where screen reale state is limited you may want to use the "small" size.

Loading demo…

Placeholder

The text that appears in the form control when it has no value set.

Loading demo…

Custom item

To display a custom item you can provide an itemRenderer to the combobox.

NOTE: Should be a pure function (no side-effects), declared outside of the component.

Loading demo…

Item height

Because the scroll menu is virtualized we need an itemHeight (in pixels) to safely render the list. You only need to provide an itemHeight when using a custom itemRenderer and the height of your items differs from the default item implementation.

If your custom item wraps variable-length text and you need to calculate its height from the container width and character count rather than hard-coding it, comboboxDynamicTextContainerItemHeight does that calculation — see its source for the exact inputs it needs, since the right values depend entirely on your item's layout.

Loading demo…

By default, the menu dialog will match the width of the input container. For situations where horizontal real estate is limited this might mean that menu items are clipped. Provide a menuWidth prop to resolve the issue, accomodating expected item label lengths.

Loading demo…

Async

The logic for filtering items typically lives on the server rather than the client because it’s impractical to send all possible items over the network. However, when prototyping in Playroom or working with smaller datasets, you may want to perform this filtering on the client instead.

The async examples simulate a server request using the simulateFetch utility. You should not use this in your application.

Filtering

When fetching data from the server, update the loadingState to keep users informed. While the request is being processed, the loading indicator will let users know that their input has been recognised and that something is happening.

<Combobox loadingState="loading" />

NOTE: When fetching from the server based on user input you should always implement a constraint technique, like debounce or throttle, to limit the amount of requests.

Loading demo…

Pagination

Paginated data is handled in the Combobox using "infinite scrolling", a technique for loading more results as the user nears the bottom of a scroll view.

To paginate results you must provide:

  • mode="paginated"
  • canLoadMore — signal that more content is available
  • onLoadMore — called when the user nears the bottom of the menu (if canLoadMore)
  • loadingState — must be kept in-sync to avoid duplicate calls

NOTE: Like all handlers, onLoadMore must be memoized with React.useCallback() for performance.

Loading demo…

Advanced

The Combobox is tailored for the most common usage—where possible, simplicity and reduced API surface-area is preferred. There are however features available should you need them.

Actions such as "Create new" can be shown in the footer using the footerItem prop. The footer item is just a normal item rendered differently (for accessibility reasons) so most of the behavior is left to the consumer.

Below is a sample of how to implement a "Create new" item in the footer.

Loading demo…

Grouping, dividers and sticky items

We cannot have hierarchical data in a combobox, instead we can have the illusion of grouped items by inserting disabled and unselectable items into the list and rendering them differently.

type GroupedItem = {
  label: string;
  value: string;
  groupLabel?: string;
  divider?: boolean;
};

/**
 * The grouped data can be in many different shapes, this
 *  is just one example.
 */
const groupedFlavours = React.useMemo(() => {
  return [
    {
      groupId: 'group_a',
      groupLabel: 'Group A',
      flavours: [
        {
          label: 'Almond Fudge',
          value: 'almond_fudge',
        },
        {
          label: 'Apple Pie',
          value: 'apple_pie',
        },
        {
          label: 'Avocado',
          value: 'avocado',
        },
        {
          label: 'Amaretto',
          value: 'amaretto',
        },
      ],
    },
    {
      groupId: 'group_b',
      groupLabel: 'Group B',
      flavours: [
        {
          label: 'Blueberry Cheesecake',
          value: 'blueberry_cheesecake',
        },
        {
          label: 'Butter Pecan',
          value: 'nutter_pecan',
        },
        {
          label: 'Banana Split',
          value: 'banana_split',
        },
        {
          label: 'Brownie Batter',
          value: 'brownie_batter',
        },
      ],
    },
    {
      groupId: 'group_c',
      groupLabel: 'Group C',
      flavours: [
        {
          label: 'Cookies and Cream',
          value: 'cookies_and_cream',
        },
        {
          label: 'Chocolate Chip Cookie Dough',
          value: 'chocolate_chip_cookie_dough',
        },
        {
          label: 'Cherry Garcia',
          value: 'cherry_garcia',
        },
        {
          label: 'Caramel Swirl',
          value: 'caramel_swirl',
        },
      ],
    },
    {
      groupId: 'group_d',
      groupLabel: 'Group D',
      flavours: [
        {
          label: 'Dulce de Leche',
          value: 'dulce_de_leche',
        },
        {
          label: 'Double Chocolate',
          value: 'double_chocolate',
        },
        {
          label: 'Dragon Fruit',
          value: 'dragon_fruit',
        },
        {
          label: 'Dark Chocolate Raspberry',
          value: 'dark_chocolate_raspberry',
        },
      ],
    },
  ];
}, []);

const [inputValue, setInputValue] = useState('');

/**
 * Make sure to apply filtering to your grouped data, not the flat data
 *  or you'll end up with empty groups.
 **/
const filteredGroupedFlavours = React.useMemo(() => {
  if (!inputValue.length) {
    return groupedFlavours;
  }

  return groupedFlavours
    .map((flavourGroup) => {
      return {
        ...flavourGroup,
        flavours: flavourGroup.flavours.filter((flavour) => {
          return flavour.label.toLowerCase().includes(inputValue.toLowerCase());
        }),
      };
    })
    .filter((flavourGroup) => {
      return flavourGroup.flavours.length;
    });
}, [groupedFlavours, inputValue]);

/**
 * We need to flatten the grouped data into a
 *  single list with some metadata. This might change
 *  depending on your data structure and requirements.
 */
const itemData = React.useMemo(() => {
  const items: Array<GroupedItem> = [];

  filteredGroupedFlavours.forEach((flavourGroup, flavourGroupIdx) => {
    if (flavourGroup.flavours.length === 0) {
      return;
    }

    items.push({
      label: `${flavourGroup.groupLabel}_header`,
      value: `${flavourGroup.groupLabel}_header`,
      groupLabel: flavourGroup.groupLabel,
    });

    flavourGroup.flavours.forEach((flavour) => {
      items.push({
        label: flavour.label,
        value: flavour.value,
      });
    });

    if (
      flavourGroup.flavours.length &&
      flavourGroupIdx < filteredGroupedFlavours.length - 1
    ) {
      items.push({
        label: `${flavourGroup.groupLabel}_footer`,
        value: `${flavourGroup.groupLabel}_footer`,
        divider: true,
      });
    }
  });

  return items;
}, [filteredGroupedFlavours]);

/**
 * This is not necessary to have groups and dividers but sticky
 *  group labels are nice.
 **/
const stickyIndexes = React.useMemo(() => {
  const indices: number[] = [];
  itemData.forEach((item, index) => {
    if (item.groupLabel?.length) {
      indices.push(index);
    }
  });
  return indices;
}, [itemData]);

const [items, setItems] = useState(itemData);
const [selectedItem, setSelectedItem] = useState<GroupedItem | null>();

const onInputChange = React.useCallback((text) => {
  setInputValue(text);
}, []);

const onClear = React.useCallback(() => {
  setInputValue('');
  setItems(itemData);
  setSelectedItem(null);
}, [itemData]);

React.useEffect(() => {
  setItems(itemData);
}, [itemData]);

return (
  <Field label="Group & dividers">
    <Combobox
      footerItemHeight={30}
      inputValue={inputValue}
      itemHeight={(i) => {
        const item = items[i];
        if (item.groupLabel?.length) {
          return 40;
        }

        if (item.divider) {
          return 1;
        }

        return 50;
      }}
      itemRenderer={(item) => {
        if (item.groupLabel?.length) {
          return (
            <Box paddingLeft="small">
              <Text color="dim" size="xsmall" weight="bold">
                {item.groupLabel}
              </Text>
            </Box>
          );
        }

        if (item.divider) {
          return <Divider />;
        }

        return <Text>{item.label}</Text>;
      }}
      items={items}
      // We must disable the group items so user can't select them
      itemToDisabled={(item) => {
        const isGroupItem = !!(item.groupLabel?.length || item.divider);
        return isGroupItem;
      }}
      // We must make the group items not selectable so we have full control over the layout.
      itemToSelectable={(item) => {
        const isGroupItem = !!(item.groupLabel?.length || item.divider);
        return !isGroupItem;
      }}
      placeholder="Select an item"
      stickyIndexes={stickyIndexes}
      value={selectedItem}
      onChange={setSelectedItem}
      onClear={onClear}
      onInputChange={onInputChange}
    />
  </Field>
);

State changes

The Combobox implements Downshift under-the-hood. Both onChange and onInputChange will provide "next state" as their second argument. This state is a result of the respective events, and can be used to alter the behaviour of your component.

type ComboboxState = Partial<{
  highlightedIndex: number;
  inputValue: string;
  isOpen: boolean;
  selectedItem: Item | null;
}>;

In the example below we're checking whether the input value exactly matches the recently selected item. If this is the case, we reset the list of items so they're visible next time the menu is opened.

Loading demo…

State reducer

For granular control of the state changing process, you can provide your own reducer. When stateReducer is called it will receive the previous state and the actionAndChanges object. actionAndChanges contains the change type, which explains why the state is being changed. It also contains the changes proposed that should occur as a consequence of that change type.

NOTE: The stateReducer should be a pure function (no side-effects), it should be declared it outside of your component.

The TS types you'll need are re-exported from this package as UseCombobox*. While Balance prefers string literals, Downshift implements an Enum for the change "type", which is exposed as stateChangeTypesEnum.

In the example below we'll uppercase the input value, and return the new value along with the rest of the changes. For all other state change types, we return the default changes.

Loading demo…

Messages

You can customise the message displayed to users when no items are available by providing a getMessageText callback, which has the signature:

(status: 'error' | 'noResults', inputValue: string) => string;

You can define this function outside of your component, or implement React.useCallback() to memoize it. Prefer the former where possible.

NOTE: You must account for each status in your function definition.

Loading demo…

The environment prop

This prop is only useful if you're rendering the combobox within a different window context from where your JavaScript is running; for example, an iframe or a shadow-root.

Simply pass the window object to the environment.

<Combobox {...otherProps} environment={window} />

Recipes

Reference implementations for comboboxes of varying complexity.

Client side filtering with dynamic item height and quick add

type ItemType = {
  label: string;
  value: string;
  description?: string;
  disabled?: boolean;
};

// This would usually be fetched above this component and passed in as a prop
const serverItemList = React.useMemo((): Array<ItemType> => {
  return [
    { label: 'Chocolate', value: 'chocolate' },
    { label: 'Vanilla', value: 'vanilla' },
    { label: 'Strawberry', value: 'strawberry' },
    {
      label: 'Pineapple',
      value: 'pineapple',
      description:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
    },
    { label: 'Mango', value: 'mango' },
    { label: 'Marshmallow', value: 'marshmallow' },
    { label: 'Peach', value: 'peach' },
    {
      label: 'Watermelon',
      value: 'watermelon',
      description:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
    },
    { label: 'Guava', value: 'guava' },
    {
      label: 'Custard apple',
      value: 'custardapple',
      description:
        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
    },
    { label: 'Plum', value: 'plum' },
    { label: 'Rockmelon', value: 'rockmelon' },
  ];
}, []);

const createNewItem: ItemType = React.useMemo(() => {
  return {
    label: 'Create new',
    value: '_createNew',
  };
}, []);

const emptyStateItem: ItemType = React.useMemo(() => {
  return {
    label: '',
    value: '_emptyState',
  };
}, []);

// Perform any in-app filtering and sorting
const filteredServerItemList = React.useMemo(() => {
  return serverItemList
    .filter(() => {
      return true;
    })
    .sort((a, b) => {
      return a.label.localeCompare(b.label);
    });
}, [serverItemList]);

const [searchInputValue, setSearchInputValue] = useState('');
const [selectedItem, setSelectedItem] = useState<ItemType | null>(null);

const searchedItemList = React.useMemo(() => {
  // Perform all search on trimmed and lower case
  const formattedSearchValue = searchInputValue?.toLowerCase().trim();

  // Return all items if user input is empty
  if (!formattedSearchValue?.length) {
    return filteredServerItemList;
  }

  // Return all items if search value is the same as selected item i.e user is not searching
  const exactSelectedItemMatched =
    !!selectedItem &&
    formattedSearchValue === selectedItem.label.toLowerCase().trim();

  if (exactSelectedItemMatched) {
    return filteredServerItemList;
  }

  const filteredItems = filteredServerItemList.filter((item) => {
    return item.label.toLowerCase().trim().includes(formattedSearchValue);
  });

  if (filteredItems?.length) {
    return filteredItems;
  }

  // Return a special item when there are no items
  return [
    {
      // Different messages based on whether the list is empty or no matches from user search
      label: !filteredServerItemList.length
        ? `There are no items in this list, click "Create new" below to get started.`
        : `There are no items in this list matching your search criteria.`,
      value: emptyStateItem.value,
      disabled: true,
    },
  ];
}, [filteredServerItemList, searchInputValue, selectedItem]);

const onInputChange = React.useCallback(
  (text) => {
    setSearchInputValue(text);
  },
  [filteredServerItemList]
);

const onClear = React.useCallback(() => {
  setSearchInputValue('');
  setSelectedItem(null);
}, [filteredServerItemList]);

const handleCreateNewClick = React.useCallback(() => {
  onClear();
  // Allow user to create a new item here (by opening a drawer or something)
}, [onClear]);

const footerNode = React.useMemo(() => {
  const formattedInputValue = searchInputValue?.toLowerCase().trim();
  if (!formattedInputValue.length) {
    return <TextLinkButton>Create new</TextLinkButton>;
  }

  const exactSelectedItemMatched = filteredServerItemList.find((item) => {
    return item.label.toLowerCase().trim() === formattedInputValue;
  });

  return (
    <Flex flexGrow={1} justifyContent="space-between" paddingRight="large">
      {exactSelectedItemMatched ? null : (
        <Text>{`"${searchInputValue}"`} &nbsp; </Text>
      )}
      <Text>
        <TextLinkButton>Create new</TextLinkButton>
      </Text>
    </Flex>
  );
}, [searchInputValue, selectedItem]);

const itemRenderer = React.useCallback(
  (item: ItemType) => {
    if (item.value === emptyStateItem.value) {
      return (
        <Box paddingY="medium">
          <Text align="center" color="dim">
            {item.label}
          </Text>
        </Box>
      );
    }

    if (createNewItem.value === item.value) {
      return footerNode;
    }

    return (
      <Stack gap="small">
        <Text weight="semibold">{item.label}</Text>
        {item.description?.length ? (
          <Text size="small" color="muted">
            {item.description}
          </Text>
        ) : null}
      </Stack>
    );
  },
  [createNewItem, footerNode]
);

const minItemHeight = 56;
const menuWidth = 400;

return (
  <div style={{ width: menuWidth }}>
    <Field label="Flavours">
      <Combobox
        footerItem={createNewItem}
        footerItemHeight={minItemHeight}
        inputValue={searchInputValue}
        itemRenderer={itemRenderer}
        items={searchedItemList}
        itemHeight={(i) => {
          const item = searchedItemList[i];
          if (item.value === emptyStateItem.value) {
            return minItemHeight + 10;
          }

          if (!item.description?.length) {
            return minItemHeight;
          }

          // This can be skipped if your items are all roughly the same size.
          return (
            comoboboxDynamicTextContainerItemHeight({
              lineHeight: 21,
              minContainerWidth: 30,
              maxCharactersPerMinContainerWidth: 4,
              characterCount:
                item.value.length + (item.description?.length || 0),
              containerWidth: menuWidth - 10,
            }) + minItemHeight
          );
        }}
        menuWidth={menuWidth}
        placeholder="Select a flavour"
        value={selectedItem}
        onChange={(item) => {
          if (item?.value === createNewItem?.value) {
            handleCreateNewClick();
            return;
          }
          setSelectedItem(item);
        }}
        onClear={onClear}
        onInputChange={onInputChange}
      />
    </Field>
  </div>
);

On this page