DEV Community

Cover image for Building a Refined Combobox Component with React & TypeScript
Rodion Chachura
Rodion Chachura

Posted on • Originally published at radzion.com

Building a Refined Combobox Component with React & TypeScript

๐Ÿ™ GitHub | ๐ŸŽฎ Demo

Building a Custom Combobox in React and TypeScript: A Step-by-Step Guide

In this article, we will create a fully functional and visually appealing combobox component using React and TypeScript, without relying on external component libraries. Our goal is to make it flexible and reusable. To demonstrate its versatility, we'll construct two applications: a country selector and a cryptocurrency input. By the end of this article, you'll have the knowledge to implement your own input with a dropdown list tailored to your project's requirements. You can view a demo on this page, and the complete source code is accessible in the ReactKit repository.

Implementing a Country Selector: Enhancing User Experience with Dynamic Search and Navigation

Instead of creating a generic reusable component right away, let's focus on a specific use case first. In my productivity app, Increaser, I needed to incorporate an input for users to select a country for their public profile. To simplify the search process, we will integrate a dropdown list with a search feature. Users can type their country's name, and the list will dynamically filter to match the input. Additionally, users can navigate through the list using arrow keys and select a country with the Enter key. We also plan to display each country's flag in the list and show the selected country's flag in the input field. Moreover, including a clear button to reset the input would enhance usability.

Country selector at Increaser

Defining Properties for the Country Input Component in React

Now, let's determine the properties that our country input component should accept. The value prop will represent the selected country and can be either a two-letter country code string or null if no country is selected. Additionally, an onChange callback is essential to update this value. We should also consider an optional label prop, which will be displayed above the input field.

export interface InputProps<T> {
  value: T
  onChange: (value: T) => void
}

interface CountryInputProps extends InputProps<CountryCode | null> {
  label?: React.ReactNode
}
Enter fullscreen mode Exit fullscreen mode

Designing the Cryptocurrency Input: UI and Property Specifications

The user interface for the cryptocurrency input will closely resemble that of the country input, with a few key differences. Instead of displaying a country flag, we will showcase the logo of the cryptocurrency. Additionally, in the dropdown options, we will present both the cryptocurrency symbol and its name, providing a comprehensive and user-friendly selection experience.

Cryptocurrency input from ReactKit

The properties for the cryptocurrency input mirror those of the country input in many ways. The value prop will indicate the chosen cryptocurrency, which can either be an Asset object or null. An Asset here is defined as an object with fields such as id, name, and icon. Unlike the country input, which inherently knows all available countries, the cryptocurrency input will require a list of assets, provided via the options prop. Similar to the previous component, the label prop remains optional.

export interface Asset {
  id: string
  name: string
  icon?: string
}

interface AssetInputProps extends InputProps<Asset | null> {
  label?: React.ReactNode
  options: Asset[]
}
Enter fullscreen mode Exit fullscreen mode

Creating FixedOptionsInput: A Reusable Component for Selecting From Fixed Options

These components share several similarities, allowing us to abstract the common logic into a reusable component named FixedOptionsInput. This component will cater to selections from a predetermined set of options and will accommodate the following properties:

  • value and onChange: These represent the selected option. We will use a generic type T, making it adaptable to different data types โ€“ a string for the country input and an Asset object for the cryptocurrency input.
  • placeholder and label: Essential attributes for any text input.
  • options: This prop supplies the list of selectable options.
  • getOptionKey: A function to derive a unique key from each option, to be used in the React key attribute.
  • renderOption: This allows for custom rendering of each option in the list.
  • getOptionSearchStrings: A function to extract an array of strings from each option, enhancing the search functionality.
  • getOptionName: This retrieves the name of the selected option for display in the input field.
  • renderOptionIdentifier: A method for rendering an identifier for the selected option, such as a country flag for country input or a cryptocurrency icon for cryptocurrency input.
  • optionIdentifierPlaceholder: A placeholder for the identifier when no option is selected. For instance, a gray rectangle for the country input and a gray circle for the cryptocurrency input.
interface FixedOptionsInputProps<T> extends InputProps<T | null> {
  placeholder?: string
  label?: ReactNode

