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:
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()))
})
}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
searchable
searchPlaceholder="Find a tag"
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"
searchClass="mb-1 h-8 rounded-md border border-input px-2 text-sm placeholder:text-muted-foreground"
itemClass="rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground"
trigger={
<View class="h-9 w-48 justify-center rounded-md border border-input px-3 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<Text>{tags().length ? `${tags().length} tags` : "Add tags"}</Text>
</View>
}
renderItem={(item) => (
<View class="flex-row justify-between">
<Text>{item.title}</Text>
<Text>{item.selected ? "✓" : ""}</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), 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.
#Search
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
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. |
searchable | .searchable(..) | boolean | false | A search field above the items filters them by title. |
searchPlaceholder | .search_placeholder(..) | string | none | The search field’s placeholder. |
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 trigger. |
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. |
searchClass, searchStyle | .search_class(..), .search_style(..) | string, StyleProp<InputStyle> | none | The search field. |
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. |
trigger | .trigger(..) | JSX.Element | none | What opens the list when pressed; the list is placed against it. |
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: SelectItemState) => JSX.Element | none | An item’s row, drawn by the app; 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 view around the trigger. |
#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 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
| Part | Where | Styled with |
|---|---|---|
| The trigger | the combobox’s view | the app’s own element (trigger) |
| The clear button | the combobox’s view, after the trigger (cleanable, while an item is selected) | clearButtonClass, clearButtonStyle; its content clearButton |
| The popup or sheet | over everything, at the trigger (desktops) or at the bottom (touch screens) | popupClass, popupStyle |
| The backdrop | behind the sheet (touch screens) | backdropClass, backdropStyle |
| The search field | the popup, first (searchable) | searchClass, searchStyle, searchPlaceholder |
| The list | the popup, after the search field | listClass, listStyle |
| A section’s header | the list, before its items | groupClass, groupStyle, renderGroup |
| An item’s row | the list | itemClass, itemStyle, renderItem |
| 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), 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.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.
See Accessibility for labels, roles and announcements in general.