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:

el-select.tsx
el-select.tsxLive
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()))
        })
}

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.

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

PropRustTypeDefaultDescription
items.items(..)SelectItem[][]The items, in order.
value.value(..)string | nullnoneThe chosen item’s value, owned by the app; null for none. Left out, the select keeps its own.
defaultValue.default_value(..)stringnoneThe value at first, without value.
open.open(..)booleannoneOpen, owned by the app.
defaultOpen.default_open(..)booleanfalseOpen at first, without open.
searchable.searchable(..)booleanfalseA search field above the items filters them by title.
searchPlaceholder.search_placeholder(..)stringnoneThe search field’s placeholder.
cleanable.cleanable(..)booleanfalseA clear button while an item is chosen; a press clears the value (onChange(null)).
disabled.disabled(..)booleanfalseIt stays closed: the trigger takes no presses and no keyboard focus, and disabled: styles apply to it.
accessibilityLabel.accessibility_label(..)stringnoneWhat 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(..)number0Desktops: the gap between the trigger and the list.
margin.margin(..)number0Desktops: the distance it keeps from the edges of the safe area.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe popup (desktops) or the sheet (touch screens) the list is in.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>noneTouch screens: the 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<ViewStyle>noneEach item’s row: focus: while highlighted, selected: while chosen, 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<ViewStyle>noneThe search field.
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>noneThe clear button (cleanable); disabled: with the select.
clearButton.clear_button(..)JSX.ElementnoneThe content of the clear button: a cross, an icon.
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 (a search with no match).
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 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

TypeMembersDescription
SelectItemvalue: string, title: string, disabled?: boolean, group?: stringAn item of items.
SelectItemStatevalue, title, disabled, group? (the item’s), selected: boolean, highlighted: boolean, index: numberWhat 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.
SelectGrouptitle: stringWhat 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

PartWhereStyled with
The triggerthe select’s viewthe app’s own element (trigger)
The clear buttonthe select’s view, after the trigger (cleanable, while an item is chosen)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 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:

KeyAction
Down, UpThe next or previous enabled item, around the ends.
Home, EndThe first or last enabled item (the search field’s own while it has the keyboard).
Page Down, Page UpA page of rows on, or back.
ReturnChooses the highlighted item and closes the list.
SpaceChooses the highlighted item (without a search field).
Letters, digitsThe next item whose title starts with what was typed in the last second (without a search field).
EscapeCloses 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 browsersPhones and tablets
The lista 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 ita sheet at the bottom over a backdrop, as tall as its rows up to the status bar
Closing ita choice, Escape, a press outside (which goes on to what is under the pointer)a choice, the backdrop, the Android back button
Searchthe field takes the keyboard as the list opensthe keyboard comes up when the field is pressed
A choicethe press going down chooses, so the search field keeps the keyboarda 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. disabled dims 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.