Listbox

The option list inside a Popup, with optional groups, search, and multiple selection. Select and Filter chip use it. Scripts own the behavior; CSS styles the state they set.

Installation

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

Usage

import { Button, ExpandChevron, Listbox, Option, Popup } from "@harmo-ui/react";

<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Period
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Period" defaultValue="30">
    <Option value="7">Last 7 days</Option>
    <Option value="30">Last 30 days</Option>
    <Option value="90">Last 90 days</Option>
  </Listbox>
</Popup>

Examples

This site is using your theme

Single

A role="listbox" of role="option" items inside a Popup. The selected option has aria-selected="true" and a check mark, and stays highlighted while nothing else is. Opening it moves focus to the list: the Arrow keys, Home, and End move the highlight, skipping disabled options, and Enter or Space chooses. Clicking works too.

  • Last 7 days
  • Last 30 days
  • Last 90 days
  • This year
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Period
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Period" defaultValue="30">
    <Option value="7">Last 7 days</Option>
    <Option value="30">Last 30 days</Option>
    <Option value="90">Last 90 days</Option>
    <Option value="year">This year</Option>
  </Listbox>
</Popup>

Single, grouped

Groups are role="group" lists labelled by their .hoption-group-label, with a divider above every group after the first. Options outside a group sit above them.

  • All channels
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Channel
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Channel" defaultValue="whatsapp">
    <Option value="all">All channels</Option>
    <OptionGroup label="Messaging">
      <Option value="whatsapp">WhatsApp</Option>
      <Option value="telegram">Telegram</Option>
      <Option value="viber">Viber</Option>
    </OptionGroup>
    <OptionGroup label="Email">
      <Option value="gmail">Gmail</Option>
      <Option value="outlook">Outlook</Option>
    </OptionGroup>
  </Listbox>
</Popup>

A .hlistbox-search row above the list. Opening it moves focus to the search field, which keeps it while its aria-activedescendant names the highlighted option. Typing hides the options whose name doesn't contain the text and highlights the first match.

  • Unassigned
  • Jane Smith
  • Omar Haddad
  • Li Wei
  • Ana Souza
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Assignee
      <ExpandChevron />
    </Button>
  }
>
  <Listbox
    aria-label="Assignee"
    searchLabel="Search agents"
    emptyMessage="No agents found"
    defaultValue="jane"
  >
    <Option value="none">Unassigned</Option>
    <Option value="jane">Jane Smith</Option>
    <Option value="omar">Omar Haddad</Option>
    <Option value="li">Li Wei</Option>
    <Option value="ana">Ana Souza</Option>
  </Listbox>
</Popup>

Searching a grouped list hides each option that doesn't match, and each group left with none, label and all.

  • All inboxes
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Inbox
      <ExpandChevron />
    </Button>
  }
>
  <Listbox
    aria-label="Inbox"
    searchLabel="Search inboxes"
    emptyMessage="No inboxes found"
    defaultValue="all"
  >
    <Option value="all">All inboxes</Option>
    <OptionGroup label="Messaging">
      <Option value="whatsapp">WhatsApp</Option>
      <Option value="telegram">Telegram</Option>
    </OptionGroup>
    <OptionGroup label="Email">
      <Option value="gmail">Gmail</Option>
      <Option value="outlook">Outlook</Option>
    </OptionGroup>
  </Listbox>
</Popup>

Multiple

aria-multiselectable="true" on the list, and a .hoption-checkbox at the start of each option instead of the check mark. Clicking toggles an option. The checkbox is a drawing, not an input, so nothing inside the list takes focus.

  • Delivered
  • Read
  • Failed
  • Pending
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Status
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Status" multiple defaultValue={["delivered", "read"]}>
    <Option value="delivered">Delivered</Option>
    <Option value="read">Read</Option>
    <Option value="failed">Failed</Option>
    <Option value="pending">Pending</Option>
  </Listbox>
</Popup>

Multiple, grouped

Groups work the same way in a multi-select.

<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Tags
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Tags" multiple defaultValue={["refund", "feature"]}>
    <OptionGroup label="Billing">
      <Option value="refund">Refund</Option>
      <Option value="invoice">Invoice</Option>
    </OptionGroup>
    <OptionGroup label="Support">
      <Option value="bug">Bug report</Option>
      <Option value="feature">Feature request</Option>
      <Option value="onboarding">Onboarding</Option>
    </OptionGroup>
  </Listbox>
</Popup>

A .hlistbox-footer below the list holds a full-width action, such as clearing the filter. Here it clears the selection.

  • English
  • Spanish
  • Arabic
  • German
const [languages, setLanguages] = useState(["en", "es"]);

