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. Its trigger is an input: typing opens the list and filters it, as shadcn/ui’s
combobox does. It is Select’s sibling and shares its list, presented as each device expects:
import { type JSX, mount, signal, Text, View } from "nsh";
import { Combobox, type OpenChangeReason, type SelectEntry, type SelectGroup } from "nsh/base";
import { CheckMark, desktop, Icon, Log, Panel, size, Split, 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
// field: the input typing filters the list with, 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 field: a bordered box around the input, the clear button and the chevron. */
const fieldStyle = (open: boolean) => ({
flexDirection: "row" as const,
alignItems: "center" as const,
minHeight: size.control,
paddingLeft: 10,
paddingRight: 4,
borderRadius: 8,
borderWidth: 1,
borderColor: open ? ui().faint : ui().border,
backgroundColor: ui().card.backgroundColor,
...(desktop ? { width: 260 } : {}),
});
const inputClass = () => `${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`;
const inputStyle = { flex: 1, minWidth: 40, height: size.control - 2 };
/** The trigger button: a chevron that turns while the list is open. */
function Chevron(props: { open: boolean }) {
return (
<View
class="transition-transform duration-150 motion-reduce:transition-none"
style={{ width: 24, height: 24, alignItems: "center", justifyContent: "center", transform: [{ rotate: props.open ? "270deg" : "90deg" }] }}
>
<Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
</View>
);
}
/**
* A row's look, on the row itself (`itemClass`): the accent tint while
* highlighted (the keys or the pointer, as the menus' rows), dimmed while
* disabled. The states are the row's classes (`group`), so the list draws a
* row's content once per item and the highlight moves without drawing.
*/
const rowClass =
"group rounded-lg border border-transparent transition-colors duration-100 motion-reduce:transition-none " +
"focus:border-brand/30 focus:bg-brand-tint active:opacity-70 disabled:opacity-40";
/** A row's content: the title, the check in its own lane (`group-selected:`). */
function Row(props: { item: SelectEntry }) {
return (
// The row's 1-point border on each side.
<View style={{ height: size.rowSm - 2, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 7 }}>
<Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>{props.item.title}</Text>
<View class="opacity-0 group-selected:opacity-100" style={{ width: 16 }}>
<CheckMark size={16} tint={ui().accent} />
</View>
</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"
style={fieldStyle(labelsOpen())}
placeholder={labelTitle() ?? "Add labels"}
inputClass={inputClass()}
inputStyle={inputStyle}
triggerButton={<Chevron open={labelsOpen()} />}
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={rowClass}
cleanable
clearButtonAccessibilityLabel="Clear the labels"
clearButtonClass="items-center justify-center rounded-full enabled:hover:opacity-70"
clearButtonStyle={{ width: 20, height: 20 }}
clearButton={<Icon name="close" size={14} tint={ui().muted} />}
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>
}
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={rowClass}
style={fieldStyle(assigneeOpen())}
placeholder="Assign someone"
inputClass={inputClass()}
inputStyle={inputStyle}
triggerButton={<Chevron open={assigneeOpen()} />}
renderItem={(item) => <Row item={item} />}
/>
</Field>
</View>
</Split>
</View>
);
}
mount(App);- On the desktops and in desktop browsers, a popup under the field, as wide as the field (the app’s
popupStyle/popupClasswidth sets another) 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 field, the popup, the list,
the rows, the sections’ headers, the input and the footer, or draws the rows itself.
use nsh_base::{SelectEntry, SelectItem, 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)
.placeholder(title)
.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()
})
.h_9()
.w_48()
.px_3()
.flex_row()
.items_center()
.rounded_md()
.border_1()
.border_color(move || theme().input)
.input_style(|s| s.flex_1().text_sm())
.trigger_button(text("⌄"))
.item_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
.render_item(|item: &SelectEntry| view().px_2().py_1p5().child(text(item.title.clone()).text_sm()))
}import { mount, signal, Text, View } from "nsh";
import { Combobox } from "nsh/base";
const TAGS = [
{ value: "bug", title: "Bug" },
{ value: "design", title: "Design" },
{ value: "docs", title: "Docs" },
{ value: "perf", title: "Performance" },
{ value: "security", title: "Security" },
];
function App() {
const tags = signal<string[]>([]);
return (
<Combobox
items={TAGS}
multiple
placeholder={tags().length ? `${tags().length} tags` : "Add tags"}
value={tags()}
onChange={({ value }) => tags.set(value)}
accessibilityLabel="Tags"
offset={4}
popupClass="rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md"
backdropClass="bg-black/40"
class="h-9 w-48 flex-row items-center rounded-md border border-input px-3"
inputClass="flex-1 text-sm placeholder:text-muted-foreground"
itemClass="group rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground"
triggerButton={<Text class="px-1 text-muted-foreground">⌄</Text>}
renderItem={(item) => (
<View class="flex-row justify-between">
<Text>{item.title}</Text>
<Text class="opacity-0 group-selected:opacity-100">✓</Text>
</View>
)}
/>
);
}
mount(App);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 button, Tab or Shift+Tab from the open list, reason: "focus"), 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 input, before the trigger button; a press on it never opens the list. The app fills it, as
a select’s clear button.
#Input
The trigger is a text input in the combobox’s row (its class and style: the field’s border and fill), with the
app’s prefix before it (the chosen items as chips, an icon) and, after it, the clear button, the app’s
triggerButton (a chevron) and suffix.
- Typing opens the list and filters the items by title, ignoring case;
emptyshows while nothing matches. Down and Up open it too, and a press on the input or the trigger button opens and closes it; on touch screens the tap that focuses the input opens it. - With one value, the input shows the chosen item’s title (
placeholderwhile there is none). It is selected when the input gets the focus, so what is typed replaces it. - With
multiple, the input shows the query; the app shows the chosen items, inprefixor beside the combobox.
The query is the combobox’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
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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | The items, in order. |
multiple | .multiple(..) | boolean | false | A choice toggles the item in the value and the list stays open. |
value | .value(..) | string[] | none | The selected items’ values, owned by the app. |
defaultValue | .default_value(..) | string[] | [] | The value at first, without value. |
open, defaultOpen | .open(..), .default_open(..) | boolean | false | The open state, the app’s or the combobox’s own. |
placeholder | .placeholder(..) | string | none | The input’s placeholder, while it is empty. |
query | .query(..) | string | none | The query, owned by the app. |
defaultQuery | .default_query(..) | string | none | The query each opening starts with, without query. |
cleanable | .cleanable(..) | boolean | false | A clear button while an item is selected; a press empties the value. |
disabled | .disabled(..) | boolean | false | It stays closed and takes no presses or keyboard focus. |
accessibilityLabel | .accessibility_label(..) | string | none | What the choice is about, for screen readers. |
placement, align, offset, margin | .placement(..), … | as Select’s | "bottom", "start", 0, 0 | Desktops: where the popup goes against the field. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | none | The popup (desktops) or the sheet (touch screens). |
backdropClass, backdropStyle | .backdrop_class(..), .backdrop_style(..) | string, StyleProp<ViewStyle> | none | The backdrop behind the sheet. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | none | The list, the scroll view of the rows. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | none | Each item’s row: focus: while highlighted, selected: while selected, disabled: while it cannot be. |
groupClass, groupStyle | .group_class(..), .group_style(..) | string, StyleProp<ViewStyle> | none | Each section’s header. |
inputClass, inputStyle | .input_class(..), .input_style(..) | string, StyleProp<InputStyle> | none | The input: focus: while it edits, disabled: with the combobox. |
footerClass, footerStyle | .footer_class(..), .footer_style(..) | string, StyleProp<ViewStyle> | none | The footer, below the rows. |
clearButtonClass, clearButtonStyle | .clear_button_class(..), .clear_button_style(..) | string, StyleProp<ViewStyle> | none | The clear button (cleanable). |
clearButton | .clear_button(..) | JSX.Element | none | The content of the clear button. |
clearButtonAccessibilityLabel | .clear_button_accessibility_label(..) | string | none | What screen readers call the clear button. |
triggerButton | .trigger_button(..) | JSX.Element | none | A button after the input (a chevron): a press opens and closes the list. |
prefix | .prefix(..) | JSX.Element | none | Content before the input: chips, an icon. |
suffix | .suffix(..) | JSX.Element | none | Content after the trigger button. |
empty | .empty(..) | JSX.Element | none | What the list shows while no item does. |
footer | .footer(..) | JSX.Element | none | What shows below the rows. |
renderItem | .render_item(..) | (item: SelectEntry) => JSX.Element | none | An item’s row, drawn by the app from what the item is, never its state: the content follows the row’s group-focus:, group-selected: and group-disabled: (group in itemClass), so a move of the highlight or a toggle draws nothing again; the item’s title otherwise. |
renderGroup | .render_group(..) | (group: SelectGroup) => JSX.Element | none | A section’s header, drawn by the app; its title otherwise. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The field: the row of the prefix, the input, the buttons and the suffix; the list is as wide as it. |
#Events
| Event | Rust | Description |
|---|---|---|
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 input: { 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
| Part | Where | Styled with |
|---|---|---|
| The field | the combobox’s view, a row | class, style |
| The prefix | the field, first | the app’s own element (prefix) |
| The input | the field, after the prefix | inputClass, inputStyle, placeholder |
| The clear button | the field, after the input (cleanable, while an item is selected) | clearButtonClass, clearButtonStyle; its content clearButton |
| The trigger button | the field, after the clear button | the app’s own element (triggerButton) |
| The suffix | the field, last | the app’s own element (suffix) |
| The popup or sheet | over everything, under the field (desktops) or at the bottom (touch screens) | popupClass, popupStyle |
| The backdrop | behind the sheet (touch screens) | backdropClass, backdropStyle |
| The list | the popup | listClass, listStyle |
| A section’s header | the list, before its items | groupClass, groupStyle, renderGroup |
| An item’s row | the list | itemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: inside, under group in itemClass) |
| The empty state | the popup, while no item shows | its own element (empty) |
| The footer | the popup, last | footerClass, footerStyle; its content footer |
#Keyboard
The keys are a select’s (see Keyboard) while the input keeps the focus, with these
differences: letters and digits are typed into the input, which filters the list, rather than typeahead; Down and Up
open the closed list; Return toggles the highlighted item with multiple and keeps the list open; Cmd+Return on a
Mac (Ctrl+Return elsewhere) closes it and confirms. Tab and Shift+Tab close the open list and move the focus to the
next or previous control. When the list closes otherwise on the desktops, the input keeps the focus.
#Accessibility
- The input 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.disableddims 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. The trigger button is hidden from screen readers, as the input says whether the list is open, and is no stop of Tab either.
See Accessibility for labels, roles and announcements in general.