Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

Combobox

Eine Combobox wählt Werte aus einer Liste von Items aus, einen oder, mit multiple, mehrere: die Tags eines Beitrags, die Mitglieder eines Kanals, die Sprachen eines Dokuments. Ihr Trigger ist ein Input: Tippen öffnet die Liste und filtert sie, wie bei der Combobox von shadcn/ui. Sie ist das Schwesterelement von Select und teilt sich dessen Liste, dargestellt so, wie jedes Gerät es erwartet:

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);
  • Auf Desktops und in Desktop-Browsern ein Popup unter dem Feld, so breit wie das Feld (eine andere Breite legt popupStyle / popupClass der App fest) und so hoch wie seine Zeilen, bis zum verfügbaren Platz daneben.
  • Auf Smartphones und Tablets ein Sheet am unteren Bildschirmrand über einem Backdrop.

Die Combobox besitzt das Verhalten: den Wert (den der App oder ihren eigenen), den Open-Zustand, die Tastatur, die Suche, die Sections, die hervorgehobene Zeile und das, was Screenreader ansagen. Mit multiple schaltet eine Auswahl das Item im Wert um, und die Liste bleibt offen, sodass sich mehrere Items in einem Zug auswählen lassen. Das Aussehen bestimmt die App: Sie gestaltet das Feld, das Popup, die Liste, die Zeilen, die Header der Sections, das Input und den Footer oder zeichnet die Zeilen selbst.

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 stammt aus nsh/base, dem nsh-base-Crate in Rust. In Rust erzeugt combobox() eine Combobox mit einem Setter pro Prop (.items(..), .value(..), .multiple(..)) und einem Listener pro Event (.on_change(..)). Ihre Items sind die von Select: SelectItem::new(value, title) mit .disabled(..) und .group(..), in Sections wie bei einem Select (siehe Items).

#Value

Der Wert ist ein Array der Werte der ausgewählten Items. Mit value gehört er der App: Die Combobox zeigt diese Items als ausgewählt an und meldet jede Änderung mit onChange({ value }); die App setzt value, um ihr zu folgen, oder behält ihren eigenen Wert. Wird er weggelassen, verwaltet die Combobox ihren eigenen Wert, zunächst aus defaultValue.

  • Mit multiple schaltet ein Klick, Return oder Space das hervorgehobene Item um: in den Wert hinein, wenn es draußen war, heraus, wenn es drin war. Die Liste bleibt offen.
  • Ohne dies ersetzt eine Auswahl den Wert durch den des Items und schließt die Liste; wird das ausgewählte Item erneut gewählt, schließt sie sich nur.

onConfirm({ value }) meldet den Wert, wenn die Liste sich schließt: einmal pro Öffnung, egal wie sie sich schließt (Auswahl, Escape, Klick außerhalb, Trigger-Button), mit dem Wert, wie der Nutzer ihn hinterlassen hat. Cmd+Return auf einem Mac (anderswo Ctrl+Return) schließt die Liste und bestätigt.

cleanable fügt einen Clear-Button hinzu, solange ein Item ausgewählt ist: Ein Klick leert den Wert (onChange({ value: [] })). Der Button steht nach dem Input und vor dem Trigger-Button; ein Klick darauf öffnet die Liste nie. Die App füllt ihn, wie bei dem Clear-Button eines Select.

#Input

Der Trigger ist ein Texteingabefeld in der Zeile der Combobox (mit class und style: Rahmen und Füllung des Felds), mit dem prefix der App davor (die gewählten Items als Chips, ein Icon) und danach dem Clear-Button, dem triggerButton der App (ein Chevron) und suffix.

  • Tippen öffnet die Liste und filtert die Items nach Titel, ohne Beachtung der Groß- und Kleinschreibung; empty erscheint, solange nichts passt. Pfeil nach unten und Pfeil nach oben öffnen sie ebenfalls, ein Klick auf den Trigger-Button öffnet und schließt sie, und auf Touchscreens öffnet ein Tipp auf das Input die Liste.
  • Bei einem einzelnen Wert zeigt das Input den Titel des gewählten Items (placeholder, solange keines gewählt ist). Er wird markiert, wenn das Input den Fokus erhält, sodass getippter Text ihn ersetzt.
  • Mit multiple zeigt das Input die Query; die App zeigt die gewählten Items, in prefix oder neben der Combobox.

