Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Combobox

Un combobox permette di scegliere valori da un elenco di elementi, uno solo o, con multiple, più di uno: i tag di un post, i membri di un canale, le lingue di un documento. Il suo trigger è un input: digitare apre l’elenco e lo filtra, come fa il combobox di shadcn/ui. È il gemello di Select e ne condivide l’elenco, presentato come ciascun dispositivo se lo aspetta:

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);
  • Su desktop e nei browser desktop, un popup sotto il campo, largo quanto il campo (popupStyle / popupClass dell’app ne impostano un’altra) e alto quanto le sue righe, fino allo spazio disponibile accanto.
  • Su telefoni e tablet, un foglio in fondo allo schermo sopra un backdrop.

Il combobox gestisce il comportamento: il valore (quello dell’app o il proprio), lo stato di apertura, la tastiera, la ricerca, le sezioni, la riga evidenziata e ciò che sentono gli screen reader. Con multiple, una scelta attiva o disattiva l’elemento nel valore e l’elenco resta aperto, così se ne possono scegliere diversi in un colpo solo. L’aspetto è dell’app: ne definisce lo stile del campo, del popup, dell’elenco, delle righe, delle intestazioni delle sezioni, dell’input e del footer, oppure disegna le righe da sé.

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 proviene da nsh/base, il crate nsh-base in Rust. In Rust, combobox() ne crea uno con un setter per ogni prop (.items(..), .value(..), .multiple(..)) e un listener per ogni evento (.on_change(..)). I suoi elementi sono quelli di Select: SelectItem::new(value, title), con .disabled(..) e .group(..), in sezioni come quelli di una select (vedi Elementi).

#Valore

Il valore è un array dei valori degli elementi selezionati. Con value, il valore appartiene all’app: il combobox mostra quegli elementi come selezionati e segnala ogni modifica con onChange({ value }); l’app imposta value per seguirla, oppure tiene il proprio valore. Se omesso, il combobox tiene un valore proprio, inizialmente preso da defaultValue.

  • Con multiple, una pressione, Invio o Spazio attiva o disattiva l’elemento evidenziato: nel valore se era fuori, fuori se era dentro. L’elenco resta aperto.
  • Senza, una scelta sostituisce il valore con quello dell’elemento e chiude l’elenco; scegliere di nuovo l’elemento selezionato si limita a chiuderlo.

onConfirm({ value }) comunica il valore alla chiusura dell’elenco: una volta per apertura, comunque si chiuda (una scelta, Escape, una pressione all’esterno, il pulsante trigger), con il valore così come l’utente lo ha lasciato. Cmd+Invio su Mac (Ctrl+Invio altrove) chiude l’elenco e conferma.

cleanable aggiunge un pulsante di cancellazione finché un elemento è selezionato: una pressione svuota il valore (onChange({ value: [] })). Il pulsante viene dopo l’input e prima del pulsante trigger; una pressione su di esso non apre mai l’elenco. Il contenuto lo fornisce l’app, come per il pulsante di cancellazione di una select.

#Input

Il trigger è un input di testo nella riga del combobox (class e style: il bordo e lo sfondo del campo), con il prefix dell’app prima di esso (gli elementi scelti come chip, un’icona) e, dopo, il pulsante di cancellazione, il triggerButton dell’app (un chevron) e suffix.

  • Digitare apre l’elenco e filtra gli elementi per titolo, senza distinguere maiuscole e minuscole; empty compare finché nulla corrisponde. Anche Giù e Su lo aprono, una pressione sul pulsante trigger lo apre e lo chiude, e sui touch screen un tap sull’input lo apre.
  • Con un solo valore, l’input mostra il titolo dell’elemento scelto (placeholder finché non ce n’è uno). Viene selezionato quando l’input riceve il focus, così ciò che si digita lo sostituisce.
  • Con multiple, l’input mostra la query; l’app mostra gli elementi scelti, in prefix o accanto al combobox.

