Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Combobox

Combobox выбирает значения из списка элементов: одно или, с multiple, несколько. Например, теги записи, участников канала, языки документа. Его триггер — поле ввода: набор текста открывает список и фильтрует его, как это делает combobox в shadcn/ui. Это родственник Select, и список у них общий, но показывается так, как ожидает каждое устройство:

el-combobox.tsx
el-combobox.tsxLive
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()))
}

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(..)booleanfalseВыбор переключает элемент в значении, а список остаётся открытым.
value.value(..)string[]нетЗначения выбранных элементов, которыми владеет приложение.
defaultValue.default_value(..)string[][]Начальное значение, без value.
open, defaultOpen.open(..), .default_open(..)booleanfalseСостояние открытия: приложения или собственное состояние combobox.
placeholder.placeholder(..)stringнетПлейсхолдер поля ввода, пока оно пусто.
query.query(..)stringнетЗапрос, которым владеет приложение.
defaultQuery.default_query(..)stringнетЗапрос, с которого начинается каждое открытие, без query.
cleanable.cleanable(..)booleanfalseКнопка очистки, пока выбран хотя бы один элемент; нажатие опустошает значение.
disabled.disabled(..)booleanfalseОстаётся закрытым, не принимает нажатий и фокуса клавиатуры.
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.

Общие сведения о метках, ролях и объявлениях см. в разделе Доступность.