Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Select

Select выбирает одно значение из списка элементов: настройку, фильтр, страну, часовой пояс. Список открывается триггером приложения и выглядит так, как ожидает пользователь на каждом устройстве:

el-select.tsx
el-select.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Button, type OpenChangeReason, Select, type SelectEntry, type SelectGroup, Switch } from "nsh/base";
import { button, buttonText, CheckMark, desktop, focusRing, hoverFill, Icon, Log, Panel, size, Split, 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().muted }}>
        {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'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 (bolder while chosen), a quieter detail at the
 * end, the check in its own lane (`group-selected:`).
 */
function Row(props: { item: SelectEntry; detail?: string }) {
  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 class="font-normal group-selected:font-semibold" style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>
        {props.item.title}
      </Text>
      <If when={props.detail}>
        <Text style={{ fontSize: 12, color: ui().faint }}>{props.detail}</Text>
      </If>
      <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 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) => ({ open: next, reason }: { open: 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={({ checked }) => locked.set(checked)}
                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={({ value: 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={rowClass}
              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={({ value: 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"
              searchAccessibilityLabel="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={rowClass}
              // 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={<Icon name="close" size={14} tint={ui().muted} />}
              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);
  • На настольных платформах и в браузерах на десктопе это всплывающее окно поверх триггера. Выбранная строка накладывается на триггер, поэтому выбранный заголовок остаётся на месте (позиция item-aligned из shadcn/ui). Окно по ширине равно триггеру (другую ширину задаёт popupStyle / popupClass приложения, как menu_width у gpui-component), а по высоте соответствует строкам, но не превышает свободного места на экране. Если ничего не выбрано, список открывается под триггером. Клавиатура перемещается по элементам и выбирает один из них.
  • На телефонах и планшетах это нижняя панель (sheet) в нижней части экрана поверх подложки.

Select отвечает за поведение: значение (принадлежащее приложению или собственное), состояние открытости, клавиатуру, typeahead, поиск, секции, подсвеченную строку и то, что озвучивают screen readers. Внешний вид задаёт приложение: оно оформляет триггер, всплывающее окно, список, строки, заголовки секций и поле поиска или рисует строки само.

use nsh_base::{SelectEntry, SelectItem, select, theme};
use nsh_core::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_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
        .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: &SelectEntry| {
            view()
                .flex_row()
                .items_center()
                .px_2()
                .py_1p5()
                .child(text(item.title.clone()).text_sm())
        })
}

Select поставляется в nsh/base, крейте nsh-base в Rust. В Rust select() создаёт его с сеттером на каждое свойство (.items(..), .value(..), .item_style(..)) и обработчиком на каждое событие (.on_change(..)); SelectItem::new(value, title) создаёт элемент с .disabled(..) и .group(..).

#Элементы

items задаёт список как данные: у каждого элемента есть value (уникальное среди элементов: именно оно устанавливается при выборе), title (что показывает его строка, с чем сопоставляются поиск и typeahead и что произносят screen readers) и, по желанию, disabled и group. Соседние элементы с одинаковым group образуют секцию под заголовком с этим названием:

<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}
/>

Строки образуют виртуализированный список в прокручиваемой области всплывающего окна: список из тысяч элементов отрисовывает только видимые строки, а окно прокручивается, когда строки выше доступного места.

#Значение

С value выбранным элементом владеет приложение: select показывает элемент с этим значением и сообщает о выборе через onChange({ value }); приложение устанавливает value, чтобы следовать за выбором, или хранит своё. value={null} означает, что ничего не выбрано. Если свойство не задано, select хранит собственное значение, сначала взятое из defaultValue. onChange сообщает об изменениях: повторный выбор уже выбранного элемента закрывает список.

cleanable добавляет кнопку очистки, пока элемент выбран: нажатие очищает значение (о чём сообщает onChange({ value: null })), а список остаётся закрытым. Кнопка находится в представлении select после триггера, а не внутри него, поэтому нажатие на неё никогда не открывает список. Приложение размещает её само (поверх конца триггера с помощью absolute, там, где был бы шеврон) и наполняет содержимым:

<Select
  items={fonts}
  value={font()}
  onChange={({ value }) => font.set(value)}
  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}
/>

Триггер — элемент приложения, поэтому он показывает всё, что приложение хочет для выбранного элемента: его название, значок, подсказку, пока ничего не выбрано. В TSX это передаваемый вами элемент: он является combobox и сам принимает нажатия, фокус клавиатуры, состояние раскрытия и disabled. Поэтому его собственные классы active:, focus: и disabled: следуют состоянию select, его собственный onPress тоже срабатывает, а список размещается относительно него и имеет такую же ширину.

#Состояние открытости

Без open select хранит собственное состояние открытости (начальное берётся из defaultOpen). С open им владеет приложение и изменяет его в onOpenChange({ open, reason }): "triggerPress" (нажатие на триггер или одна из его клавиш), "select" (выбор), "pressOutside", "backdropPress" или "cancel" (Escape или «назад»). onOpen и onClose сообщают, когда список показан и когда он скрыт (для нижней панели — после того как она появилась и после того как исчезла), а onCancel — что пользователь закрыл список, ничего не выбрав.

#Поиск

searchable добавляет над элементами поле поиска: набранный текст фильтрует их по названию без учёта регистра, а секция без совпадений исчезает вместе со своим заголовком. Подсветка переходит на первое совпадение, клавиши продолжают её перемещать, пока фокус клавиатуры в поле, а Return выбирает подсвеченный элемент. empty — то, что показывает список, пока ничего не найдено:

<Select
  items={zones}
  searchable
  searchPlaceholder="Search time zones"
  searchAccessibilityLabel="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}
/>

На настольных платформах поле поиска получает клавиатуру в момент открытия списка; на сенсорных экранах клавиатура появляется при нажатии на поле. Каждое открытие начинается с пустого поиска.

#Строки, которые рисует приложение

itemClass оформляет строку каждого элемента, которая показывает название элемента, пока renderItem не нарисует её иначе: focus: применяется к подсвеченной строке (там, где находятся клавиши или указатель), selected: — к выбранной, а disabled: — к отключённой. Чтобы нарисовать строку, передайте renderItem: он получает описание элемента (SelectEntry) и возвращает содержимое строки для видимых строк. Состояние строки он никогда не получает: при group в itemClass содержимое следует состояниям строки через классы group-focus:, group-selected: и group-disabled:, поэтому отрисовка выполняется один раз на элемент, а перемещение подсветки или выбор ничего не перерисовывают. renderGroup рисует заголовок секции тем же способом:

<Select
  items={fonts}
  itemClass="group rounded-md focus:bg-accent disabled:opacity-50"
  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 opacity-0 group-selected:opacity-100">✓</Text>
    </View>
  )}
  renderGroup={(group) => <Text class="px-2 pt-2 text-xs font-medium text-muted-foreground">{group.title}</Text>}
  trigger={trigger}
/>

#Свойства

СвойствоRustТипПо умолчаниюОписание
items.items(..)SelectItem[][]Элементы по порядку.
value.value(..)string | nullнетvalue выбранного элемента, которым владеет приложение; null — ничего не выбрано. Если не задано, select хранит собственное значение.
defaultValue.default_value(..)stringнетНачальное значение, без value.
open.open(..)booleanнетОткрытость, которой владеет приложение.
defaultOpen.default_open(..)booleanfalseОткрыт ли список в начале, без open.
searchable.searchable(..)booleanfalseПоле поиска над элементами фильтрует их по названию.
searchPlaceholder.search_placeholder(..)stringнетПодсказка поля поиска.
searchAccessibilityLabel.search_accessibility_label(..)stringнетКак screen readers называют поле поиска: что именно оно ищет.
cleanable.cleanable(..)booleanfalseКнопка очистки, пока элемент выбран; нажатие очищает значение (onChange({ value: null })).
disabled.disabled(..)booleanfalseСписок остаётся закрытым: триггер не принимает нажатия и фокус клавиатуры, к нему применяются стили disabled:.
accessibilityLabel.accessibility_label(..)stringнетКак screen readers его называют: о чём этот выбор.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"Десктопы: сторона триггера, с которой размещается список; другая сторона — если место есть только там.
align.align(..)"start" | "center" | "end""start"Десктопы: как список выравнивается относительно триггера.
offset.offset(..)number0Десктопы: зазор между триггером и списком.
margin.margin(..)number0Десктопы: расстояние, которое список сохраняет до краёв безопасной области.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>нетВсплывающее окно (десктопы) или нижняя панель (сенсорные экраны), в которых находится список.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>нетСенсорные экраны: подложка за нижней панелью.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>нетСписок — прокручиваемая область со строками.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>нетСтрока каждого элемента; её стили текста наследует название: focus: при подсветке, selected: при выборе, disabled:, пока элемент недоступен.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<ViewStyle>нетЗаголовок каждой секции.
searchClass, searchStyle.search_class(..), .search_style(..)string, StyleProp<InputStyle>нетПоле поиска: его рамка и текст (color, fontSize, placeholderColor).
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>нетКнопка очистки (cleanable); disabled: вместе с select.
clearButton.clear_button(..)JSX.ElementнетСодержимое кнопки очистки: крестик, значок.
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)stringнетКак screen readers называют кнопку очистки.
trigger.trigger(..)JSX.ElementнетТо, что открывает список при нажатии; список размещается относительно него.
empty.empty(..)JSX.ElementнетЧто показывает список, пока нет ни одного элемента (поиск без совпадений).
renderItem.render_item(..)(item: SelectEntry) => JSX.ElementнетСтрока элемента, нарисованная приложением по описанию элемента (её состояния — классы строки); иначе — название элемента.
renderGroup.render_group(..)(group: SelectGroup) => JSX.ElementнетЗаголовок секции, нарисованный приложением; иначе — её название.
class, styleметоды стилейstring, StyleProp<ViewStyle>нетПредставление вокруг триггера.

#События

СобытиеRustОписание
onChange.on_change(|value| ..)Пользователь выбрал другой элемент (нажатие, Return или Space, typeahead на триггере): его value; null, если значение очистила кнопка очистки. Установите value, чтобы следовать за выбором.
onOpenChange.on_open_change(|e| ..)Список открылся или закрылся, с указанием причины. Установите open, чтобы следовать за изменением.
onOpen.on_open(|| ..)Список показан; для нижней панели — после её появления.
onClose.on_close(|| ..)Список скрыт; для нижней панели — после её исчезновения. Также когда его закрыло приложение.
onCancel.on_cancel(|| ..)Пользователь закрыл список, ничего не выбрав (Escape или «назад», нажатие снаружи или на подложку).

События приходят в таком порядке: onChange при выборе или onCancel при закрытии без выбора, затем onOpenChange, затем onOpen или onClose, когда список показан или скрыт.

#Типы

ТипПоляОписание
SelectItemvalue: string, title: string, disabled?: boolean, group?: stringЭлемент items.
SelectEntryvalue, title, disabled, group? (элемента), index: numberЧто получает renderItem: элемент и его индекс среди items, но никогда не его состояние (focus:, selected: и disabled: строки).
SelectGrouptitle: stringЧто получает renderGroup: group секции.

В Rust это nsh_base::SelectItem, SelectEntry и SelectGroup; value — это SelectValue (Controlled(Some(..)) для значения, Controlled(None) для null), который .value(..) создаёт из Option<SharedString> или строки.

#Части

ЧастьГде находитсяЧем оформляется
Триггерпредставление selectсобственный элемент приложения (trigger)
Кнопка очисткипредставление select, после триггера (cleanable, пока элемент выбран)clearButtonClass, clearButtonStyle; её содержимое — clearButton
Всплывающее окно или нижняя панельповерх всего, у триггера (десктопы) или внизу (сенсорные экраны)popupClass, popupStyle
Подложказа нижней панелью (сенсорные экраны)backdropClass, backdropStyle
Поле поискавсплывающее окно, первым (searchable)searchClass, searchStyle, searchPlaceholder, searchAccessibilityLabel
Списоквсплывающее окно, после поля поискаlistClass, listStyle
Заголовок секциисписок, перед её элементамиgroupClass, groupStyle, renderGroup
Строка элементасписокitemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: внутри, под group в itemClass)
Пустое состояниевсплывающее окно, пока ни один элемент не показансобственный элемент (empty)