La query è del combobox, e a ogni apertura parte da defaultQuery (altrimenti vuota), oppure dell’app con query: onQueryChange({ query }) segnala ciò che viene digitato e l’app imposta query per seguirlo, per filtrare a modo suo o per suggerire un punto di partenza.

footer è il contenuto sotto le righe, in una parte a sé (footerClass, footerStyle): un’azione (“Gestisci tag”), un conteggio, un suggerimento. Resta fermo mentre le righe scorrono.

#Props

PropRustTipoPredefinitoDescrizione
items.items(..)SelectItem[][]Gli elementi, in ordine.
multiple.multiple(..)booleanfalseUna scelta attiva o disattiva l’elemento nel valore e l’elenco resta aperto.
value.value(..)string[]nessunoI valori degli elementi selezionati, gestiti dall’app.
defaultValue.default_value(..)string[][]Il valore iniziale, senza value.
open, defaultOpen.open(..), .default_open(..)booleanfalseLo stato di apertura, dell’app o del combobox stesso.
placeholder.placeholder(..)stringnessunoIl placeholder dell’input, finché è vuoto.
query.query(..)stringnessunoLa query, gestita dall’app.
defaultQuery.default_query(..)stringnessunoLa query con cui parte ogni apertura, senza query.
cleanable.cleanable(..)booleanfalseUn pulsante di cancellazione finché un elemento è selezionato; una pressione svuota il valore.
disabled.disabled(..)booleanfalseResta chiuso e non accetta pressioni né focus da tastiera.
accessibilityLabel.accessibility_label(..)stringnessunoDi cosa tratta la scelta, per gli screen reader.
placement, align, offset, margin.placement(..), …come quelli di Select"bottom", "start", 0, 0Desktop: dove va il popup rispetto al campo.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>nessunoIl popup (desktop) o il foglio (touch screen).
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>nessunoIl backdrop dietro il foglio.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>nessunoL’elenco, la scroll view delle righe.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>nessunoLa riga di ogni elemento: focus: mentre è evidenziata, selected: mentre è selezionato, disabled: mentre non può esserlo.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<ViewStyle>nessunoL’intestazione di ogni sezione.
inputClass, inputStyle.input_class(..), .input_style(..)string, StyleProp<InputStyle>nessunoL’input: focus: mentre è in modifica, disabled: con il combobox.
footerClass, footerStyle.footer_class(..), .footer_style(..)string, StyleProp<ViewStyle>nessunoIl footer, sotto le righe.
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>nessunoIl pulsante di cancellazione (cleanable).
clearButton.clear_button(..)JSX.ElementnessunoIl contenuto del pulsante di cancellazione.
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)stringnessunoCome gli screen reader chiamano il pulsante di cancellazione.
triggerButton.trigger_button(..)JSX.ElementnessunoUn pulsante dopo l’input (un chevron): una pressione apre e chiude l’elenco.
prefix.prefix(..)JSX.ElementnessunoContenuto prima dell’input: chip, un’icona.
suffix.suffix(..)JSX.ElementnessunoContenuto dopo il pulsante trigger.
empty.empty(..)JSX.ElementnessunoCiò che l’elenco mostra finché nessun elemento è visibile.
footer.footer(..)JSX.ElementnessunoCiò che compare sotto le righe.
renderItem.render_item(..)(item: SelectEntry) => JSX.ElementnessunoLa riga di un elemento, disegnata dall’app a partire da ciò che l’elemento è, mai dal suo stato: il contenuto segue group-focus:, group-selected: e group-disabled: della riga (group in itemClass), quindi uno spostamento dell’evidenziazione o un’attivazione/disattivazione non ridisegna nulla; altrimenti, il titolo dell’elemento.
renderGroup.render_group(..)(group: SelectGroup) => JSX.ElementnessunoL’intestazione di una sezione, disegnata dall’app; altrimenti, il suo titolo.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoIl campo: la riga del prefisso, dell’input, dei pulsanti e del suffisso; l’elenco è largo quanto esso.

