Elements

Combobox

A combobox chooses values from a list of items, one or, with multiple, several: the tags of a post, the members of a channel, the languages of a document. It is Select’s sibling and shares its list, presented as each device expects:

el-combobox.tsx
el-combobox.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Combobox, type OpenChangeReason, type SelectGroup, type SelectItemState } from "nsh/base";
import { button, desktop, Log, Panel, size, Split, tint, ui } from "./palette";

// Two comboboxes in a card: labels for an issue, several picked in one go
// from a searchable list with sections, a clear button and a footer; and
// an assignee, one person replacing the last. The trigger is the shared
// button look with what is selected and a chevron that turns while the
// list is open; the list is a popup at the trigger with a pointer, a sheet
// on a touch screen. Selected rows show a check in its own lane.

const LABELS = [
  { value: "bug", title: "Bug", group: "Type" },
  { value: "feature", title: "Feature", group: "Type" },
  { value: "docs", title: "Docs", group: "Type" },
  { value: "ios", title: "iOS", group: "Platform" },
  { value: "android", title: "Android", group: "Platform" },
  { value: "macos", title: "macOS", group: "Platform" },
  { value: "windows", title: "Windows", group: "Platform" },
  { value: "linux", title: "Linux", group: "Platform" },
  { value: "web", title: "Web", group: "Platform" },
  { value: "wontfix", title: "Won't fix", group: "Status", disabled: true },
  { value: "blocked", title: "Blocked", group: "Status" },
];

const PEOPLE = [
  { value: "ada", title: "Ada Lovelace" },
  { value: "grace", title: "Grace Hopper" },
  { value: "linus", title: "Linus Torvalds" },
  { value: "margaret", title: "Margaret Hamilton" },
];

const titlesOf = (items: { value: string; title: string }[], values: string[]) =>
  values.map((v) => items.find((i) => i.value === v)?.title ?? v);

/** The text of a control: 13 with a pointer, the touch label size otherwise. */
const controlText = desktop ? 13 : size.label;

/** A popup at the trigger with a pointer, a sheet at the bottom on a touch screen. */
const surface = () =>
  desktop
    ? { ...ui().card, borderRadius: 12, padding: 4 }
    : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 10, paddingBottom: 30, maxHeight: "70%" as const };

const backdrop = { backgroundColor: "rgba(0,0,0,0.45)" };

/** The trigger: the shared button's look, what is selected at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; clearable?: boolean }) {
  const look = button("secondary", {
    style: () => ({
      flexDirection: "row",
      justifyContent: "space-between",
      gap: 8,
      ...(desktop ? { width: 260 } : {}),
      ...(props.open ? { borderColor: ui().faint } : {}),
    }),
  });
  return (
    <View {...look}>
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: props.title ? ui().text : ui().faint }}>
        {props.title ?? props.placeholder}
      </Text>
      {/* With a clear button over the end (`cleanable`), an empty lane as wide as the button in place of the chevron. */}
      <View
        class="transition-transform duration-150 motion-reduce:transition-none"
        style={{
          width: props.clearable ? 20 : 10,
          height: 20,
          alignItems: "center",
          justifyContent: "center",
          opacity: props.clearable ? 0 : 1,
          transform: [{ rotate: props.open ? "270deg" : "90deg" }],
        }}
      >
        <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
      </View>
    </View>
  );
}

/** A row: the title, the check in its own lane. */
function Row(props: { item: SelectItemState }) {
  return (
    <View
      style={{
        height: size.rowSm,
        flexDirection: "row",
        alignItems: "center",
        gap: 8,
        paddingHorizontal: 8,
        borderRadius: 8,
        borderWidth: 1,
        borderColor: "transparent",
        opacity: props.item.disabled ? 0.4 : 1,
        ...(props.item.highlighted ? { backgroundColor: tint(ui().accent, 0.08), borderColor: tint(ui().accent, 0.24) } : {}),
      }}
    >
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>{props.item.title}</Text>
      <Text style={{ width: 12, fontSize: 12, textAlign: "right", color: ui().accent }}>{props.item.selected ? "✓" : ""}</Text>
    </View>
  );
}