  options: T[]
  getOptionKey: (option: T) => string
  renderOption: (option: T) => ReactNode
  getOptionSearchStrings: (option: T) => string[]
  getOptionName: (option: T) => string
  renderOptionIdentifier: (option: T) => ReactNode
  optionIdentifierPlaceholder: ReactNode
}
Enter fullscreen mode Exit fullscreen mode

Implementing CountryInput and AssetInput Using FixedOptionsInput

Here is the implementation of the CountryInput component, which utilizes the FixedOptionsInput component. We forward the value, onChange, and label directly to FixedOptionsInput. The options are set to the list of countryCodes. For search functionality, we include only the country name as search strings, as users are more likely to search by name rather than code. To obtain the country names, we use a predefined record. The flag is used as a visual identifier for each country. If you're interested in how I generated all the SVG icons for the flags, you can find details in my previous article about TypeScript code generation. For rendering the options in the dropdown, we employ a helper component named OptionContent, which provides a visually appealing layout with the icon on the left and the name on the right.

import {
  CountryCode,
  countryCodes,
  countryNameRecord,
} from "@reactkit/utils/countries"
import { InputProps } from "../props"
import { FixedOptionsInput } from "./FixedOptionsInput"
import CountryFlag from "../countries/flags/CountryFlag"
import { IconWrapper } from "../icons/IconWrapper"
import styled from "styled-components"
import { CountryFlagFrame } from "../countries/CountryFlagFrame"
import { OptionContent } from "./FixedOptionsInput/OptionContent"

interface CountryInputProps extends InputProps<CountryCode | null> {
  label?: React.ReactNode
}

const FlagWrapper = styled(IconWrapper)`
  border-radius: 2px;
`