Die Query gehört der Combobox selbst und beginnt jede Öffnung mit defaultQuery (sonst leer), oder sie gehört der App mit query: onQueryChange({ query }) meldet, was getippt wird, und die App setzt query, um dem zu folgen, auf ihre eigene Weise zu filtern oder einen Startpunkt vorzuschlagen.

footer ist Inhalt unterhalb der Zeilen, in einem eigenen Part (footerClass, footerStyle): eine Aktion („Manage tags“), eine Anzahl, ein Hinweis. Er bleibt an Ort und Stelle, während die Zeilen scrollen.

#Props

Prop Rust Typ Standard Beschreibung
items .items(..) SelectItem[] [] Die Items, in Reihenfolge.
multiple .multiple(..) boolean false Eine Auswahl schaltet das Item im Wert um, und die Liste bleibt offen.
value .value(..) string[] keiner Die Werte der ausgewählten Items, im Besitz der App.
defaultValue .default_value(..) string[] [] Der anfängliche Wert, ohne value.
open, defaultOpen .open(..), .default_open(..) boolean false Der Open-Zustand, der der App oder der eigene der Combobox.
placeholder .placeholder(..) string keiner Der Platzhalter des Inputs, solange es leer ist.
query .query(..) string keine Die Query, im Besitz der App.
defaultQuery .default_query(..) string keine Die Query, mit der jede Öffnung beginnt, ohne query.
cleanable .cleanable(..) boolean false Ein Clear-Button, solange ein Item ausgewählt ist; ein Klick leert den Wert.
disabled .disabled(..) boolean false Sie bleibt geschlossen und nimmt weder Klicks noch Tastaturfokus an.
accessibilityLabel .accessibility_label(..) string keiner Worum es bei der Auswahl geht, für Screenreader.
placement, align, offset, margin .placement(..), … wie bei Select "bottom", "start", 0, 0 Desktops: wo das Popup relativ zum Feld platziert wird.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> keine Das Popup (Desktops) oder das Sheet (Touchscreens).
backdropClass, backdropStyle .backdrop_class(..), .backdrop_style(..) string, StyleProp<ViewStyle> keine Der Backdrop hinter dem Sheet.
listClass, listStyle .list_class(..), .list_style(..) string, StyleProp<ViewStyle> keine Die Liste, die Scroll-View der Zeilen.
itemClass, itemStyle .item_class(..), .item_style(..) string, StyleProp<TextStyle> keine Die Zeile jedes Items: focus:, solange sie hervorgehoben ist, selected:, solange sie ausgewählt ist, disabled:, solange sie es nicht sein kann.
groupClass, groupStyle .group_class(..), .group_style(..) string, StyleProp<ViewStyle> keine Der Header jeder Section.
inputClass, inputStyle .input_class(..), .input_style(..) string, StyleProp<InputStyle> keine Das Input: focus:, solange es bearbeitet wird, disabled: mit der Combobox.
footerClass, footerStyle .footer_class(..), .footer_style(..) string, StyleProp<ViewStyle> keine Der Footer, unterhalb der Zeilen.
clearButtonClass, clearButtonStyle .clear_button_class(..), .clear_button_style(..) string, StyleProp<ViewStyle> keine Der Clear-Button (cleanable).
clearButton .clear_button(..) JSX.Element keiner Der Inhalt des Clear-Buttons.
clearButtonAccessibilityLabel .clear_button_accessibility_label(..) string keiner Wie Screenreader den Clear-Button benennen.
triggerButton .trigger_button(..) JSX.Element keiner Ein Button nach dem Input (ein Chevron): Ein Klick öffnet und schließt die Liste.
prefix .prefix(..) JSX.Element keiner Inhalt vor dem Input: Chips, ein Icon.
suffix .suffix(..) JSX.Element keiner Inhalt nach dem Trigger-Button.
empty .empty(..) JSX.Element keiner Was die Liste zeigt, solange kein Item sichtbar ist.
footer .footer(..) JSX.Element keiner Was unterhalb der Zeilen erscheint.
renderItem .render_item(..) (item: SelectEntry) => JSX.Element keiner Die Zeile eines Items, von der App aus dem gezeichnet, was das Item ist, nie aus seinem Zustand: Der Inhalt folgt group-focus:, group-selected: und group-disabled: der Zeile (group in itemClass), sodass das Verschieben der Hervorhebung oder ein Umschalten nichts neu zeichnet; sonst der Titel des Items.
renderGroup .render_group(..) (group: SelectGroup) => JSX.Element keiner Der Header einer Section, von der App gezeichnet; sonst ihr Titel.
class, style Style-Methoden string, StyleProp<ViewStyle> keine Das Feld: die Zeile aus Prefix, Input, Buttons und Suffix; die Liste ist so breit wie sie.