function Header(props: { group: SelectGroup }) {
  return (
    <Text style={{ fontSize: 12, fontWeight: "500", color: ui().muted, paddingHorizontal: 9, paddingTop: 8, paddingBottom: 4 }}>
      {props.group.title}
    </Text>
  );
}

/** A label over its control, a quieter line under the label. */
function Field(props: { label: string; detail: string; children: JSX.Element }) {
  return (
    <View style={{ gap: 8 }}>
      <View style={{ gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
      {props.children}
    </View>
  );
}

function App() {
  const labels = signal<string[]>(["bug", "ios"]);
  const assignee = signal<string[]>([]);
  const labelsOpen = signal(false);
  const assigneeOpen = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 8));
  const opened = (open: (next: boolean) => void) => ({ open: next }: { open: boolean; reason: OpenChangeReason }) => open(next);
  const labelTitle = () => {
    const titles = titlesOf(LABELS, labels());
    return titles.length === 0 ? undefined : titles.length <= 2 ? titles.join(", ") : `${titles.length} labels`;
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>combobox</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Pick several labels without the list closing; Cmd+Return confirms" : "Tap labels to toggle them; tap outside to finish"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{`labels [${labels().join(", ")}]`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`assignee [${assignee().join(", ")}]`}</Text>
            </Panel>
            <Log title="onChange · onConfirm" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
          <Field label="Labels" detail="Several at once, with search and sections">
            <Combobox
              items={LABELS}
              multiple
              value={labels()}
              onChange={({ value }) => {
                labels.set(value);
                log(`Labels → ${titlesOf(LABELS, value).join(", ") || "none"}`);
              }}
              onConfirm={({ value }) => log(`Confirmed ${value.length} labels`)}
              onOpenChange={opened((next) => labelsOpen.set(next))}
              accessibilityLabel="Labels"
              searchable
              searchPlaceholder="Filter labels"
              searchClass={`${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`}
              searchStyle={{ ...ui().inset, borderRadius: 8, height: size.control, paddingHorizontal: 8, marginBottom: 4 }}
              empty={<Text style={{ paddingVertical: 20, textAlign: "center", fontSize: 13, color: ui().muted }}>No label matches</Text>}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              itemClass="active:opacity-70"
              style={desktop ? { width: 260 } : undefined}
              cleanable
              clearButtonAccessibilityLabel="Clear the labels"
              clearButtonClass="absolute items-center justify-center rounded-full enabled:hover:opacity-70"
              clearButtonStyle={{ top: (size.control - 20) / 2, right: 10, width: 20, height: 20 }}
              clearButton={<Text style={{ fontSize: 12, color: ui().muted }}>✕</Text>}
              footerStyle={{ borderTopWidth: 1, borderColor: ui().border, marginTop: 4, paddingTop: 4 }}
              footer={
                <View style={{ paddingHorizontal: 8, paddingVertical: 6 }}>
                  <Text style={{ fontSize: 12, color: ui().muted }}>{`${labels().length} selected`}</Text>
                </View>
              }
              trigger={<Trigger title={labelTitle()} placeholder="Add labels" open={labelsOpen()} clearable={labels().length > 0} />}
              renderItem={(item) => <Row item={item} />}
              renderGroup={(group) => <Header group={group} />}
            />
          </Field>
          <Field label="Assignee" detail="One person: a choice replaces the last">
            <Combobox
              items={PEOPLE}
              value={assignee()}
              onChange={({ value }) => {
                assignee.set(value);
                log(`Assignee → ${titlesOf(PEOPLE, value)[0] ?? "none"}`);
              }}
              onOpenChange={opened((next) => assigneeOpen.set(next))}
              accessibilityLabel="Assignee"
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              itemClass="active:opacity-70"
              trigger={<Trigger title={titlesOf(PEOPLE, assignee())[0]} placeholder="Assign someone" open={assigneeOpen()} />}
              renderItem={(item) => <Row item={item} />}
            />
          </Field>
        </View>
      </Split>
    </View>
  );
}

mount(App);
  • On the desktops and in desktop browsers, a popup at the trigger, at least as wide as the trigger and as tall as its rows up to the room beside it.
  • On phones and tablets, a sheet at the bottom of the screen over a backdrop.

