元素
Combobox
Combobox 從項目列表中選取值,可選一個,或在 multiple 下選取多個:文章的標籤、頻道的成員、文件的語言。它的觸發元件是一個輸入框:輸入文字會開啟並篩選列表,與 shadcn/ui 的 combobox 一樣。它是 Select 的姊妹元件,並共用其列表,按各種裝置的習慣呈現:
import { type JSX, mount, signal, Text, View } from "nsh";
import { Combobox, type OpenChangeReason, type SelectEntry, type SelectGroup } from "nsh/base";
import { CheckMark, desktop, Icon, Log, Panel, size, Split, 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
// field: the input typing filters the list with, 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 field: a bordered box around the input, the clear button and the chevron. */
const fieldStyle = (open: boolean) => ({
flexDirection: "row" as const,
alignItems: "center" as const,
minHeight: size.control,
paddingLeft: 10,
paddingRight: 4,
borderRadius: 8,
borderWidth: 1,
borderColor: open ? ui().faint : ui().border,
backgroundColor: ui().card.backgroundColor,
...(desktop ? { width: 260 } : {}),
});
const inputClass = () => `${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`;
const inputStyle = { flex: 1, minWidth: 40, height: size.control - 2 };
/** The trigger button: a chevron that turns while the list is open. */
function Chevron(props: { open: boolean }) {
return (
<View
class="transition-transform duration-150 motion-reduce:transition-none"
style={{ width: 24, height: 24, alignItems: "center", justifyContent: "center", transform: [{ rotate: props.open ? "270deg" : "90deg" }] }}
>
<Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
</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, the check in its own lane (`group-selected:`). */
function Row(props: { item: SelectEntry }) {
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 style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>{props.item.title}</Text>
<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 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"
style={fieldStyle(labelsOpen())}
placeholder={labelTitle() ?? "Add labels"}
inputClass={inputClass()}
inputStyle={inputStyle}
triggerButton={<Chevron open={labelsOpen()} />}
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={rowClass}
cleanable
clearButtonAccessibilityLabel="Clear the labels"
clearButtonClass="items-center justify-center rounded-full enabled:hover:opacity-70"
clearButtonStyle={{ width: 20, height: 20 }}
clearButton={<Icon name="close" size={14} tint={ui().muted} />}
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>
}
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={rowClass}
style={fieldStyle(assigneeOpen())}
placeholder="Assign someone"
inputClass={inputClass()}
inputStyle={inputStyle}
triggerButton={<Chevron open={assigneeOpen()} />}
renderItem={(item) => <Row item={item} />}
/>
</Field>
</View>
</Split>
</View>
);
}
mount(App);- 在桌面平台和桌面瀏覽器上,是欄位下方的彈出視窗,寬度與欄位相同(應用程式的
popupStyle/popupClass寬度可另行設定),高度依列數而定,最高為旁邊可用的空間。 - 在手機和平板上,是螢幕底部的面板,下方有背景遮罩。
Combobox 負責行為:值(由應用程式持有,或由它自己持有)、開啟狀態、鍵盤操作、搜尋、分組、高亮的列,以及螢幕閱讀器所讀出的內容。使用 multiple 時,選擇某個項目會在值中切換該項目,列表保持開啟,因此可以一次選取多個。外觀由應用程式決定:它為欄位、彈出視窗、列表、列、分組標題、輸入框和頁腳設定樣式,或自行繪製各列。
use nsh_base::{SelectEntry, SelectItem, 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)
.placeholder(title)
.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()
})
.h_9()
.w_48()
.px_3()
.flex_row()
.items_center()
.rounded_md()
.border_1()
.border_color(move || theme().input)
.input_style(|s| s.flex_1().text_sm())
.trigger_button(text("⌄"))
.item_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
.render_item(|item: &SelectEntry| view().px_2().py_1p5().child(text(item.title.clone()).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
placeholder={tags().length ? `${tags().length} tags` : "Add tags"}
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"
class="h-9 w-48 flex-row items-center rounded-md border border-input px-3"
inputClass="flex-1 text-sm placeholder:text-muted-foreground"
itemClass="group rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground"
triggerButton={<Text class="px-1 text-muted-foreground">⌄</Text>}
renderItem={(item) => (
<View class="flex-row justify-between">
<Text>{item.title}</Text>
<Text class="opacity-0 group-selected:opacity-100">✓</Text>
</View>
)}
/>
);
}
mount(App);Combobox 來自 nsh/base,即 Rust 中的 nsh-base crate。在 Rust 中,combobox() 會建立一個 combobox,每個屬性有一個 setter(.items(..)、.value(..)、.multiple(..)),每個事件有一個監聽器(.on_change(..))。其項目與 Select 的相同:SelectItem::new(value, title),帶有 .disabled(..) 和 .group(..),可如 select 般分組(見項目)。
#值
值是由已選項目的值組成的陣列。使用 value 時,值由應用程式持有:combobox 會將這些項目顯示為已選,並以 onChange({ value }) 回報每次變更;應用程式把 value 設為相應的值以跟隨變更,或自行保留一份值。
若省略,combobox 會自行保留值,起始值來自 defaultValue。
- 使用
multiple時,按下、Return 或 Space 會切換高亮的項目:原本不在值中就加入,原本已在值中就移除。列表保持開啟。 - 不使用時,選擇會以該項目的值取代原有的值並關閉列表;再次選擇已選的項目只會關閉列表。
onConfirm({ value }) 會在列表關閉時告知值:每次開啟只觸發一次,無論以何種方式關閉(選擇、Escape、在外部按下、觸發按鈕),所帶的值都是用戶最終留下的值。在 Mac 上按 Cmd+Return(其他平台為 Ctrl+Return)會關閉列表並確認。
cleanable 會在有項目被選取時加入清除按鈕:按下會清空值(onChange({ value: [] }))。
該按鈕位於輸入框之後、觸發按鈕之前;按下它不會開啟列表。內容由應用程式填入,方式與 select 的清除按鈕相同。
#輸入框
觸發元件是 combobox 所在列中的文字輸入框(其 class 和 style:欄位的邊框和填色),前面是應用程式的 prefix(以標籤形式顯示已選項目、圖示),後面依次是清除按鈕、應用程式的 triggerButton(箭咀圖示)和 suffix。
- 輸入文字會開啟列表,並按標題篩選項目,不分大小寫;沒有任何項目符合時會顯示
empty。按 Down 和 Up 同樣會開啟列表,按觸發按鈕會開啟和關閉列表,在觸控螢幕上,輕按輸入框會開啟列表。 - 只有一個值時,輸入框會顯示已選項目的標題(沒有選取時顯示
placeholder)。輸入框獲得焦點時,內容會被選取,因此輸入的文字會取代它。 - 使用
multiple時,輸入框顯示查詢文字;已選項目由應用程式顯示,可放在prefix中或 combobox 旁邊。
查詢文字可以由 combobox 自行持有,每次開啟時從 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 |
開啟狀態,由應用程式或 combobox 自行持有。 |
placeholder |
.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> |
無 | 彈出視窗(桌面平台)或面板(觸控螢幕)。 |
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> |
無 | 每個分組的標題。 |
inputClass、inputStyle |
.input_class(..)、.input_style(..) |
string、StyleProp<InputStyle> |
無 | 輸入框:編輯時為 focus:,隨 combobox 一起時為 disabled:。 |
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 |
無 | 螢幕閱讀器對清除按鈕的稱呼。 |
triggerButton |
.trigger_button(..) |
JSX.Element |
無 | 輸入框後的按鈕(箭咀圖示):按下會開啟和關閉列表。 |
prefix |
.prefix(..) |
JSX.Element |
無 | 輸入框前的內容:標籤、圖示。 |
suffix |
.suffix(..) |
JSX.Element |
無 | 觸發按鈕後的內容。 |
empty |
.empty(..) |
JSX.Element |
無 | 沒有任何項目可顯示時,列表所顯示的內容。 |
footer |
.footer(..) |
JSX.Element |
無 | 顯示於各列下方的內容。 |
renderItem |
.render_item(..) |
(item: SelectEntry) => JSX.Element |
無 | 由應用程式根據項目本身(而非其狀態)繪製的列:內容跟隨該列的 group-focus:、group-selected: 和 group-disabled:(itemClass 中的 group),因此高亮移動或切換選取時不會重新繪製;否則顯示項目的標題。 |
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。
#組成部分
| 部分 | 位置 | 樣式設定 |
|---|---|---|
| 欄位 | combobox 的視圖,一列 | class、style |
| 前綴 | 欄位,最前面 | 應用程式自己的元素(prefix) |
| 輸入框 | 欄位,前綴之後 | inputClass、inputStyle、placeholder |
| 清除按鈕 | 欄位,輸入框之後(cleanable,有項目被選取時) |
clearButtonClass、clearButtonStyle;其內容為 clearButton |
| 觸發按鈕 | 欄位,清除按鈕之後 | 應用程式自己的元素(triggerButton) |
| 後綴 | 欄位,最後面 | 應用程式自己的元素(suffix) |
| 彈出視窗或面板 | 位於所有內容之上,在欄位下方(桌面平台)或螢幕底部(觸控螢幕) | popupClass、popupStyle |
| 背景遮罩 | 面板後方(觸控螢幕) | backdropClass、backdropStyle |
| 列表 | 彈出視窗中 | listClass、listStyle |
| 分組標題 | 列表中,位於其項目之前 | groupClass、groupStyle、renderGroup |
| 項目的列 | 列表中 | itemClass、itemStyle、renderItem(內部的 group-focus:、group-selected:、group-disabled:,位於 itemClass 的 group 之下) |
| 空狀態 | 彈出視窗中,沒有項目顯示時 | 其自身的元素(empty) |
| 頁腳 | 彈出視窗中,最後面 | footerClass、footerStyle;其內容為 footer |
#鍵盤
輸入框保持焦點時,按鍵與 select 相同(見鍵盤),但有以下差異:字母和數字會輸入到輸入框中並篩選列表,而不是進行預先輸入定位;Down 和 Up 會開啟已關閉的列表;使用 multiple 時,Return 會切換高亮的項目並保持列表開啟;在 Mac 上按 Cmd+Return(其他平台為 Ctrl+Return)會關閉列表並確認。在桌面平台上,列表關閉後,輸入框會保持焦點。
#無障礙
- 對螢幕閱讀器而言,輸入框是一個 combobox:其標籤為
accessibilityLabel,其值為已選項目的標題(以逗號連接),並會說明列表是否展開。disabled會使它變暗。 - 列表是 listbox,每一列是一個 option,以其標題命名;已選取的 option 會標示為已選取,已停用的則顯示為變暗。分組標題是標題。
- 清除按鈕是以
clearButtonAccessibilityLabel命名的按鈕;它不是 Tab 的停駐點。觸發按鈕對螢幕閱讀器隱藏,因為輸入框已說明列表是否開啟,同樣不是 Tab 的停駐點。
有關標籤、角色和播報的一般說明,見無障礙。