元素
Combobox
组合框从一组条目中选出值,一个,或者在 multiple 时选多个:一篇文章的标签、一个频道的成员、一份文档的语言。它是 Select 的同类组件,和它共用同一个列表,并按每种设备的习惯呈现:
import { type JSX, mount, signal, Text, View } from "nsh";
import { Combobox, type OpenChangeReason, type SelectGroup, type SelectItemState } from "nsh/base";
import { button, desktop, Log, Panel, size, Split, tint, ui } from "./palette";
// Two comboboxes in a card: labels for an issue, several picked in one go
// from a searchable list with sections, a clear button and a footer; and
// an assignee, one person replacing the last. The trigger is the shared
// button look with what is selected 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. Selected rows show a check in its own lane.
const LABELS = [
{ value: "bug", title: "Bug", group: "Type" },
{ value: "feature", title: "Feature", group: "Type" },
{ value: "docs", title: "Docs", group: "Type" },
{ value: "ios", title: "iOS", group: "Platform" },
{ value: "android", title: "Android", group: "Platform" },
{ value: "macos", title: "macOS", group: "Platform" },
{ value: "windows", title: "Windows", group: "Platform" },
{ value: "linux", title: "Linux", group: "Platform" },
{ value: "web", title: "Web", group: "Platform" },
{ value: "wontfix", title: "Won't fix", group: "Status", disabled: true },
{ value: "blocked", title: "Blocked", group: "Status" },
];
const PEOPLE = [
{ value: "ada", title: "Ada Lovelace" },
{ value: "grace", title: "Grace Hopper" },
{ value: "linus", title: "Linus Torvalds" },
{ value: "margaret", title: "Margaret Hamilton" },
];
const titlesOf = (items: { value: string; title: string }[], values: string[]) =>
values.map((v) => items.find((i) => i.value === v)?.title ?? v);
/** 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, what is selected at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; clearable?: boolean }) {
const look = button("secondary", {
style: () => ({
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
...(desktop ? { width: 260 } : {}),
...(props.open ? { borderColor: ui().faint } : {}),
}),
});
return (
<View {...look}>
<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, the check in its own lane. */
function Row(props: { item: SelectItemState }) {
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,
...(props.item.highlighted ? { backgroundColor: tint(ui().accent, 0.08), borderColor: tint(ui().accent, 0.24) } : {}),
}}
>
<Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>{props.item.title}</Text>
<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 labels = signal<string[]>(["bug", "ios"]);
const assignee = signal<string[]>([]);
const labelsOpen = signal(false);
const assigneeOpen = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 8));
const opened = (open: (next: boolean) => void) => ({ open: next }: { open: boolean; reason: OpenChangeReason }) => open(next);
const labelTitle = () => {
const titles = titlesOf(LABELS, labels());
return titles.length === 0 ? undefined : titles.length <= 2 ? titles.join(", ") : `${titles.length} labels`;
};
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>combobox</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Pick several labels without the list closing; Cmd+Return confirms" : "Tap labels to toggle them; tap outside to finish"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text }}>{`labels [${labels().join(", ")}]`}</Text>
<Text style={{ fontSize: 13, color: ui().text }}>{`assignee [${assignee().join(", ")}]`}</Text>
</Panel>
<Log title="onChange · onConfirm" lines={events()} minHeight={120} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
<Field label="Labels" detail="Several at once, with search and sections">
<Combobox
items={LABELS}
multiple
value={labels()}
onChange={({ value }) => {
labels.set(value);
log(`Labels → ${titlesOf(LABELS, value).join(", ") || "none"}`);
}}
onConfirm={({ value }) => log(`Confirmed ${value.length} labels`)}
onOpenChange={opened((next) => labelsOpen.set(next))}
accessibilityLabel="Labels"
searchable
searchPlaceholder="Filter labels"
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 label matches</Text>}
offset={4}
margin={8}
popupStyle={surface()}
backdropStyle={backdrop}
itemClass="active:opacity-70"
style={desktop ? { width: 260 } : undefined}
cleanable
clearButtonAccessibilityLabel="Clear the labels"
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>}
footerStyle={{ borderTopWidth: 1, borderColor: ui().border, marginTop: 4, paddingTop: 4 }}
footer={
<View style={{ paddingHorizontal: 8, paddingVertical: 6 }}>
<Text style={{ fontSize: 12, color: ui().muted }}>{`${labels().length} selected`}</Text>
</View>
}
trigger={<Trigger title={labelTitle()} placeholder="Add labels" open={labelsOpen()} clearable={labels().length > 0} />}
renderItem={(item) => <Row item={item} />}
renderGroup={(group) => <Header group={group} />}
/>
</Field>
<Field label="Assignee" detail="One person: a choice replaces the last">
<Combobox
items={PEOPLE}
value={assignee()}
onChange={({ value }) => {
assignee.set(value);
log(`Assignee → ${titlesOf(PEOPLE, value)[0] ?? "none"}`);
}}
onOpenChange={opened((next) => assigneeOpen.set(next))}
accessibilityLabel="Assignee"
offset={4}
margin={8}
popupStyle={surface()}
backdropStyle={backdrop}
itemClass="active:opacity-70"
trigger={<Trigger title={titlesOf(PEOPLE, assignee())[0]} placeholder="Assign someone" open={assigneeOpen()} />}
renderItem={(item) => <Row item={item} />}
/>
</Field>
</View>
</Split>
</View>
);
}
mount(App);- 在桌面平台和桌面浏览器上,是触发元素处的弹出层,至少和触发元素一样宽,高度随条目增加,最多到触发元素旁边剩余的空间。
- 在手机和平板上,是屏幕底部、覆盖在背景层之上的 sheet。
组合框负责行为:值(应用的,或它自己的)、展开状态、键盘、搜索、分组、高亮的行,以及屏幕阅读器听到的内容。在 multiple 时,一次选择会把该条目加入或移出值,列表保持打开,所以可以一次选好多个。外观由应用决定:应用为触发元素、弹出层、列表、行、分组标题、搜索框和底部区域设置样式,或者自己绘制行。
use nsh_base::{SelectItem, SelectItemState, combobox, theme};
use nsh_core::prelude::*;
const TAGS: [(&str, &str); 5] = [
("bug", "Bug"),
("design", "Design"),
("docs", "Docs"),
("perf", "Performance"),
("security", "Security"),
];
fn tag_picker() -> impl IntoElement {
let tags = signal(Vec::<SharedString>::new());
let title = move || match tags.get().len() {
0 => "Add tags".to_string(),
n => format!("{n} tags"),
};
combobox()
.items(
TAGS.iter()
.map(|&(value, title)| SelectItem::new(value, title))
.collect::<Vec<_>>(),
)
.multiple(true)
.searchable(true)
.search_placeholder("Find a tag")
.value(move || Some(tags.get()))
.on_change(move |next: &Vec<SharedString>| tags.set(next.clone()))
.accessibility_label("Tags")
.offset(px(4.0))
.popup_style(|s| {
s.p_1()
.rounded_lg()
.border_1()
.border_color(move || theme().border)
.bg(move || theme().popover)
.shadow_md()
})
.trigger(
view()
.h_9()
.w_48()
.px_3()
.justify_center()
.rounded_md()
.border_1()
.border_color(move || theme().input)
.child(text(title)),
)
.render_item(|item: &SelectItemState| {
view()
.flex_row()
.justify_between()
.px_2()
.py_1p5()
.when(item.highlighted, |v| v.bg(move || theme().accent))
.child(text(item.title.clone()).text_sm())
.when(item.selected, |v| v.child(text("✓").text_sm()))
})
}import { mount, signal, Text, View } from "nsh";
import { Combobox } from "nsh/base";
const TAGS = [
{ value: "bug", title: "Bug" },
{ value: "design", title: "Design" },
{ value: "docs", title: "Docs" },
{ value: "perf", title: "Performance" },
{ value: "security", title: "Security" },
];
function App() {
const tags = signal<string[]>([]);
return (
<Combobox
items={TAGS}
multiple
searchable
searchPlaceholder="Find a tag"
value={tags()}
onChange={({ value }) => tags.set(value)}
accessibilityLabel="Tags"
offset={4}
popupClass="rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md"
backdropClass="bg-black/40"
searchClass="mb-1 h-8 rounded-md border border-input px-2 text-sm placeholder:text-muted-foreground"
itemClass="rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground"
trigger={
<View class="h-9 w-48 justify-center rounded-md border border-input px-3 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<Text>{tags().length ? `${tags().length} tags` : "Add tags"}</Text>
</View>
}
renderItem={(item) => (
<View class="flex-row justify-between">
<Text>{item.title}</Text>
<Text>{item.selected ? "✓" : ""}</Text>
</View>
)}
/>
);
}
mount(App);Combobox 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,combobox() 创建一个组合框,每个属性有一个设置方法(.items(..)、.value(..)、.multiple(..)),每个事件有一个监听方法(.on_change(..))。它的条目与 Select 的相同:SelectItem::new(value, title),可以用 .disabled(..) 和 .group(..),分组方式也和选择框一样(见条目)。
#值
值是已选条目的值组成的数组。有 value 时,值由应用持有:组合框把这些条目显示为已选,并用 onChange({ value }) 报告每次变化;应用把 value 设为报告的值以跟随它,或者保留自己的值。省略 value 时,组合框自己保存值,初始为 defaultValue。
- 在
multiple时,按压、Return 或 Space 切换高亮的条目:不在值里就加入,在就移出。列表保持打开。 - 不设
multiple时,一次选择把值替换为该条目的值并关闭列表;再次选择已选的条目只会关闭列表。
onConfirm({ value }) 在列表关闭时告知当时的值:每次打开报告一次,不论怎样关闭(一次选择、Escape、在外面按压、按触发元素),值是用户离开时的样子。Mac 上的 Cmd+Return(其他平台为 Ctrl+Return)关闭列表并确认。
cleanable 会在有已选条目时加一个清除按钮:按下它会清空值(onChange({ value: [] }))。这个按钮在触发元素之后,不在它里面,所以按它不会打开列表;由应用摆放并填充内容,和选择框的清除按钮一样。
#搜索
searchable 在条目上方放一个搜索框,和选择框的一样:输入时按标题筛选条目,忽略大小写,没有匹配时显示 empty。搜索词默认属于搜索框自己,每次打开从 defaultQuery(没有则为空)开始;有 query 时由应用持有:onQueryChange({ query }) 报告输入的内容,应用把 query 设为它以跟随,用来按自己的方式筛选,或给出一个起始搜索词。
#底部区域
footer 是显示在行下方的内容,位于它自己的部件中(footerClass、footerStyle):一个操作(“管理标签”)、一个计数、一句提示。行滚动时它保持不动。
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | 条目,按顺序。 |
multiple | .multiple(..) | boolean | false | 一次选择把条目加入或移出值,列表保持打开。 |
value | .value(..) | string[] | 无 | 已选条目的值,由应用持有。 |
defaultValue | .default_value(..) | string[] | [] | 没有 value 时的初始值。 |
open、defaultOpen | .open(..)、.default_open(..) | boolean | false | 展开状态,由应用或组合框自己持有。 |
searchable | .searchable(..) | boolean | false | 条目上方的搜索框按标题筛选条目。 |
searchPlaceholder | .search_placeholder(..) | string | 无 | 搜索框的占位文字。 |
query | .query(..) | string | 无 | 搜索词,由应用持有。 |
defaultQuery | .default_query(..) | string | 无 | 没有 query 时每次打开的初始搜索词。 |
cleanable | .cleanable(..) | boolean | false | 有已选条目时显示清除按钮;按下会清空值。 |
disabled | .disabled(..) | boolean | false | 保持关闭,不接受按压和键盘焦点。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 这次选择是关于什么的,供屏幕阅读器使用。 |
placement、align、offset、margin | .placement(..) 等 | 同 Select | "bottom"、"start"、0、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<TextStyle> | 无 | 每个条目的行:高亮时应用 focus:,已选时应用 selected:,不可选时应用 disabled:。 |
groupClass、groupStyle | .group_class(..)、.group_style(..) | string、StyleProp<ViewStyle> | 无 | 每个分组的标题。 |
searchClass、searchStyle | .search_class(..)、.search_style(..) | string、StyleProp<InputStyle> | 无 | 搜索框。 |
footerClass、footerStyle | .footer_class(..)、.footer_style(..) | string、StyleProp<ViewStyle> | 无 | 行下方的底部区域。 |
clearButtonClass、clearButtonStyle | .clear_button_class(..)、.clear_button_style(..) | string、StyleProp<ViewStyle> | 无 | 清除按钮(cleanable)。 |
clearButton | .clear_button(..) | JSX.Element | 无 | 清除按钮的内容。 |
clearButtonAccessibilityLabel | .clear_button_accessibility_label(..) | string | 无 | 屏幕阅读器对清除按钮的称呼。 |
trigger | .trigger(..) | JSX.Element | 无 | 按下时打开列表的元素;列表相对它放置。 |
empty | .empty(..) | JSX.Element | 无 | 没有条目显示时列表显示的内容。 |
footer | .footer(..) | 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| ..) | 值变化了(一次选择、一次切换、清除按钮):{ value },已选条目的值。设置 value 以跟随它。 |
onConfirm | .on_confirm(|value| ..) | 列表关闭了:{ value } 为当时的值,每次打开报告一次。 |
onQueryChange | .on_query_change(|query| ..) | 用户在搜索框中输入了内容:{ query }。设置 query 以跟随它。 |
onOpenChange | .on_open_change(|e| ..) | 列表打开或关闭了,以及原因({ open, reason })。设置 open 以跟随它。 |
onOpen、onClose | .on_open(..)、.on_close(..) | 列表已显示,或已消失。 |
onCancel | .on_cancel(..) | 用户没有通过会关闭列表的选择而关闭了它(Escape 或返回、在外面或背景层上按压)。 |
它们按这个顺序到来:选择时先是 onChange,没有选择就关闭时先是 onCancel,然后是 onOpenChange,列表关闭时是 onConfirm,列表显示或消失之后是 onOpen 或 onClose。
#部件
| 部件 | 位置 | 样式 |
|---|---|---|
| 触发元素 | 组合框的视图 | 应用自己的元素(trigger) |
| 清除按钮 | 组合框的视图,触发元素之后(cleanable,有已选条目时) | clearButtonClass、clearButtonStyle;内容 clearButton |
| 弹出层或 sheet | 在一切之上,位于触发元素处(桌面平台)或底部(触屏设备) | popupClass、popupStyle |
| 背景层 | sheet 后面(触屏设备) | backdropClass、backdropStyle |
| 搜索框 | 弹出层中的第一个(searchable) | searchClass、searchStyle、searchPlaceholder |
| 列表 | 弹出层中,搜索框之后 | listClass、listStyle |
| 分组标题 | 列表中,它的条目之前 | groupClass、groupStyle、renderGroup |
| 条目的行 | 列表中 | itemClass、itemStyle、renderItem |
| 空状态 | 弹出层中,没有条目显示时 | 它自己的元素(empty) |
| 底部区域 | 弹出层中的最后一个 | footerClass、footerStyle;内容 footer |
#键盘
按键与选择框的相同(见键盘),区别在于:在 multiple 时,Return 和 Space 切换高亮的条目并保持列表打开;Mac 上的 Cmd+Return(其他平台为 Ctrl+Return)关闭列表并确认;关闭时的触发元素不支持输入跳转(它的按键只用来打开列表)。
#无障碍
- 触发元素对屏幕阅读器来说是一个组合框(combobox):它的标签是
accessibilityLabel,值是已选条目的标题,以逗号连接,并说明列表是否展开。disabled时它显示为不可用。 - 列表是一个列表框(listbox),每一行是一个选项,以其标题命名;已选的选项为已选状态,不可选的显示为不可用。分组标题是标题。
- 清除按钮是一个按钮,名称来自
clearButtonAccessibilityLabel;它不是 Tab 的一站。
关于标签、角色和播报的一般说明,见无障碍。