The combobox owns the behavior: the value (the app’s, or its own), the open state, the keyboard, search, sections, the highlighted row and what screen readers hear. With multiple, a choice toggles the item in the value and the list stays open, so several are picked in one go. The look is the app’s: it styles the trigger, the popup, the list, the rows, the sections’ headers, the search field and the footer, or draws the rows itself.

use nsh_base::{SelectItem, SelectItemState, combobox, theme};
use nsh_core::prelude::*;

const TAGS: [(&str, &str); 5] = [
    ("bug", "Bug"),
    ("design", "Design"),
    ("docs", "Docs"),
    ("perf", "Performance"),
    ("security", "Security"),
];

fn tag_picker() -> impl IntoElement {
    let tags = signal(Vec::<SharedString>::new());
    let title = move || match tags.get().len() {
        0 => "Add tags".to_string(),
        n => format!("{n} tags"),
    };
    combobox()
        .items(
            TAGS.iter()
                .map(|&(value, title)| SelectItem::new(value, title))
                .collect::<Vec<_>>(),
        )
        .multiple(true)
        .searchable(true)
        .search_placeholder("Find a tag")
        .value(move || Some(tags.get()))
        .on_change(move |next: &Vec<SharedString>| tags.set(next.clone()))
        .accessibility_label("Tags")
        .offset(px(4.0))
        .popup_style(|s| {
            s.p_1()
                .rounded_lg()
                .border_1()
                .border_color(move || theme().border)
                .bg(move || theme().popover)
                .shadow_md()
        })
        .trigger(
            view()
                .h_9()
                .w_48()
                .px_3()
                .justify_center()
                .rounded_md()
                .border_1()
                .border_color(move || theme().input)
                .child(text(title)),
        )
        .render_item(|item: &SelectItemState| {
            view()
                .flex_row()
                .justify_between()
                .px_2()
                .py_1p5()
                .when(item.highlighted, |v| v.bg(move || theme().accent))
                .child(text(item.title.clone()).text_sm())
                .when(item.selected, |v| v.child(text("✓").text_sm()))
        })
}

Combobox comes from nsh/base, the nsh-base crate in Rust. In Rust, combobox() makes one with a setter per prop (.items(..), .value(..), .multiple(..)) and a listener per event (.on_change(..)). Its items are Select’s: SelectItem::new(value, title), with .disabled(..) and .group(..), in sections as a select’s are (see Items).

#Value

The value is an array of the selected items’ values. With value, the app owns it: the combobox shows those items as selected and reports each change with onChange({ value }); the app sets value to follow it, or keeps its own. Left out, the combobox keeps its own value, from defaultValue at first.

  • With multiple, a press, Return or Space toggles the highlighted item: in the value if it was out, out if it was in. The list stays open.
  • Without it, a choice replaces the value with that item’s and closes the list; choosing the selected item again only closes it.

onConfirm({ value }) tells the value as the list closes: once per opening, however it closes (a choice, Escape, a press outside, the trigger), with the value as the user left it. Cmd+Return on a Mac (Ctrl+Return elsewhere) closes the list and confirms.

cleanable adds a clear button while an item is selected: a press empties the value (onChange({ value: [] })). The button comes after the trigger, not inside it, so a press on it never opens the list; the app places it and fills it, as a select’s clear button.

searchable puts a search field above the items, as a select’s: typing filters them by title, ignoring case, and empty shows while nothing matches. The query is the field’s own, starting each opening from defaultQuery (else empty), or the app’s with query: onQueryChange({ query }) reports what is typed, and the app sets query to follow it, to filter its own way or to suggest a starting point.

footer is content below the rows, in a part of its own (footerClass, footerStyle): an action (“Manage tags”), a count, a hint. It stays in place while the rows scroll.

#Props

