Select

A form field that picks one or more options from a list: a trigger that looks like an Input, a Popup, and a Listbox. For a few short options always in view, use a segmented control or a Radio group; to filter a list or table, use a Filter chip.

Installation

bun add @harmo-ui/css @harmo-ui/react

Usage

import { Option, Select } from "@harmo-ui/react";

<Select label="Channel" name="channel" defaultValue="whatsapp">
  <Option value="whatsapp">WhatsApp</Option>
  <Option value="telegram">Telegram</Option>
  <Option value="viber">Viber</Option>
</Select>

Examples

This site is using your theme

Default

A trigger that looks like an Input, a Popup, and a Listbox. The popup opens and closes without a script. Focus stays on the trigger: the Arrow keys open the list and move the highlight, and Enter or Space chooses.

Channel
  • WhatsApp
  • Telegram
  • Viber
<Select label="Channel" name="channel" defaultValue="whatsapp">
  <Option value="whatsapp">WhatsApp</Option>
  <Option value="telegram">Telegram</Option>
  <Option value="viber">Viber</Option>
</Select>

Placeholder

data-placeholder on the value grays it while nothing is chosen. The trigger takes a Field's Optional marker and description like any control.

Fallback channelOptional

Used when a message can't be delivered on the main channel.

  • Email
  • SMS
<Select
  label="Fallback channel"
  optional="Optional"
  description="Used when a message can't be delivered on the main channel."
  name="fallback"
  placeholder="Select a channel"
>
  <Option value="email">Email</Option>
  <Option value="sms">SMS</Option>
</Select>

Multiple

With aria-multiselectable="true" on the list, each option starts with a .hoption-checkbox, the value lists the chosen labels, and the popup stays open while you choose.

Message statuses
  • Delivered
  • Read
  • Failed
  • Pending
<Select
  multiple
  label="Message statuses"
  name="statuses"
  placeholder="Any status"
  defaultValue={["delivered", "read"]}
>
  <Option value="delivered">Delivered</Option>
  <Option value="read">Read</Option>
  <Option value="failed">Failed</Option>
  <Option value="pending">Pending</Option>
</Select>

Long value

In a narrow container, drawn dashed here, the value stays on one line and ends with an ellipsis.

Sender number
  • +41 44 7654321 · Customer support for Switzerland
  • +44 20 7946 0000 · Sales
<Select label="Sender number" name="sender" defaultValue="support">
  <Option value="support">
    +41 44 7654321 · Customer support for Switzerland
  </Option>
  <Option value="sales">+44 20 7946 0000 · Sales</Option>
</Select>

Invalid

aria-invalid="true" turns the border red, as for an Input.

Template language
  • English
  • Spanish
  • Arabic

Choose a language for the template.

<Select
  label="Template language"
  name="language"
  placeholder="Select a language"
  error="Choose a language for the template."
>
  <Option value="en">English</Option>
  <Option value="es">Spanish</Option>
  <Option value="ar">Arabic</Option>
</Select>

Disabled

disabled grays the trigger and its chevron, as for an Input. Disable its hidden input too, so the form doesn't send it, as with a disabled native select.

Region
<Select label="Region" name="region" defaultValue="europe" disabled>
  <Option value="europe">Europe</Option>
</Select>

Disabling

Disabling the trigger disables its hidden inputs too, so a form leaves the value out, as it would a native select's. Here a checkbox turns the select off and on, as an app's settings might.

Reminder channel
  • SMS
  • Email
const [reminding, setReminding] = useState(true);
return (
  <>
    <Field orientation="horizontal" label="Send a reminder">
      <Checkbox
        name="reminder"
        checked={reminding}
        onChange={(event) => setReminding(event.target.checked)}
      />
    </Field>
    <Select
      label="Reminder channel"
      name="reminder-channel"
      defaultValue="sms"
      disabled={!reminding}
    >
      <Option value="sms">SMS</Option>
      <Option value="email">Email</Option>
    </Select>
  </>
);

In a form

