Элементы
Combobox
Combobox выбирает значения из списка элементов: одно или, с multiple, несколько. Например, теги записи, участников канала, языки документа. Его триггер — поле ввода: набор текста открывает список и фильтрует его, как это делает combobox в shadcn/ui. Это родственник 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, crate nsh-base на Rust. В Rust combobox() создаёт его с сеттером для каждого свойства (.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, нажатие снаружи, кнопка-триггер), со значением в том виде, в каком его оставил пользователь. Cmd+Return на Mac (Ctrl+Return на других платформах) закрывает список и подтверждает выбор.
cleanable добавляет кнопку очистки, пока выбран хотя бы один элемент: нажатие опустошает значение (onChange({ value: [] })). Кнопка находится после поля ввода и перед кнопкой-триггером; нажатие на неё никогда не открывает список. Содержимое задаёт приложение, как у кнопки очистки в Select.
#Поле ввода
Триггер — это текстовое поле в строке combobox (её class и style: рамка и заливка поля), перед которым находится prefix приложения (выбранные элементы в виде чипов, иконка), а после него — кнопка очистки, triggerButton приложения (шеврон) и suffix.
- Набор текста открывает список и фильтрует элементы по заголовку без учёта регистра; пока ничего не найдено, показывается
empty. Стрелки вниз и вверх тоже открывают список, нажатие на кнопку-триггер открывает и закрывает его, а на сенсорных экранах его открывает касание поля ввода. - При одном значении поле ввода показывает заголовок выбранного элемента (
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:, пока в нём редактируют текст, disabled: вместе с combobox. |
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: строки (group в itemClass), поэтому перемещение подсветки или переключение ничего не перерисовывает; в остальных случаях показывается заголовок элемента. |
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: внутри, под group в itemClass) |
| Пустое состояние | всплывающее окно, пока нет ни одного элемента | собственный элемент (empty) |
| Нижний блок | всплывающее окно, последним | footerClass, footerStyle; его содержимое footer |
#Клавиатура
Клавиши такие же, как у Select (см. Клавиатура), пока фокус остаётся в поле ввода, со следующими отличиями: буквы и цифры вводятся в поле ввода и фильтруют список, а не работают как быстрый поиск по первым буквам; стрелки вниз и вверх открывают закрытый список; Return с multiple переключает подсвеченный элемент и оставляет список открытым; Cmd+Return на Mac (Ctrl+Return на других платформах) закрывает его и подтверждает выбор. Когда список закрывается на десктопах, фокус остаётся в поле ввода.
#Доступность
- Для скринридеров поле ввода является combobox: его метка —
accessibilityLabel, его значение — заголовки выбранных элементов через запятую, и оно сообщает, развёрнут ли список.disabledделает его неактивным. - Список является listbox, а каждая строка — option с именем, равным её заголовку; выбранные варианты отмечены как выбранные, а отключённый показан неактивным. Заголовки секций являются заголовками.
- Кнопка очистки — это кнопка с именем
clearButtonAccessibilityLabel; она не является остановкой при навигации клавишей Tab. Кнопка-триггер скрыта от скринридеров, так как поле ввода сообщает, открыт ли список, и тоже не является остановкой при навигации клавишей Tab.
Общие сведения о метках, ролях и объявлениях см. в разделе Доступность.