要素
Combobox
Combobox は、項目のリストから値を選びます。選べるのは1つですが、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 は動作を担います。具体的には、値(アプリの値または 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 クレート)に由来します。Rust では、combobox() がプロパティごとのセッター(.items(..)、.value(..)、.multiple(..))とイベントごとのリスナー(.on_change(..))を備えた Combobox を作ります。項目は Select と同じで、SelectItem::new(value, title) に .disabled(..) と .group(..) を持たせ、select と同様にセクションに分けられます(アイテムを参照)。
#値
値は、選択された項目の value の配列です。value を指定すると、値の管理はアプリが行います。Combobox はそれらの項目を選択済みとして表示し、変更のたびに onChange({ value }) で通知します。アプリはそれに追従するよう value を設定するか、独自の値を保持します。指定しない場合、Combobox は自身で値を保持し、初期値は defaultValue から取ります。
multipleを指定すると、押下、Return、Space でハイライト中の項目をトグルします。値に含まれていなければ追加し、含まれていれば取り除きます。リストは開いたままです。- 指定しない場合、選択操作は値をその項目の値に置き換えてリストを閉じます。選択済みの項目をもう一度選んだときは、リストを閉じるだけです。
onConfirm({ value }) は、リストが閉じるときに値を通知します。閉じ方(選択、Escape、外側の押下、トリガーボタン)にかかわらず、開くたびに1回、ユーザーが最終的に残した値で呼ばれます。Mac では Cmd+Return(それ以外では Ctrl+Return)でリストを閉じて確定します。
cleanable は、項目が選択されている間、クリアボタンを追加します。押すと値が空になります(onChange({ value: [] }))。ボタンは入力欄の後、トリガーボタンの前に置かれ、押してもリストは開きません。中身はアプリが用意します。select のクリアボタンと同じです。
#入力
トリガーは、Combobox の行(class と style: フィールドの枠線と塗り)の中にあるテキスト入力欄です。その前にアプリの prefix(選択された項目のチップやアイコン)があり、後ろにクリアボタン、アプリの triggerButton(シェブロンなど)、suffix が続きます。
- 入力するとリストが開き、大文字小文字を区別せずにタイトルで項目を絞り込みます。一致する項目がない間は
emptyが表示されます。Down と Up でもリストが開き、トリガーボタンを押すとリストが開閉します。タッチスクリーンでは、入力欄をタップするとリストが開きます。 - 値が1つの場合、入力欄には選択された項目のタイトルが表示されます(選択がない間は
placeholder)。入力欄にフォーカスが移ると全体が選択されるため、入力した文字がそれを置き換えます。 multipleを指定した場合、入力欄にはクエリが表示されます。選択された項目は、アプリがprefixまたは Combobox の横に表示します。
クエリは Combobox 自身が持ち、開くたびに defaultQuery(なければ空)から始めます。query を指定すると、アプリが持ちます。onQueryChange({ query }) は入力された内容を通知し、アプリはそれに追従するよう query を設定して、独自の方法で絞り込んだり、開始点を提案したりします。
#フッター
footer は、行の下にある専用のパーツ(footerClass、footerStyle)に置くコンテンツです。たとえば「Manage tags」のようなアクション、件数、ヒントなどです。行がスクロールしても、その位置に留まります。
#プロパティ
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | 項目。表示順のとおりです。 |
multiple | .multiple(..) | boolean | false | 選択操作が値の中の項目をトグルし、リストは開いたままになります。 |
value | .value(..) | string[] | なし | 選択された項目の value。アプリが管理します。 |
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 です。追従するには value を設定します。 |
onConfirm | .on_confirm(|value| ..) | リストが閉じました。{ value } はそのときの値で、開くたびに1回です。 |
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 は選択状態になり、無効な option は淡色表示になります。セクションのヘッダーは見出しとして扱われます。
- クリアボタンは
clearButtonAccessibilityLabelを名前とするボタンで、Tab の停止位置にはなりません。トリガーボタンは、リストが開いているかどうかを入力欄が伝えるため、スクリーンリーダーからは隠され、こちらも Tab の停止位置にはなりません。
ラベル、ロール、アナウンスの一般的な内容については、アクセシビリティを参照してください。