The trigger is a button, so it submits nothing itself. Its data-name names a hidden input for each chosen option's data-value, which the script keeps in step. Reset restores the first choices. Here, Save shows what the form would send.

Channel
  • WhatsApp
  • Telegram
  • Viber
Notify me about
  • Delivered
  • Read
  • Failed
Save to see what the form sends.
const [sent, setSent] = useState("Save to see what the form sends.");
const save = (event: FormEvent<HTMLFormElement>) => {
  event.preventDefault();
  const data = new URLSearchParams(
    [...new FormData(event.currentTarget)].map(([key, value]) => [
      key,
      String(value),
    ]),
  ).toString();
  setSent(data ? `Sends ${data}` : "Sends nothing");
};

return (
  <form
    style={{ display: "flex", flexDirection: "column", gap: 16 }}
    onSubmit={save}
  >
    <Select label="Channel" name="channel" defaultValue="whatsapp">
      <Option value="whatsapp">WhatsApp</Option>
      <Option value="telegram">Telegram</Option>
      <Option value="viber">Viber</Option>
    </Select>
    <Select
      multiple
      label="Notify me about"
      name="statuses"
      placeholder="No statuses"
      defaultValue={["failed"]}
    >
      <Option value="delivered">Delivered</Option>
      <Option value="read">Read</Option>
      <Option value="failed">Failed</Option>
    </Select>
    <div style={{ display: "flex", gap: 8 }}>
      <Button type="submit">Save</Button>
      <Button variant="secondary" type="reset">
        Reset
      </Button>
    </div>
    <output className="hdescription">{sent}</output>
  </form>
);

Next to an input

A select and an input share the same box, so they line up in a row. The row here is a plain flex container of your own.

  • Delivered
  • Failed
<div style={{ display: "flex", gap: 8 }}>
  <Input
    type="search"
    aria-label="Search messages"
    placeholder="Search messages"
  />
  <Select aria-label="Status" placeholder="Any status">
    <Option value="delivered">Delivered</Option>
    <Option value="failed">Failed</Option>
  </Select>
</div>

API reference

React

<Select> renders the trigger, its popup, and its list, and does everything a script would. With label, it renders the Field too; without one, name it with aria-label, or place it in a Setting, which names it. A disabled Select has no popup. Its children are <Option>s and <OptionGroup>s, as in a Listbox. Other props are the trigger <button>'s attributes.

PropEffect
label, optional, description, errorAs for a Field; the last three need a label
multipleLets several options be chosen; the popup stays open
value or defaultValueThe chosen value, or an array of them with multiple
onValueChangeCalled with the new value or array
nameAdds the hidden inputs a form submits; a form reset restores defaultValue
placeholderShown, grayed, while nothing is chosen
searchLabelAdds a search row, with this as its placeholder and name
emptyMessageRequired with searchLabel: shown when the search matches nothing, such as "No agents found"

HTML

A <button class="hselect" type="button" role="combobox">, followed by a .hpopup[popover] holding a .hlistbox. Load field.css, select.css, popup.css, listbox.css, and expand-chevron.css.

HookEffect
data-placeholder on .hselect-valuePlaceholder text, such as "Select a channel"
aria-expanded="true"Open: focus border and ring, and the chevron folds up
aria-invalid="true"Red border, as for Input
disabledDisabled, as for Input; the chevron follows the disabled text color
data-name on the triggerIn a form, the name its hidden inputs send
data-value on an optionIn a form, the value that option sends

It needs a script for the keyboard, selection, the shown value, and the form value. The trigger is a button, so in a form it submits nothing: hidden inputs carry the value instead, as a native <select> would send it. See Select's full contract.

Accessibility

  • <button role="combobox"> gets native focus and disabled, and aria-labelledby names it from the Field's label.
  • Focus stays on the trigger while the list is open (the WAI-ARIA select-only combobox pattern); the trigger's aria-activedescendant names the highlighted option.
  • Keys: Down and Up move the highlight, Home and End go to the first and last option, typing jumps to the option whose label starts with the typed letters, Enter or Space chooses, Escape closes. Disabled options are skipped.