Dropdown

Dropdown component is a controlled single-value select follows a11y guidelines.

  • Supports a onChange function that receives string | undefined
  • Selecting an option passes the value string;clearing always passes undefined.
  • The menu auto-repositions based on viewport space, and search auto-focuses when the dropdown opens.
  • Supports disabled options, validation states, and an info tooltip on the label.

Import

import { Dropdown } from "h2o-library";
// optional, if you want to type your options
import type { DropdownOption } from "h2o-library/types";

Usage

Basic

const [value, setValue] = useState<string | undefined>(undefined);

<Dropdown
  label="Department"
  options={options}
  value={value}
  onChange={setValue}
  placeholder="Select a department"
/>;

Sizes

Multiple sizes are available for the trigger button, matching our Input component sizes.

The menu and search input scale with the trigger.

The search input auto-focuses when the dropdown opens.

<Dropdown options={options} enableSearch placeholder="Type to filter…" />;

Clear Button

Calling onChange with undefined resets the selection. Use undefined (not "") to represent "nothing selected".

Disabled Options

Trough the disabled property on options, you can prevent selection of specific items while still showing them in the list.

Tooltip on Label

You can add a tooltip to the label, which appears on hover of an info icon next to the label text.

Validation States

Use the error prop to show an error message and red styling, and the note prop for neutral helper text.

Show validation messages after form submission or on field blur, not on every change.

Callbacks

onOpen and onClose callbacks allow you to respond to menu state changes, e.g. for analytics or to reset search input.

<Dropdown
  options={options}
  onOpen={() => console.log("Dropdown opened")}
  onClose={() => console.log("Dropdown closed")}
/>;

Playground

Loading playground…

Usage in Forms

Dropdown is a controlled component. For native form participation, combine with a hidden <input>.

<form
  onSubmit={(e) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get("department")); // value from hidden input
  }}
>
  <input type="hidden" name="department" value={department ?? ""} />
  <Dropdown
    label="Department"
    options={options}
    value={department}
    onChange={setDepartment}
    placeholder="Select…"
  />
  <Button type="submit" label="Save" />
</form>;

Controlled with useState

const [specialty, setSpecialty] = useState<string | undefined>(undefined);
const [error, setError] = useState("");

function handleSubmit() {
  if (!specialty) {
    setError("Please select a specialty.");
    return;
  }
  setError("");
  // submit
}

<Dropdown
  label="Specialty"
  options={specialtyOptions}
  value={specialty}
  onChange={(v) => {
    setSpecialty(v);
    setError("");
  }}
  error={error}
  placeholder="Select specialty"
/>;

With react-hook-form (Controller)

Dropdown is not a native input, so use Controller to integrate it.

import { useForm, Controller } from "react-hook-form";

function PatientForm() {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<{
    specialty: string;
  }>();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="specialty"
        control={control}
        rules={{ required: "Please select a specialty." }}
        render={({ field }) => (
          <Dropdown
            label="Specialty"
            options={specialtyOptions}
            value={field.value}
            onChange={(v) => field.onChange(v ?? "")}
            error={errors.specialty?.message}
            placeholder="Select specialty"
          />
        )}
      />
      <Button type="submit" label="Save patient" />
    </form>
  );
}

Keyboard

While focus is inside the dropdown container (trigger, clear control, search field when open, or options):

KeyAction
EscapeCloses an open menu (and clears search when enableSearch is on).
Enter / SpaceStandard button behavior on the trigger (open/close) and each role="option" row (select).
Tab / Shift+TabMove focus through interactive controls in the menu.

Props

PropTypeDefaultDescription
options*DropdownOption[]List of options to display.
valuestringundefinedCurrently selected value (controlled).
onChange(value: string | undefined) => voidCalled on selection. Passes undefined when cleared.
labelstringundefinedLabel displayed above the dropdown.
placeholderstring"Select an option"Text shown when no value is selected.
size"sm" | "md" | "lg""md"Size of the dropdown trigger.
disabledbooleanfalseDisables the dropdown.
enableSearchbooleanfalseAdds a search input that auto-focuses when the menu opens.
showClearbooleanfalseShows a clear button that calls onChange(undefined).
direction"top" | "bottom" | "auto""auto"Forces or auto-detects menu direction.
tooltipstringundefinedTooltip shown on an info icon next to the label.
tooltipPosition"top" | "bottom" | "left" | "right""top"Position of the tooltip.
errorstringundefinedError message displayed below the dropdown.
notestringundefinedHelper text displayed below the dropdown.
onOpen() => voidundefinedFired when the menu opens.
onClose() => voidundefinedFired when the menu closes.
containerClassNamestringundefinedAdditional CSS classes for the outer container.
arrowClassNamestringundefinedAdditional CSS classes for the chevron icon.

* Required

PropTypeDefaultDescription
label*stringDisplay text.
value*stringUnique value identifier.
disabledbooleanfalsePrevents the option from being selected.

* Required

Design Guidelines

  • Use Dropdown for single-value selection from a bounded list (≤ ~50 items without search).
  • Use enableSearch for lists with more than ~8 items.
  • Use undefined (not "") to represent "no selection" — onChange already does this on clear.
  • Show error after form submission or on field blur, not on every change.

Accessibility

  • The trigger is a <button> with aria-expanded, aria-haspopup="listbox", and aria-controls.
  • The menu has role="listbox". Each option has role="option" and aria-selected.
  • Escape closes the menu and returns focus to the trigger.
  • The label is linked to the trigger via htmlFor/id.