#Eventi

EventoRustDescrizione
onChange.on_change(|value| ..)Il valore è cambiato (una scelta, un’attivazione/disattivazione, il pulsante di cancellazione): { value }, i valori degli elementi selezionati. Imposta value per seguirlo.
onConfirm.on_confirm(|value| ..)L’elenco si è chiuso: { value } così com’è in quel momento, una volta per apertura.
onQueryChange.on_query_change(|query| ..)L’utente ha digitato nell’input: { query }. Imposta query per seguirlo.
onOpenChange.on_open_change(|e| ..)L’elenco si è aperto o chiuso, con il motivo ({ open, reason }). Imposta open per seguirlo.
onOpen, onClose.on_open(..), .on_close(..)L’elenco è mostrato, o è scomparso.
onCancel.on_cancel(..)L’utente ha chiuso l’elenco senza una scelta che lo chiuda (Escape o indietro, una pressione all’esterno o sul backdrop).

Arrivano in quest’ordine: onChange per una scelta, o onCancel per una chiusura senza scelta, poi onOpenChange, poi onConfirm alla chiusura dell’elenco, poi onOpen o onClose quando l’elenco è mostrato o scomparso.

#Parti

ParteDoveStile
Il campola view del combobox, una rigaclass, style
Il prefissoil campo, per primol’elemento proprio dell’app (prefix)
L’inputil campo, dopo il prefissoinputClass, inputStyle, placeholder
Il pulsante di cancellazioneil campo, dopo l’input (cleanable, finché un elemento è selezionato)clearButtonClass, clearButtonStyle; il suo contenuto clearButton
Il pulsante triggeril campo, dopo il pulsante di cancellazionel’elemento proprio dell’app (triggerButton)
Il suffissoil campo, per ultimol’elemento proprio dell’app (suffix)
Il popup o il fogliosopra tutto, sotto il campo (desktop) o in fondo (touch screen)popupClass, popupStyle
Il backdropdietro il foglio (touch screen)backdropClass, backdropStyle
L’elencoil popuplistClass, listStyle
L’intestazione di una sezionel’elenco, prima dei suoi elementigroupClass, groupStyle, renderGroup
La riga di un elementol’elencoitemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: all’interno, sotto group in itemClass)
Lo stato vuotoil popup, finché nessun elemento è visibileun elemento proprio (empty)
Il footeril popup, per ultimofooterClass, footerStyle; il suo contenuto footer

#Tastiera

I tasti sono quelli di una select (vedi Tastiera) finché l’input mantiene il focus, con queste differenze: lettere e cifre vengono digitate nell’input, che filtra l’elenco, anziché fare typeahead; Giù e Su aprono l’elenco chiuso; Invio attiva o disattiva l’elemento evidenziato con multiple e mantiene l’elenco aperto; Cmd+Invio su Mac (Ctrl+Invio altrove) lo chiude e conferma. Quando l’elenco si chiude su desktop, l’input mantiene il focus.

#Accessibilità

  • Per gli screen reader l’input è un combobox: la sua etichetta è accessibilityLabel, il suo valore i titoli degli elementi selezionati, separati da virgole, e indica se l’elenco è espanso. disabled lo attenua.
  • L’elenco è un listbox e ogni riga un’opzione, chiamata con il suo titolo; le opzioni selezionate risultano selezionate e quelle disabilitate attenuate. Le intestazioni delle sezioni sono intestazioni.
  • Il pulsante di cancellazione è un pulsante chiamato con clearButtonAccessibilityLabel; non è una tappa del Tab. Il pulsante trigger è nascosto agli screen reader, poiché l’input indica se l’elenco è aperto, e non è nemmeno una tappa del Tab.

Vedi Accessibilità per etichette, ruoli e annunci in generale.