Всплывающее окно — это колонка: её высота зависит от поля поиска и строк, поэтому max-h-* на popupClass ограничивает её, а список прокручивается внутри.

#Клавиатура

На настольных платформах Tab переводит фокус на триггер select, который не disabled. Когда список закрыт, Down, Up, Return и Space открывают его на выбранном элементе (если ничего не выбрано, Up открывает на последнем доступном элементе, а остальные клавиши — на первом), а буквы и цифры выбирают элемент, название которого начинается с набранного, как это делает нативный select. Когда список открыт:

КлавишаДействие
Down, UpСледующий или предыдущий доступный элемент, по кругу.
Home, EndПервый или последний доступный элемент (собственные действия поля поиска, пока в нём фокус клавиатуры).
Page Down, Page UpНа страницу строк вперёд или назад.
ReturnВыбирает подсвеченный элемент и закрывает список.
SpaceВыбирает подсвеченный элемент (без поля поиска).
Буквы, цифрыСледующий элемент, название которого начинается с набранного за последнюю секунду (без поля поиска).
EscapeЗакрывает список без выбора.

После закрытия списка фокус клавиатуры возвращается на триггер, если только нажатие снаружи не перенесло его в другое место. Указатель подсвечивает строку, над которой проходит; подсвеченная строка прокручивается в видимую область при перемещении к ней клавишами.