export const CountryInput = ({ value, onChange, label }: CountryInputProps) => {
  return (
    <FixedOptionsInput
      value={value}
      label={label}
      onChange={onChange}
      placeholder="Search for a country"
      options={countryCodes}
      getOptionSearchStrings={(code) => [countryNameRecord[code]]}
      getOptionName={(code) => countryNameRecord[code]}
      getOptionKey={(code) => code}
      renderOptionIdentifier={(code) => (
        <FlagWrapper>
          <CountryFlag code={code} />
        </FlagWrapper>
      )}
      optionIdentifierPlaceholder={
        <FlagWrapper>
          <CountryFlagFrame />
        </FlagWrapper>
      }
      renderOption={(code) => (
        <OptionContent
          identifier={
            <FlagWrapper>
              <CountryFlag code={code} />
            </FlagWrapper>
          }
          name={countryNameRecord[code]}
        />
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The implementation of the AssetInput closely mirrors that of the CountryInput, with a few distinctions. For displaying the identifier, we use the AssetIcon component. The search strings include both the name and id of the asset. Additionally, the renderOption function is slightly modified to display both the name and id in the dropdown list.

import { InputProps } from "../props"
import { FixedOptionsInput } from "../inputs/FixedOptionsInput"
import styled from "styled-components"
import { OptionContent } from "../inputs/FixedOptionsInput/OptionContent"
import { Asset } from "@reactkit/entities/Asset"
import { round } from "../css/round"
import { sameDimensions } from "../css/sameDimensions"
import { getColor } from "../theme/getters"
import { VStack } from "../layout/Stack"
import { Text } from "../text"
import { AssetIcon } from "./AssetIcon"

interface AssetInputProps extends InputProps<Asset | null> {
  label?: React.ReactNode
  options: Asset[]
}

const IdentifierPlaceholder = styled.div`
  ${round};
  ${sameDimensions("1em")};
  background: ${getColor("mist")};
`

export const AssetInput = ({
  value,
  onChange,
  label,
  options,
}: AssetInputProps) => {
  return (
    <FixedOptionsInput
      value={value}
      label={label}
      onChange={onChange}
      placeholder="Search for an asset"
      options={options}
      getOptionSearchStrings={(option) => [option.name, option.id]}
      getOptionName={(option) => option.name}
      getOptionKey={(option) => option.id}
      renderOptionIdentifier={({ name, icon }) => (
        <AssetIcon name={name} src={icon} />
      )}
      optionIdentifierPlaceholder={<IdentifierPlaceholder />}
      renderOption={({ name, id, icon }) => (
        <OptionContent
          identifier={<AssetIcon name={name} src={icon} />}
          name={
            <VStack>
              <Text size={14} weight="semibold">
                {name}
              </Text>
              <Text size={14} color="shy">
                {id}
              </Text>
            </VStack>
          }
        />
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Deep Dive into the Combobox Component Implementation

Having outlined all the requirements for our combobox, let's dive into the implementation. Despite the efforts to modularize the code by splitting it into different files, this remains one of those rare components that spans about 200 lines.

import { ReactNode, useCallback, useMemo, useRef, useState } from "react"
import { InputProps } from "../../props"
import { useEffectOnDependencyChange } from "../../hooks/useEffectOnDependencyChange"
import { getSuggestions } from "./getSuggestions"
import { NoMatchesMessage } from "./NoMatchesMessage"
import { FixedOptionsInputItem } from "./OptionItem"
import { FixedOptionsInputOptionsContainer } from "./OptionsContainer"
import { FixedOptionsInputIdentifierWrapper } from "./IdentifierWrapper"
import { Text } from "../../text"
import { RelativeRow } from "../../layout/RelativeRow"
import { InputContainer } from "../InputContainer"
import { FixedOptionsInputTextInput } from "./TextInput"
import { useFloatingOptions } from "./useFloatingOptions"
import { FixedOptionsInputButtons } from "./Buttons"

interface FixedOptionsInputProps<T> extends InputProps<T | null> {
  placeholder?: string
  label?: ReactNode

  options: T[]
  getOptionKey: (option: T) => string
  renderOption: (option: T) => ReactNode
  getOptionSearchStrings: (option: T) => string[]
  getOptionName: (option: T) => string
  renderOptionIdentifier: (option: T) => ReactNode
  optionIdentifierPlaceholder: ReactNode
}

export function FixedOptionsInput<T>({
  value,
  label,
  onChange,
  placeholder,
  options,
  renderOption,
  getOptionSearchStrings,
  getOptionName,
  renderOptionIdentifier,
  optionIdentifierPlaceholder,
  getOptionKey,
}: FixedOptionsInputProps<T>) {
  const inputElement = useRef<HTMLInputElement>(null)

  const [textInputValue, setTextInputValue] = useState(() =>
    value ? getOptionName(value) : ""
  )

  const optionsToDisplay = useMemo(() => {
    if (value) {
      return options
    }

    return getSuggestions({
      inputValue: textInputValue,
      options,
      getOptionSearchStrings,
    })
  }, [getOptionSearchStrings, options, textInputValue, value])

  const {
    activeIndex,
    setActiveIndex,
    getReferenceProps,
    setReferenceRef,
    getFloatingProps,
    setFloatingRef,
    floatingStyles,
    getItemProps,
    optionsRef,
    areOptionsVisible,
    showOptions,
    hideOptions,
    toggleOptionsVisibility,
  } = useFloatingOptions()

  useEffectOnDependencyChange(() => {
    if (!value) return

    const valueName = getOptionName(value)
    if (textInputValue === valueName) return

    setTextInputValue(valueName)
  }, [value])

  const onTextInputChange = useCallback(
    (newValue: string) => {
      showOptions()

      if (value && newValue !== getOptionName(value)) {
        onChange(null)
      }

      setTextInputValue(newValue)
    },
    [getOptionName, onChange, showOptions, value]
  )

  useEffectOnDependencyChange(() => {
    if (!areOptionsVisible || optionsToDisplay.length === 0) return

    setActiveIndex(0)
  }, [textInputValue])

  return (
    <InputContainer
      onClick={() => {
        inputElement.current?.focus()
      }}
      onKeyDown={(event) => {
        if (event.key === "Enter" && activeIndex != null) {
          event.preventDefault()
          onChange(optionsToDisplay[activeIndex])
          setActiveIndex(null)
          hideOptions()
        }
      }}
    >
      {label && <Text as="div">{label}</Text>}
      <RelativeRow
        {...getReferenceProps({
          ref: setReferenceRef,
        })}
      >
        <FixedOptionsInputIdentifierWrapper>
          {value ? renderOptionIdentifier(value) : optionIdentifierPlaceholder}
        </FixedOptionsInputIdentifierWrapper>
        <FixedOptionsInputTextInput
          ref={inputElement}
          value={textInputValue}
          onChange={(event) => onTextInputChange(event.currentTarget.value)}
          placeholder={placeholder}
          aria-autocomplete="list"
        />
        {areOptionsVisible && (
          <FixedOptionsInputOptionsContainer
            {...getFloatingProps({
              ref: setFloatingRef,
              style: floatingStyles,
            })}
          >
            {optionsToDisplay.length > 0 ? (
              optionsToDisplay.map((option, index) => (
                <FixedOptionsInputItem
                  {...getItemProps({
                    ref: (element) => {
                      optionsRef.current[index] = element
                    },
                    key: getOptionKey(option),
                    onClick: () => {
                      onChange(option)
                      inputElement.current?.focus()
                      hideOptions()
                    },
                  })}
                  active={index === activeIndex}
                >
                  {renderOption(option)}
                </FixedOptionsInputItem>
              ))
            ) : (
              <NoMatchesMessage />
            )}
          </FixedOptionsInputOptionsContainer>
        )}
        <FixedOptionsInputButtons
          onClear={
            textInputValue
              ? () => {
                  onTextInputChange("")
                  inputElement.current?.focus()
                }
              : undefined
          }
          areOptionsVisible={areOptionsVisible}
          toggleOptionsVisibility={() => {
            toggleOptionsVisibility()
            inputElement.current?.focus()
          }}
        />
      </RelativeRow>
    </InputContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's delve into the structure of our component. We start by enclosing all elements within an InputContainer component. This component acts as a label element, thereby enhancing accessibility. We've crafted it using flexbox to ensure a neat gap between the label text and the input field. Moreover, we've added a transition effect on the color property, employed to change the text color when the input is focused.

import { css } from "styled-components"
import { transition } from "./transition"
import { getColor } from "../theme/getters"

export const inputContainer = css`
  display: flex;
  flex-direction: column;
  width: 100%;
  gap: 8px;

  ${transition};
  color: ${getColor("textSupporting")};

  :focus-within {
    color: ${getColor("text")};
  }
`

export const InputContainer = styled.label`
  ${inputContainer};
`
Enter fullscreen mode Exit fullscreen mode

Inside the InputContainer, we begin with a label, if provided. Next, we utilize the RelativeRow component. Designed as a flexbox element with a relative position, its align-items property is set to center. This setup guarantees that any absolutely positioned children are horizontally aligned within it.

import styled from "styled-components"

export const RelativeRow = styled.div`
  width: 100%;
  position: relative;
  display: flex;
  align-items: center;
`
Enter fullscreen mode Exit fullscreen mode

Within the RelativeRow, there are four elements. The first one is the FixedOptionsInputIdentifierWrapper. This wrapper is carefully designed to align the icon perfectly with the input field. We achieve this alignment by setting the wrapper's left property to match the input's padding. The font size inside this wrapper is consistently defined in the config.ts file located in the FixedOptionsInput folder. This arrangement ensures that when adding an identifier, there's no need for manual size adjustments; instead, we use 1em for both width and height. For country flags, which are rectangular, we set their largest dimension to 1em.

import styled from "styled-components"
import { toSizeUnit } from "../../css/toSizeUnit"
import { textInputPadding } from "../../css/textInput"
import { fixedOptionsInputConfig } from "./config"

export const FixedOptionsInputIdentifierWrapper = styled.div`
  position: absolute;
  font-size: ${toSizeUnit(fixedOptionsInputConfig.identifierSize)};
  left: ${toSizeUnit(textInputPadding)};
  pointer-events: none;
  display: flex;
`
Enter fullscreen mode Exit fullscreen mode

After the identifier wrapper, we position the text input. This input adopts the textInput CSS, which includes all the crucial styling for text fields. Our primary focus here is to fine-tune the padding on both the left and right sides. This adjustment is vital to prevent the text from overlapping with the absolutely positioned icon on the left and the buttons on the right, thereby ensuring a neat and user-friendly interface.

import styled from "styled-components"
import { textInput, textInputPadding } from "../../css/textInput"
import { toSizeUnit } from "../../css/toSizeUnit"
import { fixedOptionsInputConfig } from "./config"
import { iconButtonSizeRecord } from "../../buttons/IconButton"

export const FixedOptionsInputTextInput = styled.input`
  ${textInput};
  padding-left: ${toSizeUnit(
    fixedOptionsInputConfig.identifierSize + textInputPadding * 2
  )};
  padding-right: ${toSizeUnit(
    iconButtonSizeRecord[fixedOptionsInputConfig.iconButtonSize] * 2 +
      textInputPadding +
      fixedOptionsInputConfig.buttonsSpacing
  )};
`
Enter fullscreen mode Exit fullscreen mode

When the options become visible, we display the options container. Its placement is managed by the floating-ui library, which we will examine shortly. To guarantee that the container appears above other elements on the page, we assign it a z-index. The container is essentially a straightforward div with a defined border and border radius, creating a distinct and elegant appearance. Additionally, we set the max-height and overflow-y properties. These settings ensure that if the list of options exceeds the maximum height, it becomes scrollable, thereby maintaining a user-friendly and accessible interface.

import styled from "styled-components"
import { getColor } from "../../theme/getters"
import { toSizeUnit } from "../../css/toSizeUnit"
import { textInputBorderRadius } from "../../css/textInput"

export const FixedOptionsInputOptionsContainer = styled.div`
  background: ${getColor("foreground")};
  border: 1px solid ${getColor("mist")};

  border-radius: ${toSizeUnit(textInputBorderRadius)};
  overflow: hidden;
  max-height: 280px;
  overflow-y: auto;

  z-index: 1;
`
Enter fullscreen mode Exit fullscreen mode

In the dropdown list, if there are options that match the input value, we display them accordingly. However, if no matches are found, a message is shown to indicate this. Each option in the list is wrapped with the FixedOptionsInputItem, a component that essentially styles a div element. We visually distinguish the currently selected item by changing its background color to a color named mist. For better accessibility, we have defined both role and aria-selected attributes for each item. To ensure the floating-ui library handles these options correctly, each element must have a unique identifier. This is accomplished using the useId hook from React, which generates these unique IDs.

import styled from "styled-components"
import { transition } from "../../css/transition"
import { horizontalPadding } from "../../css/horizontalPadding"
import { textInputPadding } from "../../css/textInput"
import { verticalPadding } from "../../css/verticalPadding"
import { getColor } from "../../theme/getters"
import { ComponentProps, forwardRef, useId } from "react"
import { interactive } from "../../css/interactive"

export const Container = styled.div`
  width: 100%;
  ${transition};
  ${interactive};

  ${horizontalPadding(textInputPadding)};
  ${verticalPadding(8)}
  &[aria-selected='true'] {
    background: ${getColor("mist")};
  }
`

export const FixedOptionsInputItem = forwardRef<
  HTMLDivElement,
  ComponentProps<typeof Container>
>(({ children, active, ...rest }, ref) => {
  const id = useId()

  return (
    <Container ref={ref} role="option" id={id} aria-selected={active} {...rest}>
      {children}
    </Container>
  )
})
Enter fullscreen mode Exit fullscreen mode

The last component is the FixedOptionsInputButtons, which is displayed as an absolutely positioned flexbox element containing "Clear" and collapse buttons. In a manner akin to the identifier wrapper, we set its right attribute to textInputPadding for proper alignment. The "Clear" button is only displayed when the input is not empty. The collapse button, on the other hand, is always visible, but its icon changes based on the visibility of the options. Rather than using onClick for the toggle button, we opt for onMouseDown and onTouchStart. This is because if we used onClick, when the options are hidden and the user clicks on the button, the options would briefly show and then hide again. This occurs since we also listen for focus within the label to display the options, and by the time the onClick event is triggered, the focus has already shifted to the label.

import styled from "styled-components"
import { HStack } from "../../layout/Stack"
import { toSizeUnit } from "../../css/toSizeUnit"
import { textInputPadding } from "../../css/textInput"
import { IconButton } from "../../buttons/IconButton"
import { fixedOptionsInputConfig } from "./config"
import { CloseIcon } from "../../icons/CloseIcon"
import { CollapseToggleButton } from "../../buttons/CollapseToggleButton"

const Container = styled(HStack)`
  position: absolute;
  gap: 4px;
  right: ${toSizeUnit(textInputPadding)};
`

interface FixedOptionsInputButtonsProps {
  onClear?: () => void
  areOptionsVisible: boolean
  toggleOptionsVisibility: () => void
}

export const FixedOptionsInputButtons = ({
  onClear,
  areOptionsVisible,
  toggleOptionsVisibility,
}: FixedOptionsInputButtonsProps) => (
  <Container>
    {onClear && (
      <IconButton
        size={fixedOptionsInputConfig.iconButtonSize}
        icon={<CloseIcon />}
        title="Clear"
        kind="secondary"
        onClick={onClear}
      />
    )}
    <CollapseToggleButton
      size={fixedOptionsInputConfig.iconButtonSize}
      kind="secondary"
      isOpen={areOptionsVisible}
      onMouseDown={toggleOptionsVisibility}
      onTouchStart={toggleOptionsVisibility}
    />
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

For animating the chevron icon on the collapse button, we encase it in a wrapper and introduce a transition to the internal SVG. This transition is paired with a rotateZ transform, effectively creating a smooth animation effect for the icon.

import styled from "styled-components"
import { ComponentProps, Ref, forwardRef } from "react"

import { IconButton } from "./IconButton"
import { transition } from "../css/transition"
import { ChevronDownIcon } from "../icons/ChevronDownIcon"

type CollapseToggleButtonProps = Omit<
  ComponentProps<typeof IconButton>,
  "icon" | "title"
> & {
  isOpen: boolean
}

const IconWrapper = styled.div<{ isOpen: boolean }>`
  display: flex;
  svg {
    ${transition};
    transform: rotateZ(${({ isOpen }) => (isOpen ? "-180deg" : "0deg")});
  }
`

export const CollapseToggleButton = forwardRef(
  function CollapsableToggleIconButton(
    { isOpen, ...props }: CollapseToggleButtonProps,
    ref: Ref<HTMLButtonElement> | null
  ) {
    return (
      <IconButton
        ref={ref}
        {...props}
        title={isOpen ? "Collapse" : "Expand"}
        icon={
          <IconWrapper isOpen={isOpen}>
            <ChevronDownIcon />
          </IconWrapper>
        }
      />
    )
  }
)
Enter fullscreen mode Exit fullscreen mode

Implementing Dropdown Positioning and Keyboard Navigation with useFloatingOptions

The positioning and keyboard navigation for the dropdown are efficiently encapsulated within the useFloatingOptions hook. We initiate by establishing a state for the visibility of options. To enhance ease of use, we rely on the useBoolean hook.

import { useCallback, useState } from "react"

export function useBoolean(initial: boolean) {
  const [value, setValue] = useState(initial)

  const set = useCallback(() => setValue(true), [])
  const unset = useCallback(() => setValue(false), [])

  const toggle = useCallback(() => setValue((old) => !old), [])

  const update = useCallback((value: boolean) => setValue(value), [])

  return [value, { set, unset, toggle, update }] as const
}
Enter fullscreen mode Exit fullscreen mode

Next, we set up the positioning of the floating dropdown container using the useFloating hook. Our goal is to align the options right below the input field, so we choose bottom-start as our placement. We use a fixed positioning strategy to ensure visibility of the dropdown even if the input is inside a container with overflow: hidden. Although we inform the floating-ui about the dropdown's open state, we refrain from allowing it to alter the options state. This is because I've found managing the open state more straightforward without depending on the library. To create a slight gap between the input and the dropdown, we add an offset of 4 pixels. We also employ the size middleware to dynamically adjust the dropdown's width to match the input fieldโ€™s width.

import { autoUpdate, offset, shift, size } from "@floating-ui/dom"
import {
  useFloating,
  useInteractions,
  useListNavigation,
  useRole,
} from "@floating-ui/react"
import { toSizeUnit } from "../../css/toSizeUnit"
import { useRef, useState } from "react"
import { useBoolean } from "../../hooks/useBoolean"
import { useHasFocusWithin } from "../../hooks/useHasFocusWithin"
import { useEffectOnDependencyChange } from "../../hooks/useEffectOnDependencyChange"
import { useKey } from "react-use"

export const useFloatingOptions = () => {
  const [
    areOptionsVisible,
    { set: showOptions, unset: hideOptions, toggle: toggleOptionsVisibility },
  ] = useBoolean(false)

  const { refs, context, floatingStyles } = useFloating<HTMLDivElement>({
    placement: "bottom-start",
    strategy: "fixed",
    open: areOptionsVisible,
    whileElementsMounted: autoUpdate,
    middleware: [
      offset(4),
      shift(),
      size({
        apply({ rects, elements }) {
          Object.assign(elements.floating.style, {
            width: toSizeUnit(rects.reference.width),
          })
        },
      }),
    ],
  })

  const labelHasFocusWithin = useHasFocusWithin(refs.domReference)
  useEffectOnDependencyChange(() => {
    if (labelHasFocusWithin) {
      showOptions()
    } else {
      hideOptions()
    }
  }, [labelHasFocusWithin])

  useKey("Escape", hideOptions)

  const optionsRef = useRef<Array<HTMLElement | null>>([])

  const [activeIndex, setActiveIndex] = useState<number | null>(null)

  const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
    [
      useRole(context, { role: "listbox" }),
      useListNavigation(context, {
        listRef: optionsRef,
        activeIndex,
        onNavigate: setActiveIndex,
        virtual: true,
        loop: true,
      }),
    ]
  )

  return {
    referenceRef: refs.domReference,
    setReferenceRef: refs.setReference,
    setFloatingRef: refs.setFloating,
    floatingStyles,
    optionsRef,
    activeIndex,
    getReferenceProps,
    getFloatingProps,
    getItemProps,
    setActiveIndex,
    areOptionsVisible,
    showOptions,
    hideOptions,
    toggleOptionsVisibility,
  } as const
}
Enter fullscreen mode Exit fullscreen mode

To display the dropdown, we require that the label container has focus within it. While the focus-within CSS selector is an option, our component's code benefits from having a variable for this state. Therefore, we utilize the useHasFocusWithin hook. This hook takes a ref of the element and implements focusin and focusout event listeners. When the element receives focus (focusin), we set the isFocused state to true. Conversely, when focus is lost (focusout), we verify if the newly focused element is outside the ref element. If so, we update the isFocused state to false.

import { useEffect, RefObject } from "react"
import { useBoolean } from "./useBoolean"

const containsRelatedTarget = ({
  currentTarget,
  relatedTarget,
}: FocusEvent) => {
  if (
    currentTarget instanceof HTMLElement &&
    relatedTarget instanceof HTMLElement
  ) {
    return currentTarget.contains(relatedTarget)
  }

  return false
}

export function useHasFocusWithin(ref: RefObject<HTMLElement>): boolean {
  const [isFocused, { set: focus, unset: blur }] = useBoolean(false)

  useEffect(() => {
    const element = ref.current
    if (!element) return

    const handleFocusOut = (event: FocusEvent) => {
      if (!containsRelatedTarget(event)) {
        blur()
      }
    }

    element.addEventListener("focusin", focus)
    element.addEventListener("focusout", handleFocusOut)

    return () => {
      element.removeEventListener("focusin", focus)
      element.removeEventListener("focusout", handleFocusOut)
    }
  }, [blur, focus, ref])

  return isFocused
}
Enter fullscreen mode Exit fullscreen mode

We then employ the useEffectOnDependencyChange hook to control the visibility of the options, showing them when the label gains focus and hiding them when it loses focus. To guarantee that this behavior is triggered exclusively in response to changes in labelHasFocusWithin, we make use of the useEffectOnDependencyChange hook from ReactKit. This hook operates in a manner akin to the useEffect hook, but it's specifically tailored to activate only when its dependencies change. To hide the options when the Escape key is pressed, we use the useKey hook from react-use.

import { DependencyList, useEffect, useRef } from "react"

export const useEffectOnDependencyChange = (
  effect: () => void,
  deps: DependencyList
) => {
  const prevDeps = useRef(deps)
  useEffect(() => {
    const hasDepsChanged = !prevDeps.current.every((dep, i) => dep === deps[i])
    if (hasDepsChanged) {
      effect()
      prevDeps.current = deps
    }
  }, [deps, effect])
}
Enter fullscreen mode Exit fullscreen mode

The useListNavigation hook is instrumental in enabling keyboard navigation among the items in the dropdown. Notably, floating-ui takes care of auto-scrolling, ensuring that the currently selected item is always visible within the dropdown. To supply the useListNavigation hook with the list of items, we utilize the optionsRef array. For improved accessibility, the useRole hook is employed to set the role attribute on the dropdown container. Furthermore, the useInteractions hook amalgamates these functionalities, providing getReferenceProps, getFloatingProps, and getItemProps functions. These functions are crucial for attaching the necessary event handlers and attributes to the corresponding elements.

Implementing Intelligent Search: The getSuggestions Function for Dropdown Options

When the value is already selected and the dropdown is open we show all the options, otherwise we rely on the getSuggestions helper. It will lower case the input value and search through the options. If the option's name starts with the input value, it will be added to the primaryMatches array. Otherwise, if the option's name includes the input value, it will be added to the secondaryMatches array. Finally, we concatenate the two arrays and return the result.

interface GetSuggestionsParams<T> {
  inputValue: string
  options: T[]
  getOptionSearchStrings: (option: T) => string[]
}

export const getSuggestions = <T,>({
  inputValue,
  options,
  getOptionSearchStrings,
}: GetSuggestionsParams<T>) => {
  const matchString = inputValue.toLowerCase()

  const primaryMatches: T[] = []
  const secondaryMatches: T[] = []

  options.forEach((option) => {
    const searchStrings = getOptionSearchStrings(option).map((s) =>
      s.toLowerCase()
    )
    if (searchStrings.find((s) => s.startsWith(matchString))) {
      primaryMatches.push(option)
    } else if (searchStrings.find((s) => s.includes(matchString))) {
      secondaryMatches.push(option)
    }
  })

  return [...primaryMatches, ...secondaryMatches]
}
Enter fullscreen mode Exit fullscreen mode

Synchronizing Input and Value Changes in FixedOptionsInput with React Hooks

As the user types in the input, the onTextInputChange function is activated. This function performs several key actions: it ensures that the dropdown remains open, clears the current value if it does not correspond to the name of the selected option, and updates the textInputValue state.

const onTextInputChange = useCallback(
  (newValue: string) => {
    stopHidingOptions()

    if (value && newValue !== getOptionName(value)) {
      onChange(null)
    }

    setTextInputValue(newValue)
  },
  [getOptionName, onChange, stopHidingOptions, value]
)
Enter fullscreen mode Exit fullscreen mode

Facilitating Option Selection with onKeyDown: Handling Enter Key in Dropdown

To enable users to select the highlighted item in the dropdown, we add an onKeyDown listener to the label container. When the Enter key is pressed, we first stop the event's propagation to prevent form submission if the input is within a form. If an item is highlighted and the Enter key is pressed, we execute the onChange callback with the selected option, reset the activeIndex state, and subsequently hide the dropdown. To improve the user experience we bring the focus back to the input field on a click within the label container.

<InputContainer
  onClick={() => {
    inputElement.current?.focus()
  }}
  onKeyDown={(event) => {
    if (event.key === 'Enter' && activeIndex != null) {
      event.preventDefault()
      onChange(optionsToDisplay[activeIndex])
      setActiveIndex(null)
      hideOptions()
    }
  }}
>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)