#Events

Event Rust Beschreibung
onChange .on_change(|value| ..) Der Wert hat sich geändert (eine Auswahl, ein Umschalten, der Clear-Button): { value }, die Werte der ausgewählten Items. Setze value, um dem zu folgen.
onConfirm .on_confirm(|value| ..) Die Liste wurde geschlossen: { value } so, wie er dann ist, einmal pro Öffnung.
onQueryChange .on_query_change(|query| ..) Der Nutzer hat im Input getippt: { query }. Setze query, um dem zu folgen.
onOpenChange .on_open_change(|e| ..) Die Liste wurde geöffnet oder geschlossen, mit dem Grund ({ open, reason }). Setze open, um dem zu folgen.
onOpen, onClose .on_open(..), .on_close(..) Die Liste wird angezeigt oder ist verschwunden.
onCancel .on_cancel(..) Der Nutzer hat die Liste ohne eine Auswahl geschlossen, die sie schließt (Escape oder Zurück, ein Klick außerhalb oder auf den Backdrop).

Sie treten in dieser Reihenfolge auf: onChange bei einer Auswahl oder onCancel bei einem Schließen ohne Auswahl, dann onOpenChange, dann onConfirm, wenn die Liste sich schließt, dann onOpen oder onClose, sobald die Liste angezeigt wird oder verschwunden ist.

#Parts

Part Wo Gestaltet mit
Das Feld die View der Combobox, eine Zeile class, style
Das Prefix im Feld, als Erstes das eigene Element der App (prefix)
Das Input im Feld, nach dem Prefix inputClass, inputStyle, placeholder
Der Clear-Button im Feld, nach dem Input (cleanable, solange ein Item ausgewählt ist) clearButtonClass, clearButtonStyle; sein Inhalt clearButton
Der Trigger-Button im Feld, nach dem Clear-Button das eigene Element der App (triggerButton)
Das Suffix im Feld, als Letztes das eigene Element der App (suffix)
Das Popup oder Sheet über allem, unter dem Feld (Desktops) oder am unteren Rand (Touchscreens) popupClass, popupStyle
Der Backdrop hinter dem Sheet (Touchscreens) backdropClass, backdropStyle
Die Liste das Popup listClass, listStyle
Der Header einer Section die Liste, vor ihren Items groupClass, groupStyle, renderGroup
Die Zeile eines Items die Liste itemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: darin, unter group in itemClass)
Der Empty-Zustand das Popup, solange kein Item sichtbar ist sein eigenes Element (empty)
Der Footer das Popup, als Letztes footerClass, footerStyle; sein Inhalt footer

#Tastatur

Die Tasten sind die eines Select (siehe Tastatur), solange das Input den Fokus behält, mit diesen Unterschieden: Buchstaben und Ziffern werden ins Input getippt und filtern die Liste, statt Typeahead auszulösen; Pfeil nach unten und Pfeil nach oben öffnen die geschlossene Liste; Return schaltet mit multiple das hervorgehobene Item um und lässt die Liste offen; Cmd+Return auf einem Mac (anderswo Ctrl+Return) schließt sie und bestätigt. Wenn sich die Liste auf Desktops schließt, behält das Input den Fokus.

#Barrierefreiheit

  • Das Input ist für Screenreader eine Combobox: Ihr Label ist accessibilityLabel, ihr Wert sind die Titel der ausgewählten Items, durch Kommas getrennt, und sie gibt an, ob die Liste aufgeklappt ist. disabled dimmt sie.
  • Die Liste ist eine Listbox und jede Zeile eine Option, benannt nach ihrem Titel; die ausgewählten Optionen sind als ausgewählt markiert und eine deaktivierte als gedimmt. Die Header der Sections sind Header.
  • Der Clear-Button ist ein Button, benannt durch clearButtonAccessibilityLabel; er ist kein Tab-Stopp. Der Trigger-Button ist vor Screenreadern verborgen, da das Input angibt, ob die Liste offen ist, und ist ebenfalls kein Tab-Stopp.

Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.