Elements
Select
A select chooses one value from a list of items: a setting, a filter, a country, a time zone. The app’s trigger opens the list, presented as each device expects:
import { type JSX, mount, signal, Text, View } from "nsh";
import { Button, type OpenChangeReason, Select, type SelectGroup, type SelectItemState, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, size, Split, tint, ui } from "./palette";
// Two selects in a settings card: a plain one (typeahead jumps by letter)
// and a searchable one with sections, a disabled item and an empty state.
// The trigger is the shared button look (hover, pressed, focus ring,
// disabled) with the chosen title 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. The highlighted row (the keys or the pointer) is lit with
// the accent tint, the chosen one is bolder with a check in its own lane.
const LANGUAGES = [
{ value: "en", title: "English" },
{ value: "de", title: "Deutsch" },
{ value: "es", title: "Español" },
{ value: "fr", title: "Français" },
{ value: "it", title: "Italiano" },
{ value: "nl", title: "Nederlands" },
{ value: "pl", title: "Polski" },
{ value: "pt", title: "Português" },
{ value: "sv", title: "Svenska" },
{ value: "tr", title: "Türkçe" },
{ value: "ja", title: "日本語" },
{ value: "ko", title: "한국어" },
{ value: "zh", title: "中文" },
];
const REGIONS = [
{ value: "us-east-1", title: "US East (Virginia)", group: "Americas" },
{ value: "us-west-2", title: "US West (Oregon)", group: "Americas" },
{ value: "ca-central-1", title: "Canada (Montréal)", group: "Americas" },
{ value: "sa-east-1", title: "South America (São Paulo)", group: "Americas" },
{ value: "eu-west-1", title: "Europe (Ireland)", group: "Europe" },
{ value: "eu-central-1", title: "Europe (Frankfurt)", group: "Europe" },
{ value: "eu-north-1", title: "Europe (Stockholm)", group: "Europe" },
{ value: "ap-northeast-1", title: "Asia Pacific (Tokyo)", group: "Asia Pacific" },
{ value: "ap-northeast-2", title: "Asia Pacific (Seoul)", group: "Asia Pacific", disabled: true },
{ value: "ap-southeast-1", title: "Asia Pacific (Singapore)", group: "Asia Pacific" },
{ value: "ap-east-1", title: "Asia Pacific (Hong Kong)", group: "Asia Pacific" },
{ value: "ap-southeast-2", title: "Asia Pacific (Sydney)", group: "Asia Pacific" },
];
const titleOf = (items: { value: string; title: string }[], value: string | null) => items.find((i) => i.value === value)?.title;
/** 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, the chosen title at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; disabled?: boolean; clearable?: boolean }) {
const look = button("secondary", {
style: () => ({
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
...(desktop ? { width: 260 } : {}),
// Open: the border stays lit while the list is up; disabled, dimmed.
...(props.open ? { borderColor: ui().faint } : {}),
...(props.disabled ? { opacity: 0.4 } : {}),
}),
});
return (
// No hover while disabled.
<View {...look} hoverStyle={props.disabled ? undefined : look.hoverStyle}>
<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, a quieter detail at the end, the check in its own lane. */
function Row(props: { item: SelectItemState; detail?: string }) {
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,
// Highlighted (the keys or the pointer): the accent tint, as the menus' rows.
...(props.item.highlighted ? { backgroundColor: tint(ui().accent, 0.08), borderColor: tint(ui().accent, 0.24) } : {}),
}}
>
<Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, fontWeight: props.item.selected ? "600" : "400", color: ui().text }}>
{props.item.title}
</Text>
<If when={props.detail}>
<Text style={{ fontSize: 12, color: ui().faint }}>{props.detail}</Text>
</If>
<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 language = signal<string | null>("en");
const region = signal<string | null>(null);
const languageOpen = signal(false);
const regionOpen = signal(false);
const locked = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 8));
const opened = (name: string, open: (next: boolean) => void) => (next: boolean, reason: OpenChangeReason) => {
open(next);
log(`${name} ${next ? "opened" : "closed"} · ${reason}`);
};
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>select</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a select, or Tab to it and press Down; type a letter to jump" : "Tap a select to choose from a sheet"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`language ${language() ?? "null"}`}</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`region ${region() ?? "null"}`}</Text>
<Button
disabled={region() === null}
onPress={() => region.set(null)}
{...button("secondary", { size: "sm", style: { marginTop: 6 } })}
>
<Text style={buttonText("secondary", { size: "sm" })}>Clear region</Text>
</Button>
<Switch
checked={locked()}
onChange={(next) => locked.set(next)}
class={`-mx-2 mt-1 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
trackStyle={locked() ? ui().accentFill : ui().inset}
thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
thumbStyle={locked() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Region disabled</Text>
</Switch>
</Panel>
<Log title="onChange · onOpenChange" lines={events()} minHeight={120} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
<Field label="Language" detail={desktop ? "Typing a letter on the trigger chooses by title" : "Thirteen languages in a sheet"}>
<Select
items={LANGUAGES}
value={language()}
onChange={(next) => {
language.set(next);
log(`Language → ${titleOf(LANGUAGES, next)}`);
}}
onOpenChange={opened("Language", (next) => languageOpen.set(next))}
accessibilityLabel="Language"
offset={4}
margin={8}
popupStyle={surface()}
backdropStyle={backdrop}
itemClass="active:opacity-70"
trigger={<Trigger title={titleOf(LANGUAGES, language())} placeholder="Choose a language" open={languageOpen()} />}
renderItem={(item) => <Row item={item} />}
/>
</Field>
<Field label="Region" detail="Search by name; one region is at capacity">
<Select
items={REGIONS}
value={region()}
onChange={(next) => {
region.set(next);
log(next === null ? "Region cleared" : `Region → ${titleOf(REGIONS, next)}`);
}}
onOpenChange={opened("Region", (next) => regionOpen.set(next))}
disabled={locked()}
accessibilityLabel="Region"
searchable
searchPlaceholder="Search regions"
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 region matches</Text>}
offset={4}
margin={8}
// Wider than the trigger on the desktops: the titles and their codes side by side.
popupStyle={desktop ? { ...surface(), width: 340 } : surface()}
backdropStyle={backdrop}
itemClass="active:opacity-70"
// As wide as the trigger, so the clear button lands over its end.
style={desktop ? { width: 260 } : undefined}
cleanable
clearButtonAccessibilityLabel="Clear the region"
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>}
trigger={<Trigger title={titleOf(REGIONS, region())} placeholder="Choose a region" open={regionOpen()} disabled={locked()} clearable={region() !== null} />}
renderItem={(item) => <Row item={item} detail={item.disabled ? "At capacity" : item.value} />}
renderGroup={(group) => <Header group={group} />}
/>
</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; the keyboard moves through the items and chooses one.
- On phones and tablets, a sheet at the bottom of the screen over a backdrop.
The select owns the behavior: the value (the app’s, or its own), the open state, the keyboard, typeahead, search, sections, the highlighted row and what screen readers hear. The look is the app’s: it styles the trigger, the popup, the list, the rows, the sections’ headers and the search field, or draws the rows itself.
use nsh_base::{SelectItem, SelectItemState, select, theme};
use nsh_core::ui::prelude::*;
const FRUITS: [(&str, &str); 5] = [
("apple", "Apple"),
("banana", "Banana"),
("blueberry", "Blueberry"),
("grapes", "Grapes"),
("pineapple", "Pineapple"),
];
fn fruit_select() -> impl IntoElement {
let fruit = signal(None::<SharedString>);
let title = move || {
let chosen = fruit.get();
FRUITS
.iter()
.find(|(value, _)| Some(*value) == chosen.as_deref())
.map_or("Pick a fruit", |(_, title)| *title)
.to_string()
};
select()
.items(
FRUITS
.iter()
.map(|&(value, title)| SelectItem::new(value, title).disabled(value == "grapes"))
.collect::<Vec<_>>(),
)
.value(move || fruit.get())
.on_change(move |next: &Option<SharedString>| fruit.set(next.clone()))
.accessibility_label("Fruit")
.offset(px(4.0))
.popup_style(|s| {
s.p_1()
.rounded_lg()
.border_1()
.border_color(move || theme().border)
.bg(move || theme().popover)
.text_color(move || theme().popover_foreground)
.shadow_md()
})
.item_style(|s| s.rounded_md().overflow_hidden())
.trigger(
view()
.h_9()
.w_48()
.flex_row()
.items_center()
.justify_between()
.px_3()
.rounded_md()
.border_1()
.border_color(move || theme().input)
.hover(|s| s.bg(move || theme().accent))
.child(text(title))
.child(text("▾").text_color(move || theme().muted_foreground)),
)
.render_item(|item: &SelectItemState| {
view()
.flex_row()
.items_center()
.justify_between()
.px_2()
.py_1p5()
.when(item.highlighted, |v| v.bg(move || theme().accent))
.when(item.disabled, |v| v.opacity(0.5))
.child(text(item.title.clone()).text_sm())
.when(item.selected, |v| v.child(text("✓").text_sm()))
})
}import { mount, signal, Text, View } from "nsh";
import { Select } from "nsh/base";
const FRUITS = [
{ value: "apple", title: "Apple" },
{ value: "banana", title: "Banana" },
{ value: "blueberry", title: "Blueberry" },
{ value: "grapes", title: "Grapes", disabled: true },
{ value: "pineapple", title: "Pineapple" },
];
function App() {
const fruit = signal<string | null>(null);
const title = () => FRUITS.find((f) => f.value === fruit())?.title;
return (
<Select
items={FRUITS}
value={fruit()}
onChange={(next) => fruit.set(next)}
accessibilityLabel="Fruit"
offset={4}
popupClass="rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md"
backdropClass="bg-black/40"
itemClass="rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground disabled:opacity-50"
trigger={
<View class="h-9 w-48 flex-row items-center justify-between rounded-md border border-input px-3 hover:bg-accent active:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<Text class={fruit() ? "text-foreground" : "text-muted-foreground"}>{title() ?? "Pick a fruit"}</Text>
<Text class="text-muted-foreground">▾</Text>
</View>
}
/>
);
}
mount(App);Select comes from nsh/base, the nsh-base crate in Rust. In Rust, select() makes one
with a setter per prop (.items(..), .value(..), .item_style(..)) and a listener per event (.on_change(..));
SelectItem::new(value, title) makes an item, with .disabled(..) and .group(..).
#Items
items is the list as data: each item has a value (unique among the items: what choosing it sets), a title (what
its row shows, what search and typeahead match and what screen readers say), and optionally disabled and a group.
Adjacent items of one group form a section under a header with that title:
<Select
items={[
{ value: "lisbon", title: "Lisbon", group: "Europe" },
{ value: "london", title: "London", group: "Europe" },
{ value: "tokyo", title: "Tokyo", group: "Asia" },
{ value: "seoul", title: "Seoul", group: "Asia", disabled: true },
]}
groupClass="px-2 pb-1 pt-2 text-xs text-muted-foreground"
trigger={trigger}
/>
The rows are a virtualized list in the popup’s scroll view: a list of thousands of items makes only the rows in view, and the popup scrolls when its rows are taller than the room it has.
#Value
With value, the app owns the chosen item: the select shows the item of that value and reports a choice with
onChange(value); the app sets value to follow it, or keeps its own. value={null} is nothing chosen. Left out, the
select keeps its own value, from defaultValue at first. onChange reports changes: choosing the chosen item again
closes the list.
cleanable adds a clear button while an item is chosen: a press clears the value, reported as onChange(null), and
the list stays closed. The button comes after the trigger in the select’s view, not inside it, so a press on it never
opens the list; the app places it (over the trigger’s end with absolute, where a chevron would be) and fills it:
<Select
items={fonts}
value={font()}
onChange={(next) => font.set(next)}
cleanable
clearButtonAccessibilityLabel="Clear the font"
clearButtonClass="absolute right-2 top-2 size-5 items-center justify-center rounded-full hover:bg-accent"
clearButton={<Text class="text-xs text-muted-foreground">✕</Text>}
trigger={trigger}
/>
The trigger is the app’s element, so it shows whatever the app wants for the chosen item: its title, an icon, a
placeholder while nothing is chosen. In TSX it is the element you pass: it is the combobox and takes the presses, the
keyboard focus, the expanded state and disabled itself, so its own active:, focus: and disabled: classes
follow the select, its own onPress runs too, and the list is placed against it and is at least as wide.
#Open state
Without open, the select keeps its own open state (from defaultOpen). With open, the app owns it and changes it
in onOpenChange(open, reason): "triggerPress" (a press of the trigger, or one of its keys), "select" (a choice),
"pressOutside", "backdropPress" or "cancel" (Escape or back). onOpen and onClose tell when the list is
shown and gone (a sheet once it came in and once it went), onCancel that the user closed it without choosing.
#Search
searchable puts a search field above the items: typing filters them by title, ignoring case, and a section with no
match goes with its header. The highlight moves to the first match, the keys keep moving it while the field has the
keyboard, and Return chooses it. empty is what the list shows while nothing matches:
<Select
items={zones}
searchable
searchPlaceholder="Search time zones"
searchClass="mx-1 mb-1 rounded-md border border-input px-2 py-1.5 text-sm"
empty={<Text class="px-2 py-6 text-center text-sm text-muted-foreground">No time zone found</Text>}
trigger={trigger}
/>
On the desktops the search field takes the keyboard as the list opens; on touch screens the keyboard comes up when the field is pressed. Each opening starts with an empty search.
#Rows the app draws
itemClass styles each item’s row, which shows the item’s title until renderItem draws it: focus: applies to the highlighted row (where the
keys or the pointer are), selected: to the chosen one and disabled: to a disabled one. To draw a row, give
renderItem: it gets the item and its state (SelectItemState) and returns the row’s content, for the rows
in view. renderGroup draws a section’s header the same way:
<Select
items={fonts}
itemClass="rounded-md focus:bg-accent"
renderItem={(item) => (
<View class="flex-row items-center gap-2 px-2 py-1.5">
<Text class="flex-1 text-sm" style={{ fontFamily: item.value }}>{item.title}</Text>
<Text class="w-4 text-right text-sm">{item.selected ? "✓" : ""}</Text>
</View>
)}
renderGroup={(group) => <Text class="px-2 pt-2 text-xs font-medium text-muted-foreground">{group.title}</Text>}
trigger={trigger}
/>
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | The items, in order. |
value | .value(..) | string | null | none | The chosen item’s value, owned by the app; null for none. Left out, the select keeps its own. |
defaultValue | .default_value(..) | string | none | The value at first, without value. |
open | .open(..) | boolean | none | Open, owned by the app. |
defaultOpen | .default_open(..) | boolean | false | Open at first, without open. |
searchable | .searchable(..) | boolean | false | A search field above the items filters them by title. |
searchPlaceholder | .search_placeholder(..) | string | none | The search field’s placeholder. |
cleanable | .cleanable(..) | boolean | false | A clear button while an item is chosen; a press clears the value (onChange(null)). |
disabled | .disabled(..) | boolean | false | It stays closed: the trigger takes no presses and no keyboard focus, and disabled: styles apply to it. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers call it: what the choice is about. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | Desktops: the side of the trigger the list goes on, the other side when only that one has room. |
align | .align(..) | "start" | "center" | "end" | "start" | Desktops: how it lines up with the trigger. |
offset | .offset(..) | number | 0 | Desktops: the gap between the trigger and the list. |
margin | .margin(..) | number | 0 | Desktops: the distance it keeps from the edges of the safe area. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | none | The popup (desktops) or the sheet (touch screens) the list is in. |
backdropClass, backdropStyle | .backdrop_class(..), .backdrop_style(..) | string, StyleProp<ViewStyle> | none | Touch screens: 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<ViewStyle> | none | Each item’s row: focus: while highlighted, selected: while chosen, 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<ViewStyle> | none | The search field. |
clearButtonClass, clearButtonStyle | .clear_button_class(..), .clear_button_style(..) | string, StyleProp<ViewStyle> | none | The clear button (cleanable); disabled: with the select. |
clearButton | .clear_button(..) | JSX.Element | none | The content of the clear button: a cross, an icon. |
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 (a search with no match). |
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 user chose another item (a press, Return or Space, typeahead on the trigger): its value; null when the clear button cleared it. Set value to follow it. |
onOpenChange | .on_open_change(|e| ..) | The list opened or closed, with why. Set open to follow it. |
onOpen | .on_open(|| ..) | The list is shown; as a sheet, once it came in. |
onClose | .on_close(|| ..) | The list is gone; as a sheet, once it went. Also when the app closed it. |
onCancel | .on_cancel(|| ..) | The user closed the list without choosing (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
onOpen or onClose once the list is shown or gone.
#Types
| Type | Members | Description |
|---|---|---|
SelectItem | value: string, title: string, disabled?: boolean, group?: string | An item of items. |
SelectItemState | value, title, disabled, group? (the item’s), selected: boolean, highlighted: boolean, index: number | What renderItem gets: the item, whether it is the chosen one, whether the keys or the pointer are on it (Return or a press chooses it), and its index among the items. |
SelectGroup | title: string | What renderGroup gets: the section’s group. |
In Rust they are nsh_base::SelectItem, SelectItemState and SelectGroup; value is a SelectValue
(Controlled(Some(..)) for a value, Controlled(None) for null), which .value(..) makes from an
Option<SharedString> or a string.
#Parts
| Part | Where | Styled with |
|---|---|---|
| The trigger | the select’s view | the app’s own element (trigger) |
| The clear button | the select’s view, after the trigger (cleanable, while an item is chosen) | 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 popup is a column: its height follows the search field and the rows, so a max-h-* on popupClass caps it and
the list scrolls within it.
#Keyboard
On the desktops, Tab reaches the trigger of a select that is not disabled. With the list closed, Down, Up, Return
and Space open it on the chosen item (with none chosen, Up on the last enabled item and the others on the first), and
letters and digits choose the item whose title starts with what was typed, as a native select does. With the list
open:
| Key | Action |
|---|---|
| Down, Up | The next or previous enabled item, around the ends. |
| Home, End | The first or last enabled item (the search field’s own while it has the keyboard). |
| Page Down, Page Up | A page of rows on, or back. |
| Return | Chooses the highlighted item and closes the list. |
| Space | Chooses the highlighted item (without a search field). |
| Letters, digits | The next item whose title starts with what was typed in the last second (without a search field). |
| Escape | Closes the list without choosing. |
Once the list closes, the keyboard focus returns to the trigger, unless a press outside took it elsewhere. The pointer highlights the row it moves over; the highlighted row is revealed as the keys move to it.
#On each device
| Desktops and desktop browsers | Phones and tablets | |
|---|---|---|
| The list | a popup at the trigger (placement, align, offset, margin), at least as wide as the trigger, as tall as its rows up to the room beside it | a sheet at the bottom over a backdrop, as tall as its rows up to the status bar |
| Closing it | a choice, Escape, a press outside (which goes on to what is under the pointer) | a choice, the backdrop, the Android back button |
| Search | the field takes the keyboard as the list opens | the keyboard comes up when the field is pressed |
| A choice | the press going down chooses, so the search field keeps the keyboard | a tap |
popupClass styles the popup on desktops and the sheet on phones; a style that differs between them can compare
platform.idiom (nsh/device) with "desktop".
#Accessibility
- The trigger is a combobox for screen readers: its label is
accessibilityLabel, its value the chosen item’s title, and it says whether the list is expanded.disableddims it. - The clear button is a button named by
clearButtonAccessibilityLabel; it is no stop of Tab. - The list is a listbox and each row an option, named by its title; the chosen option is selected and a disabled one dimmed. Sections’ headers are headers.
- As the keys move the highlight, the screen reader’s focus moves to the highlighted row; while the search field has the keyboard, the highlighted item’s title is announced instead, so typing goes on.
See Accessibility for labels, roles and announcements in general.