Элементы
Select
Select выбирает одно значение из списка элементов: настройку, фильтр, страну, часовой пояс. Список открывается триггером приложения и выглядит так, как ожидает пользователь на каждом устройстве:
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())
})
}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={({ value }) => fruit.set(value)}
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 поставляется в 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(..) | boolean | false | Открыт ли список в начале, без open. |
searchable | .searchable(..) | boolean | false | Поле поиска над элементами фильтрует их по названию. |
searchPlaceholder | .search_placeholder(..) | string | нет | Подсказка поля поиска. |
searchAccessibilityLabel | .search_accessibility_label(..) | string | нет | Как screen readers называют поле поиска: что именно оно ищет. |
cleanable | .cleanable(..) | boolean | false | Кнопка очистки, пока элемент выбран; нажатие очищает значение (onChange({ value: null })). |
disabled | .disabled(..) | boolean | false | Список остаётся закрытым: триггер не принимает нажатия и фокус клавиатуры, к нему применяются стили disabled:. |
accessibilityLabel | .accessibility_label(..) | string | нет | Как screen readers его называют: о чём этот выбор. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | Десктопы: сторона триггера, с которой размещается список; другая сторона — если место есть только там. |
align | .align(..) | "start" | "center" | "end" | "start" | Десктопы: как список выравнивается относительно триггера. |
offset | .offset(..) | number | 0 | Десктопы: зазор между триггером и списком. |
margin | .margin(..) | number | 0 | Десктопы: расстояние, которое список сохраняет до краёв безопасной области. |
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, когда список показан или скрыт.
#Типы
| Тип | Поля | Описание |
|---|---|---|
SelectItem | value: string, title: string, disabled?: boolean, group?: string | Элемент items. |
SelectEntry | value, title, disabled, group? (элемента), index: number | Что получает renderItem: элемент и его индекс среди items, но никогда не его состояние (focus:, selected: и disabled: строки). |
SelectGroup | title: 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.