#На каждом устройстве

Десктопы и браузеры на десктопеТелефоны и планшеты
Списоквсплывающее окно поверх триггера, выбранная строка — на нём (под ним, если ничего не выбрано), по ширине равно триггеру, если только popupStyle не задаёт ширину, по высоте соответствует строкам, но не больше свободного места на экраненижняя панель внизу поверх подложки, по высоте соответствует строкам, но не выше строки состояния
Закрытиевыбор, Escape, нажатие снаружи (оно передаётся дальше тому, что находится под указателем)выбор, подложка, кнопка «назад» на Android
Поискполе получает клавиатуру при открытии спискаклавиатура появляется при нажатии на поле
Выборвыбирает нажатие в момент опускания, поэтому поле поиска сохраняет клавиатурукасание

popupClass оформляет всплывающее окно на десктопах и нижнюю панель на телефонах; стиль, различающийся между ними, может сравнивать platform.idiom (nsh/device) с "desktop".

#Доступность

  • Для программ чтения с экрана триггер является комбинированным списком (combobox): его метка — accessibilityLabel, его значение — название выбранного элемента, а также он сообщает, раскрыт ли список. disabled делает его приглушённым.
  • Кнопка очистки — это кнопка с именем clearButtonAccessibilityLabel; она не является остановкой при навигации клавишей Tab.
  • Поле поиска — это текстовое поле с именем searchAccessibilityLabel; его плейсхолдер — подсказка, которая отображается, пока поле пусто.
  • Список — это listbox, а каждая строка — option с именем, соответствующим её названию; выбранный вариант отмечен как выбранный, а отключённый приглушён. Заголовки секций являются заголовками.
  • Когда клавиши перемещают выделение, фокус программы чтения с экрана переходит на выделенную строку; пока клавиатурой управляет поле поиска, вместо этого озвучивается название выделенного элемента, чтобы ввод текста можно было продолжать.

Общие сведения о метках, ролях и озвучивании см. в разделе Accessibility.