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:
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/popupClassdell’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()))
}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 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;
emptycompare 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 (
placeholderfinché 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, inprefixo 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
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
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | Gli elementi, in ordine. |
multiple | .multiple(..) | boolean | false | Una scelta attiva o disattiva l’elemento nel valore e l’elenco resta aperto. |
value | .value(..) | string[] | nessuno | I valori degli elementi selezionati, gestiti dall’app. |
defaultValue | .default_value(..) | string[] | [] | Il valore iniziale, senza value. |
open, defaultOpen | .open(..), .default_open(..) | boolean | false | Lo stato di apertura, dell’app o del combobox stesso. |
placeholder | .placeholder(..) | string | nessuno | Il placeholder dell’input, finché è vuoto. |
query | .query(..) | string | nessuno | La query, gestita dall’app. |
defaultQuery | .default_query(..) | string | nessuno | La query con cui parte ogni apertura, senza query. |
cleanable | .cleanable(..) | boolean | false | Un pulsante di cancellazione finché un elemento è selezionato; una pressione svuota il valore. |
disabled | .disabled(..) | boolean | false | Resta chiuso e non accetta pressioni né focus da tastiera. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Di cosa tratta la scelta, per gli screen reader. |
placement, align, offset, margin | .placement(..), … | come quelli di Select | "bottom", "start", 0, 0 | Desktop: dove va il popup rispetto al campo. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | nessuno | Il popup (desktop) o il foglio (touch screen). |
backdropClass, backdropStyle | .backdrop_class(..), .backdrop_style(..) | string, StyleProp<ViewStyle> | nessuno | Il backdrop dietro il foglio. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | nessuno | L’elenco, la scroll view delle righe. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | nessuno | La riga di ogni elemento: focus: mentre è evidenziata, selected: mentre è selezionato, disabled: mentre non può esserlo. |
groupClass, groupStyle | .group_class(..), .group_style(..) | string, StyleProp<ViewStyle> | nessuno | L’intestazione di ogni sezione. |
inputClass, inputStyle | .input_class(..), .input_style(..) | string, StyleProp<InputStyle> | nessuno | L’input: focus: mentre è in modifica, disabled: con il combobox. |
footerClass, footerStyle | .footer_class(..), .footer_style(..) | string, StyleProp<ViewStyle> | nessuno | Il footer, sotto le righe. |
clearButtonClass, clearButtonStyle | .clear_button_class(..), .clear_button_style(..) | string, StyleProp<ViewStyle> | nessuno | Il pulsante di cancellazione (cleanable). |
clearButton | .clear_button(..) | JSX.Element | nessuno | Il contenuto del pulsante di cancellazione. |
clearButtonAccessibilityLabel | .clear_button_accessibility_label(..) | string | nessuno | Come gli screen reader chiamano il pulsante di cancellazione. |
triggerButton | .trigger_button(..) | JSX.Element | nessuno | Un pulsante dopo l’input (un chevron): una pressione apre e chiude l’elenco. |
prefix | .prefix(..) | JSX.Element | nessuno | Contenuto prima dell’input: chip, un’icona. |
suffix | .suffix(..) | JSX.Element | nessuno | Contenuto dopo il pulsante trigger. |
empty | .empty(..) | JSX.Element | nessuno | Ciò che l’elenco mostra finché nessun elemento è visibile. |
footer | .footer(..) | JSX.Element | nessuno | Ciò che compare sotto le righe. |
renderItem | .render_item(..) | (item: SelectEntry) => JSX.Element | nessuno | La 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.Element | nessuno | L’intestazione di una sezione, disegnata dall’app; altrimenti, il suo titolo. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il campo: la riga del prefisso, dell’input, dei pulsanti e del suffisso; l’elenco è largo quanto esso. |
#Eventi
| Evento | Rust | Descrizione |
|---|---|---|
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
| Parte | Dove | Stile |
|---|---|---|
| Il campo | la view del combobox, una riga | class, style |
| Il prefisso | il campo, per primo | l’elemento proprio dell’app (prefix) |
| L’input | il campo, dopo il prefisso | inputClass, inputStyle, placeholder |
| Il pulsante di cancellazione | il campo, dopo l’input (cleanable, finché un elemento è selezionato) | clearButtonClass, clearButtonStyle; il suo contenuto clearButton |
| Il pulsante trigger | il campo, dopo il pulsante di cancellazione | l’elemento proprio dell’app (triggerButton) |
| Il suffisso | il campo, per ultimo | l’elemento proprio dell’app (suffix) |
| Il popup o il foglio | sopra tutto, sotto il campo (desktop) o in fondo (touch screen) | popupClass, popupStyle |
| Il backdrop | dietro il foglio (touch screen) | backdropClass, backdropStyle |
| L’elenco | il popup | listClass, listStyle |
| L’intestazione di una sezione | l’elenco, prima dei suoi elementi | groupClass, groupStyle, renderGroup |
| La riga di un elemento | l’elenco | itemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: all’interno, sotto group in itemClass) |
| Lo stato vuoto | il popup, finché nessun elemento è visibile | un elemento proprio (empty) |
| Il footer | il popup, per ultimo | footerClass, 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.disabledlo 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.