PropRustTypeDefaultDescription
items.items(..)SelectItem[][]The items, in order.
multiple.multiple(..)booleanfalseA choice toggles the item in the value and the list stays open.
value.value(..)string[]noneThe selected items’ values, owned by the app.
defaultValue.default_value(..)string[][]The value at first, without value.
open, defaultOpen.open(..), .default_open(..)booleanfalseThe open state, the app’s or the combobox’s own.
searchable.searchable(..)booleanfalseA search field above the items filters them by title.
searchPlaceholder.search_placeholder(..)stringnoneThe search field’s placeholder.
query.query(..)stringnoneThe query, owned by the app.
defaultQuery.default_query(..)stringnoneThe query each opening starts with, without query.
cleanable.cleanable(..)booleanfalseA clear button while an item is selected; a press empties the value.
disabled.disabled(..)booleanfalseIt stays closed and takes no presses or keyboard focus.
accessibilityLabel.accessibility_label(..)stringnoneWhat the choice is about, for screen readers.
placement, align, offset, margin.placement(..), …as Select’s"bottom", "start", 0, 0Desktops: where the popup goes against the trigger.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe popup (desktops) or the sheet (touch screens).
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>noneThe backdrop behind the sheet.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>noneThe list, the scroll view of the rows.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>noneEach item’s row: focus: while highlighted, selected: while selected, disabled: while it cannot be.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<ViewStyle>noneEach section’s header.
searchClass, searchStyle.search_class(..), .search_style(..)string, StyleProp<InputStyle>noneThe search field.
footerClass, footerStyle.footer_class(..), .footer_style(..)string, StyleProp<ViewStyle>noneThe footer, below the rows.
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>noneThe clear button (cleanable).
clearButton.clear_button(..)JSX.ElementnoneThe content of the clear button.
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)stringnoneWhat screen readers call the clear button.
trigger.trigger(..)JSX.ElementnoneWhat opens the list when pressed; the list is placed against it.
empty.empty(..)JSX.ElementnoneWhat the list shows while no item does.
footer.footer(..)JSX.ElementnoneWhat shows below the rows.
renderItem.render_item(..)(item: SelectItemState) => JSX.ElementnoneAn item’s row, drawn by the app; the item’s title otherwise.
renderGroup.render_group(..)(group: SelectGroup) => JSX.ElementnoneA section’s header, drawn by the app; its title otherwise.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe view around the trigger.

#Events

EventRustDescription
onChange.on_change(|value| ..)The value changed (a choice, a toggle, the clear button): { value }, the selected items’ values. Set value to follow it.
onConfirm.on_confirm(|value| ..)The list closed: { value } as it is then, once per opening.
onQueryChange.on_query_change(|query| ..)The user typed in the search field: { query }. Set query to follow it.
onOpenChange.on_open_change(|e| ..)The list opened or closed, with why ({ open, reason }). Set open to follow it.
onOpen, onClose.on_open(..), .on_close(..)The list is shown, or gone.
onCancel.on_cancel(..)The user closed the list without a choice that closes it (Escape or back, a press outside or on the backdrop).

They come in this order: onChange for a choice, or onCancel for a close without one, then onOpenChange, then onConfirm as the list closes, then onOpen or onClose once the list is shown or gone.

#Parts

PartWhereStyled with
The triggerthe combobox’s viewthe app’s own element (trigger)
The clear buttonthe combobox’s view, after the trigger (cleanable, while an item is selected)clearButtonClass, clearButtonStyle; its content clearButton
The popup or sheetover everything, at the trigger (desktops) or at the bottom (touch screens)popupClass, popupStyle
The backdropbehind the sheet (touch screens)backdropClass, backdropStyle
The search fieldthe popup, first (searchable)searchClass, searchStyle, searchPlaceholder
The listthe popup, after the search fieldlistClass, listStyle
A section’s headerthe list, before its itemsgroupClass, groupStyle, renderGroup
An item’s rowthe listitemClass, itemStyle, renderItem
The empty statethe popup, while no item showsits own element (empty)
The footerthe popup, lastfooterClass, footerStyle; its content footer

#Keyboard

The keys are a select’s (see Keyboard), with these differences: Return and Space toggle the highlighted item with multiple and keep the list open; Cmd+Return on a Mac (Ctrl+Return elsewhere) closes it and confirms; and the closed trigger takes no typeahead (its keys only open the list).

#Accessibility

  • The trigger is a combobox for screen readers: its label is accessibilityLabel, its value the selected items’ titles, joined with commas, and it says whether the list is expanded. disabled dims it.
  • The list is a listbox and each row an option, named by its title; the selected options are selected and a disabled one dimmed. Sections’ headers are headers.
  • The clear button is a button named by clearButtonAccessibilityLabel; it is no stop of Tab.

See Accessibility for labels, roles and announcements in general.