return (
  <Popup
    trigger={
      <Button variant="secondary" aria-haspopup="listbox">
        Language
        <ExpandChevron />
      </Button>
    }
  >
    <Listbox
      aria-label="Language"
      multiple
      value={languages}
      onValueChange={setLanguages}
      footer={
        <Button variant="tertiary" onClick={() => setLanguages([])}>
          Clear filters
        </Button>
      }
    >
      <Option value="en">English</Option>
      <Option value="es">Spanish</Option>
      <Option value="ar">Arabic</Option>
      <Option value="de">German</Option>
    </Listbox>
  </Popup>
);

Disabled options

aria-disabled="true" grays an option and leaves it out of the highlight and selection. A .hoption-badge after the label says why, such as Soon.

  • WhatsApp
  • Instagram
  • Viber Soon
  • SMS
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Source
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Source" defaultValue="whatsapp">
    <Option value="whatsapp">WhatsApp</Option>
    <Option value="instagram">Instagram</Option>
    <Option value="viber" disabled badge="Soon">
      Viber
    </Option>
    <Option value="sms" disabled>
      SMS
    </Option>
  </Listbox>
</Popup>

Long options

Options stay one line. A long one widens the list up to 32rem; beyond that it ends with an ellipsis, and hovering it shows the full text.

  • +49 30 1234567 · Support
  • +41 44 7654321 · Customer support for Germany, Austria, Switzerland, Liechtenstein, and Luxembourg
  • +44 20 7946 0000 · Sales
<Popup
  trigger={
    <Button variant="secondary" aria-haspopup="listbox">
      Sender number
      <ExpandChevron />
    </Button>
  }
>
  <Listbox aria-label="Sender number" defaultValue="support">
    <Option value="support">+49 30 1234567 · Support</Option>
    <Option value="dach">
      +41 44 7654321 · Customer support for Germany, Austria, Switzerland,
      Liechtenstein, and Luxembourg
    </Option>
    <Option value="sales">+44 20 7946 0000 · Sales</Option>
  </Listbox>
</Popup>

API reference

React

<Listbox> renders the list, with <Option> and <OptionGroup> children, and does everything a script would. Put it in a Popup, and name it with aria-label or aria-labelledby. Other props are the <ul>'s attributes.

PropEffect
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
searchLabelAdds the search row, with this as its placeholder and name
emptyMessageRequired with searchLabel: shown when the search matches nothing, such as "No agents found"
footerPlaced in the footer, such as a tertiary <Button>

<Option> takes its value, its label as children, and optionally disabled and a badge, such as "Soon". <OptionGroup> takes a label. Other props are the <li>'s attributes: an id replaces the generated one, and hidden hides the option as a search does.

HTML

A <ul class="hlistbox" role="listbox"> inside a .hpopup, with aria-label or aria-labelledby. Each .hoption has role="option", an id, and aria-selected. Load listbox.css and popup.css; button.css for a footer or a button that opens it.

HookEffect
data-active on an optionHighlighted; follows the keyboard and pointer, instantly; never on a disabled option
aria-selected="true" on an optionShows .hoption-check (single) or the ticked .hoption-checkbox (multiple)
aria-disabled="true" on an optionGray text, not-allowed cursor, no check
aria-multiselectable="true" on the listMulti-select: .hoption-checkbox and no check mark
hidden on an option or groupHidden, such as by a search; skipped by the keyboard
data-value on an optionNo style: the value it stands for, which a script reports or a Select submits

To filter, set hidden on each option that doesn't match, and on a group left with none; the options stay in the page, selection included. When nothing matches, render .hlistbox-empty after the list, and only then. A list with every option hidden, or with nothing inside it (not even whitespace), loses its padding and collapses:

<ul class="hlistbox" id="channel-listbox" role="listbox" aria-label="Channel"></ul>
<p class="hlistbox-empty">No channels found</p>

It needs a script for the highlight, keyboard, selection, and search. See Listbox's full contract.

Accessibility

  • role="listbox" with role="option" items; groups are role="group" labelled by their .hoption-group-label.
  • Focus stays on one element while the list is open, and its aria-activedescendant names the highlighted option, so screen readers announce it as the highlight moves: a Select trigger keeps focus; a list with a search row moves it to the search field; otherwise the list itself takes focus (with tabindex="-1").
  • Keys: Down and Up move the highlight, Home and End go to the first and last option, and typing jumps to the next option whose label starts with the typed letters (except in a search field). Enter chooses; in a multi-select, Space toggles. Disabled options are skipped. With nothing to choose, Enter in the search field does nothing, so it can't submit the form around it.
  • The search field needs no focus ring: its caret shows focus.