元素
Select
选择框从一组条目中选出一个值:一项设置、一个筛选条件、一个国家、一个时区。应用提供的触发元素打开列表,并按每种设备的习惯呈现:
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);- 在桌面平台和桌面浏览器中,它是触发元素处的弹出层,至少与触发元素一样宽,高度随行数增长,最多占满触发元素旁边的空间;键盘可以在条目间移动并选中一个。
- 在手机和平板上,它是屏幕底部、覆盖在背景层之上的 sheet。
选择框负责行为:值(应用持有的,或它自己保存的)、打开状态、键盘、输入跳转、搜索、分组、高亮的行以及屏幕阅读器听到的内容。外观由应用决定:应用为触发元素、弹出层、列表、行、分组标题和搜索框设置样式,或者自己绘制行。
use nsh_base::{SelectItem, SelectItemState, select, theme};
use nsh_core::ui::prelude::*;
const FRUITS: [(&str, &str); 5] = [
("apple", "苹果"),
("banana", "香蕉"),
("blueberry", "蓝莓"),
("grapes", "葡萄"),
("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("选择水果", |(_, 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("水果")
.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: "苹果" },
{ value: "banana", title: "香蕉" },
{ value: "blueberry", title: "蓝莓" },
{ value: "grapes", title: "葡萄", disabled: true },
{ value: "pineapple", title: "菠萝" },
];
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="水果"
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() ?? "选择水果"}</Text>
<Text class="text-muted-foreground">▾</Text>
</View>
}
/>
);
}
mount(App);Select 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,select() 创建一个选择框,每个属性有一个设置方法(.items(..)、.value(..)、.item_style(..)),每个事件有一个监听方法(.on_change(..));SelectItem::new(value, title) 创建一个条目,可再用 .disabled(..) 和 .group(..)。
#条目
items 是以数据形式给出的列表:每个条目有一个 value(在条目中唯一,选中它时值变为它)、一个 title(它的行显示的内容,也是搜索和输入跳转匹配的内容、屏幕阅读器读出的内容),以及可选的 disabled 和 group。相邻且 group 相同的条目组成一个分组,显示在以该名称为标题的分组标题下:
<Select
items={[
{ value: "lisbon", title: "里斯本", group: "欧洲" },
{ value: "london", title: "伦敦", group: "欧洲" },
{ value: "tokyo", title: "东京", group: "亚洲" },
{ value: "seoul", title: "首尔", group: "亚洲", disabled: true },
]}
groupClass="px-2 pb-1 pt-2 text-xs text-muted-foreground"
trigger={trigger}
/>
这些行是弹出层滚动视图中的虚拟化列表:上千个条目的列表只创建可见的行;行的总高度超过可用空间时,弹出层可以滚动。
#值
有 value 时,选中的条目由应用持有:选择框显示该值对应的条目,并用 onChange(value) 报告一次选择;应用把 value 设为它以跟随这次选择,或者保留自己的值。value={null} 表示没有选中任何条目。省略 value 时,选择框自己保存值,初始为 defaultValue。onChange 只报告变化:再次选择已选中的条目会关闭列表。
cleanable 会在有选中条目时加一个清除按钮:按下它会清空值,以 onChange(null) 报告,列表保持关闭。这个按钮在选择框的视图里、触发元素之后,不在触发元素里面,所以按它不会打开列表;由应用摆放它(用 absolute 盖在触发元素末端、原本放箭头的位置)并填充内容:
<Select
items={fonts}
value={font()}
onChange={(next) => font.set(next)}
cleanable
clearButtonAccessibilityLabel="清除字体"
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 中,触发元素就是你传入的元素:它本身就是组合框,按压、键盘焦点、展开状态和 disabled 都在它自己身上,所以它自己的 active:、focus: 和 disabled: 类会跟随选择框,它自己的 onPress 也照常运行;列表相对它放置,并且至少与它一样宽。
#打开状态
没有 open 时,选择框自己保存打开状态(初始为 defaultOpen)。有 open 时,状态由应用持有,并在 onOpenChange(open, reason) 中修改它,原因为 "triggerPress"(按下触发元素,或它的某个按键)、"select"(一次选择)、"pressOutside"、"backdropPress" 或 "cancel"(Escape 或返回)。onOpen 和 onClose 表示列表已显示和已消失(sheet 在完全进入和完全离开之后),onCancel 表示用户没有选择就关闭了它。
#搜索
searchable 在条目上方放一个搜索框:输入的内容按标题筛选条目(不区分大小写),没有匹配项的分组连同标题一起隐藏。高亮移到第一个匹配项,搜索框有键盘焦点时按键继续移动高亮,Return 选中它。empty 是没有任何匹配项时列表显示的内容:
<Select
items={zones}
searchable
searchPlaceholder="搜索时区"
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">没有找到时区</Text>}
trigger={trigger}
/>
在桌面平台上,列表打开时搜索框就获得键盘焦点;在触摸屏上,按下搜索框时键盘才弹出。每次打开时搜索都从空白开始。
#应用绘制的行
itemClass 为每个条目的行设置样式,行中显示条目的标题,提供 renderItem 时改由它绘制:focus: 作用于高亮的行(按键或指针所在的行),selected: 作用于选中的行,disabled: 作用于禁用的行。要自己绘制行,传入 renderItem:它收到条目及其状态(SelectItemState),返回行的内容,只为可见的行调用。renderGroup 以同样的方式绘制分组标题:
<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}
/>
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | 条目,按顺序排列。 |
value | .value(..) | string | null | 无 | 选中条目的 value,由应用持有;null 表示没有选中。省略时选择框自己保存值。 |
defaultValue | .default_value(..) | string | 无 | 没有 value 时的初始值。 |
open | .open(..) | boolean | 无 | 是否打开,由应用持有。 |
defaultOpen | .default_open(..) | boolean | false | 没有 open 时,初始是否打开。 |
searchable | .searchable(..) | boolean | false | 条目上方的搜索框按标题筛选条目。 |
searchPlaceholder | .search_placeholder(..) | string | 无 | 搜索框的占位文字。 |
cleanable | .cleanable(..) | boolean | false | 有选中条目时显示清除按钮;按下会清空值(onChange(null))。 |
disabled | .disabled(..) | boolean | false | 保持关闭:触发元素不接受按压和键盘焦点,并应用 disabled: 样式。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器对它的称呼:这次选择是关于什么的。 |
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> | 无 | 列表所在的弹出层(桌面)或 sheet(触摸屏)。 |
backdropClass、backdropStyle | .backdrop_class(..)、.backdrop_style(..) | string、StyleProp<ViewStyle> | 无 | 触摸屏:sheet 后面的背景层。 |
listClass、listStyle | .list_class(..)、.list_style(..) | string、StyleProp<ViewStyle> | 无 | 列表,即这些行所在的滚动视图。 |
itemClass、itemStyle | .item_class(..)、.item_style(..) | string、StyleProp<ViewStyle> | 无 | 每个条目的行:高亮时应用 focus:,选中时应用 selected:,禁用时应用 disabled:。 |
groupClass、groupStyle | .group_class(..)、.group_style(..) | string、StyleProp<ViewStyle> | 无 | 每个分组的标题。 |
searchClass、searchStyle | .search_class(..)、.search_style(..) | string、StyleProp<ViewStyle> | 无 | 搜索框。 |
clearButtonClass、clearButtonStyle | .clear_button_class(..)、.clear_button_style(..) | string、StyleProp<ViewStyle> | 无 | 清除按钮(cleanable);disabled: 跟随选择框。 |
clearButton | .clear_button(..) | JSX.Element | 无 | 清除按钮的内容:一个叉号、一个图标。 |
clearButtonAccessibilityLabel | .clear_button_accessibility_label(..) | string | 无 | 屏幕阅读器对清除按钮的称呼。 |
trigger | .trigger(..) | JSX.Element | 无 | 按下时打开列表的元素;列表依它定位。 |
empty | .empty(..) | JSX.Element | 无 | 没有任何条目显示时(搜索没有匹配项)列表显示的内容。 |
renderItem | .render_item(..) | (item: SelectItemState) => JSX.Element | 无 | 由应用绘制的条目行;不提供时显示条目的标题。 |
renderGroup | .render_group(..) | (group: SelectGroup) => JSX.Element | 无 | 由应用绘制的分组标题;不提供时显示分组名称。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 触发元素外面的视图。 |
#事件
| 事件 | Rust | 说明 |
|---|---|---|
onChange | .on_change(|value| ..) | 用户选中了另一个条目(按压、Return 或 Space、在触发元素上输入跳转):它的 value;清除按钮清空时为 null。设置 value 以跟随它。 |
onOpenChange | .on_open_change(|e| ..) | 列表打开或关闭了,附带原因。设置 open 以跟随它。 |
onOpen | .on_open(|| ..) | 列表已显示;作为 sheet 时,在它完全进入之后。 |
onClose | .on_close(|| ..) | 列表已消失;作为 sheet 时,在它完全离开之后。应用关闭它时也会触发。 |
onCancel | .on_cancel(|| ..) | 用户没有选择就关闭了列表(Escape 或返回、外部按压或按下背景层)。 |
它们按这个顺序到来:选择时先是 onChange,没有选择就关闭时先是 onCancel,然后是 onOpenChange,列表显示或消失之后是 onOpen 或 onClose。
#类型
| 类型 | 成员 | 说明 |
|---|---|---|
SelectItem | value: string、title: string、disabled?: boolean、group?: string | items 中的一个条目。 |
SelectItemState | value、title、disabled、group?(条目的)、selected: boolean、highlighted: boolean、index: number | renderItem 收到的内容:条目本身、它是否是选中的条目、按键或指针是否在它上面(Return 或按压会选中它),以及它在 items 中的序号。 |
SelectGroup | title: string | renderGroup 收到的内容:分组的 group。 |
在 Rust 中它们是 nsh_base::SelectItem、SelectItemState 和 SelectGroup;value 是 SelectValue(一个值为 Controlled(Some(..)),null 为 Controlled(None)),.value(..) 可以从 Option<SharedString> 或字符串创建它。
#部件
| 部件 | 位置 | 样式来源 |
|---|---|---|
| 触发元素 | 选择框的视图 | 应用自己的元素(trigger) |
| 清除按钮 | 选择框的视图,触发元素之后(cleanable,有选中条目时) | clearButtonClass、clearButtonStyle;内容 clearButton |
| 弹出层或 sheet | 位于所有内容之上,在触发元素处(桌面)或屏幕底部(触摸屏) | popupClass、popupStyle |
| 背景层 | sheet 后面(触摸屏) | backdropClass、backdropStyle |
| 搜索框 | 弹出层中的第一个(searchable) | searchClass、searchStyle、searchPlaceholder |
| 列表 | 弹出层中,搜索框之后 | listClass、listStyle |
| 分组标题 | 列表中,分组的条目之前 | groupClass、groupStyle、renderGroup |
| 条目的行 | 列表中 | itemClass、itemStyle、renderItem |
| 空状态 | 弹出层中,没有条目显示时 | 它自己的元素(empty) |
弹出层是一列:它的高度随搜索框和各行变化,所以在 popupClass 上加 max-h-* 可以限制它的高度,列表在其中滚动。
#键盘
在桌面平台上,Tab 可以到达未 disabled 的选择框的触发元素。列表关闭时,Down、Up、Return 和 Space 会打开它并高亮选中的条目(没有选中时,Up 高亮最后一个可用条目,其他键高亮第一个),字母和数字会选中标题以已输入内容开头的条目,与原生选择框一样。列表打开时:
| 按键 | 作用 |
|---|---|
| Down、Up | 下一个或上一个可用条目,到两端后循环。 |
| Home、End | 第一个或最后一个可用条目(搜索框有键盘焦点时归搜索框使用)。 |
| Page Down、Page Up | 向后或向前一页。 |
| Return | 选中高亮的条目并关闭列表。 |
| Space | 选中高亮的条目(没有搜索框时)。 |
| 字母、数字 | 下一个标题以最近一秒内输入内容开头的条目(没有搜索框时)。 |
| Escape | 不选择,关闭列表。 |
列表关闭后,键盘焦点回到触发元素,除非一次外部按压把它带到了别处。指针移过的行会被高亮;按键移动到的高亮行会滚动到可见位置。
#在各设备上
| 桌面平台和桌面浏览器 | 手机和平板 | |
|---|---|---|
| 列表 | 触发元素处的弹出层(placement、align、offset、margin),至少与触发元素一样宽,高度随行数增长,最多占满触发元素旁边的空间 | 屏幕底部、覆盖在背景层之上的 sheet,高度随行数增长,最高到状态栏 |
| 关闭 | 一次选择、Escape、外部按压(按压继续作用于指针下的元素) | 一次选择、背景层、Android 返回键 |
| 搜索 | 列表打开时搜索框获得键盘焦点 | 按下搜索框时键盘弹出 |
| 选择 | 鼠标键按下时就选中,搜索框保持键盘焦点 | 轻点 |
popupClass 在桌面上为弹出层、在手机上为 sheet 设置样式;两者需要不同样式时,可以把 platform.idiom(nsh/device)与 "desktop" 比较。
#无障碍
- 触发元素对屏幕阅读器来说是一个组合框(combobox):它的标签是
accessibilityLabel,值是选中条目的标题,并说明列表是否展开。disabled时它显示为不可用。 - 清除按钮是一个按钮,名称来自
clearButtonAccessibilityLabel;它不是 Tab 的一站。 - 列表是一个列表框(listbox),每一行是一个选项(option),以标题命名;选中的选项处于选中状态,禁用的选项显示为不可用。分组标题是标题(header)。
- 按键移动高亮时,屏幕阅读器的焦点移到高亮的行;搜索框有键盘焦点时,改为播报高亮条目的标题,以便继续输入。
关于标签、角色和播报的一般说明,见无障碍。