Elementi
Select
Un select permette di scegliere un valore da un elenco di elementi: un’impostazione, un filtro, un paese, un fuso orario. Il trigger dell’app apre l’elenco, presentato come ogni dispositivo si aspetta:
import { type JSX, mount, signal, Text, View } from "nsh";
import { Button, type OpenChangeReason, Select, type SelectEntry, type SelectGroup, Switch } from "nsh/base";
import { button, buttonText, CheckMark, desktop, focusRing, hoverFill, Icon, Log, Panel, size, Split, ui } from "./palette";
// Two selects in a settings card: a plain one (typeahead jumps by letter)
// and a searchable one with sections, a disabled item and an empty state.
// The trigger is the shared button look (hover, pressed, focus ring,
// disabled) with the chosen title 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. The highlighted row (the keys or the pointer) is lit with
// the accent tint, the chosen one is bolder with a check in its own lane.
const LANGUAGES = [
{ value: "en", title: "English" },
{ value: "de", title: "Deutsch" },
{ value: "es", title: "Español" },
{ value: "fr", title: "Français" },
{ value: "it", title: "Italiano" },
{ value: "nl", title: "Nederlands" },
{ value: "pl", title: "Polski" },
{ value: "pt", title: "Português" },
{ value: "sv", title: "Svenska" },
{ value: "tr", title: "Türkçe" },
{ value: "ja", title: "日本語" },
{ value: "ko", title: "한국어" },
{ value: "zh", title: "中文" },
];
const REGIONS = [
{ value: "us-east-1", title: "US East (Virginia)", group: "Americas" },
{ value: "us-west-2", title: "US West (Oregon)", group: "Americas" },
{ value: "ca-central-1", title: "Canada (Montréal)", group: "Americas" },
{ value: "sa-east-1", title: "South America (São Paulo)", group: "Americas" },
{ value: "eu-west-1", title: "Europe (Ireland)", group: "Europe" },
{ value: "eu-central-1", title: "Europe (Frankfurt)", group: "Europe" },
{ value: "eu-north-1", title: "Europe (Stockholm)", group: "Europe" },
{ value: "ap-northeast-1", title: "Asia Pacific (Tokyo)", group: "Asia Pacific" },
{ value: "ap-northeast-2", title: "Asia Pacific (Seoul)", group: "Asia Pacific", disabled: true },
{ value: "ap-southeast-1", title: "Asia Pacific (Singapore)", group: "Asia Pacific" },
{ value: "ap-east-1", title: "Asia Pacific (Hong Kong)", group: "Asia Pacific" },
{ value: "ap-southeast-2", title: "Asia Pacific (Sydney)", group: "Asia Pacific" },
];
const titleOf = (items: { value: string; title: string }[], value: string | null) => items.find((i) => i.value === value)?.title;
/** 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 trigger: the shared button's look, the chosen title at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; disabled?: boolean; clearable?: boolean }) {
const look = button("secondary", {
style: () => ({
flexDirection: "row",
justifyContent: "space-between",
gap: 8,
...(desktop ? { width: 260 } : {}),
// Open: the border stays lit while the list is up; disabled, dimmed.
...(props.open ? { borderColor: ui().faint } : {}),
...(props.disabled ? { opacity: 0.4 } : {}),
}),
});
return (
// No hover while disabled.
<View {...look} hoverStyle={props.disabled ? undefined : look.hoverStyle}>
<Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: props.title ? ui().text : ui().muted }}>
{props.title ?? props.placeholder}
</Text>
{/* With a clear button over the end (`cleanable`), an empty lane as wide as the button in place of the chevron. */}
<View
class="transition-transform duration-150 motion-reduce:transition-none"
style={{
width: props.clearable ? 20 : 10,
height: 20,
alignItems: "center",
justifyContent: "center",
opacity: props.clearable ? 0 : 1,
transform: [{ rotate: props.open ? "270deg" : "90deg" }],
}}
>
<Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
</View>
</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 (bolder while chosen), a quieter detail at the
* end, the check in its own lane (`group-selected:`).
*/
function Row(props: { item: SelectEntry; detail?: string }) {
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 class="font-normal group-selected:font-semibold" style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: ui().text }}>
{props.item.title}
</Text>
<If when={props.detail}>
<Text style={{ fontSize: 12, color: ui().faint }}>{props.detail}</Text>
</If>
<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 language = signal<string | null>("en");
const region = signal<string | null>(null);
const languageOpen = signal(false);
const regionOpen = signal(false);
const locked = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 8));
const opened = (name: string, open: (next: boolean) => void) => ({ open: next, reason }: { open: boolean; reason: OpenChangeReason }) => {
open(next);
log(`${name} ${next ? "opened" : "closed"} · ${reason}`);
};
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>select</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a select, or Tab to it and press Down; type a letter to jump" : "Tap a select to choose from a sheet"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`language ${language() ?? "null"}`}</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{`region ${region() ?? "null"}`}</Text>
<Button
disabled={region() === null}
onPress={() => region.set(null)}
{...button("secondary", { size: "sm", style: { marginTop: 6 } })}
>
<Text style={buttonText("secondary", { size: "sm" })}>Clear region</Text>
</Button>
<Switch
checked={locked()}
onChange={({ checked }) => locked.set(checked)}
class={`-mx-2 mt-1 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
trackStyle={locked() ? ui().accentFill : ui().inset}
thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
thumbStyle={locked() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Region disabled</Text>
</Switch>
</Panel>
<Log title="onChange · onOpenChange" lines={events()} minHeight={120} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
<Field label="Language" detail={desktop ? "Typing a letter on the trigger chooses by title" : "Thirteen languages in a sheet"}>
<Select
items={LANGUAGES}
value={language()}
onChange={({ value: next }) => {
language.set(next);
log(`Language → ${titleOf(LANGUAGES, next)}`);
}}
onOpenChange={opened("Language", (next) => languageOpen.set(next))}
accessibilityLabel="Language"
offset={4}
margin={8}
popupStyle={surface()}
backdropStyle={backdrop}
itemClass={rowClass}
trigger={<Trigger title={titleOf(LANGUAGES, language())} placeholder="Choose a language" open={languageOpen()} />}
renderItem={(item) => <Row item={item} />}
/>
</Field>
<Field label="Region" detail="Search by name; one region is at capacity">
<Select
items={REGIONS}
value={region()}
onChange={({ value: next }) => {
region.set(next);
log(next === null ? "Region cleared" : `Region → ${titleOf(REGIONS, next)}`);
}}
onOpenChange={opened("Region", (next) => regionOpen.set(next))}
disabled={locked()}
accessibilityLabel="Region"
searchable
searchPlaceholder="Search regions"
searchAccessibilityLabel="Search regions"
searchClass={`${desktop ? "text-sm" : "text-base"} placeholder:text-[${ui().faint}] text-[${ui().text}]`}
searchStyle={{ ...ui().inset, borderRadius: 8, height: size.control, paddingHorizontal: 8, marginBottom: 4 }}
empty={<Text style={{ paddingVertical: 20, textAlign: "center", fontSize: 13, color: ui().muted }}>No region matches</Text>}
offset={4}
margin={8}
// Wider than the trigger on the desktops: the titles and their codes side by side.
popupStyle={desktop ? { ...surface(), width: 340 } : surface()}
backdropStyle={backdrop}
itemClass={rowClass}
// As wide as the trigger, so the clear button lands over its end.
style={desktop ? { width: 260 } : undefined}
cleanable
clearButtonAccessibilityLabel="Clear the region"
clearButtonClass="absolute items-center justify-center rounded-full enabled:hover:opacity-70"
clearButtonStyle={{ top: (size.control - 20) / 2, right: 10, width: 20, height: 20 }}
clearButton={<Icon name="close" size={14} tint={ui().muted} />}
trigger={<Trigger title={titleOf(REGIONS, region())} placeholder="Choose a region" open={regionOpen()} disabled={locked()} clearable={region() !== null} />}
renderItem={(item) => <Row item={item} detail={item.disabled ? "At capacity" : item.value} />}
renderGroup={(group) => <Header group={group} />}
/>
</Field>
</View>
</Split>
</View>
);
}
mount(App);- Sui desktop e nei browser desktop, un popup sopra il trigger con la riga selezionata sovrapposta al trigger, così il titolo scelto resta dov’era (la posizione
item-aligneddi shadcn/ui), largo quanto il trigger (la larghezzapopupStyle/popupClassdell’app ne imposta un’altra, comemenu_widthdigpui-component) e alto quanto le sue righe, fino allo spazio disponibile sullo schermo; senza alcuna selezione si apre sotto il trigger. La tastiera scorre gli elementi e ne sceglie uno. - Su telefoni e tablet, uno sheet in fondo allo schermo sopra un backdrop.
Il select gestisce il comportamento: il valore (quello dell’app, oppure il proprio), lo stato di apertura, la tastiera, il typeahead, la ricerca, le sezioni, la riga evidenziata e ciò che sentono gli screen reader. L’aspetto è dell’app: ne stila il trigger, il popup, l’elenco, le righe, le intestazioni delle sezioni e il campo di ricerca, oppure disegna lei stessa le righe.
use nsh_base::{SelectEntry, SelectItem, select, theme};
use nsh_core::prelude::*;
const FRUITS: [(&str, &str); 5] = [
("apple", "Apple"),
("banana", "Banana"),
("blueberry", "Blueberry"),
("grapes", "Grapes"),
("pineapple", "Pineapple"),
];
fn fruit_select() -> impl IntoElement {
let fruit = signal(None::<SharedString>);
let title = move || {
let chosen = fruit.get();
FRUITS
.iter()
.find(|(value, _)| Some(*value) == chosen.as_deref())
.map_or("Pick a fruit", |(_, title)| *title)
.to_string()
};
select()
.items(
FRUITS
.iter()
.map(|&(value, title)| SelectItem::new(value, title).disabled(value == "grapes"))
.collect::<Vec<_>>(),
)
.value(move || fruit.get())
.on_change(move |next: &Option<SharedString>| fruit.set(next.clone()))
.accessibility_label("Fruit")
.offset(px(4.0))
.popup_style(|s| {
s.p_1()
.rounded_lg()
.border_1()
.border_color(move || theme().border)
.bg(move || theme().popover)
.text_color(move || theme().popover_foreground)
.shadow_md()
})
.item_class("rounded-md focus:bg-accent selected:font-medium disabled:opacity-50")
.trigger(
view()
.h_9()
.w_48()
.flex_row()
.items_center()
.justify_between()
.px_3()
.rounded_md()
.border_1()
.border_color(move || theme().input)
.hover(|s| s.bg(move || theme().accent))
.child(text(title))
.child(text("▾").text_color(move || theme().muted_foreground)),
)
.render_item(|item: &SelectEntry| {
view()
.flex_row()
.items_center()
.px_2()
.py_1p5()
.child(text(item.title.clone()).text_sm())
})
}import { mount, signal, Text, View } from "nsh";
import { Select } from "nsh/base";
const FRUITS = [
{ value: "apple", title: "Apple" },
{ value: "banana", title: "Banana" },
{ value: "blueberry", title: "Blueberry" },
{ value: "grapes", title: "Grapes", disabled: true },
{ value: "pineapple", title: "Pineapple" },
];
function App() {
const fruit = signal<string | null>(null);
const title = () => FRUITS.find((f) => f.value === fruit())?.title;
return (
<Select
items={FRUITS}
value={fruit()}
onChange={({ value }) => fruit.set(value)}
accessibilityLabel="Fruit"
offset={4}
popupClass="rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md"
backdropClass="bg-black/40"
itemClass="rounded-md px-2 py-1.5 text-sm focus:bg-accent focus:text-accent-foreground disabled:opacity-50"
trigger={
<View class="h-9 w-48 flex-row items-center justify-between rounded-md border border-input px-3 hover:bg-accent active:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
<Text class={fruit() ? "text-foreground" : "text-muted-foreground"}>{title() ?? "Pick a fruit"}</Text>
<Text class="text-muted-foreground">▾</Text>
</View>
}
/>
);
}
mount(App);Select proviene da nsh/base, il crate nsh-base in Rust. In Rust, select() ne crea uno con un setter per ogni prop (.items(..), .value(..), .item_style(..)) e un listener per ogni evento (.on_change(..)); SelectItem::new(value, title) crea un elemento, con .disabled(..) e .group(..).
#Elementi
items è l’elenco sotto forma di dati: ogni elemento ha un value (univoco tra gli elementi: ciò che viene impostato scegliendolo), un title (ciò che mostra la sua riga, ciò con cui corrispondono ricerca e typeahead e ciò che dicono gli screen reader) e, facoltativamente, disabled e un group. Gli elementi adiacenti con lo stesso group formano una sezione sotto un’intestazione con quel titolo:
<Select
items={[
{ value: "lisbon", title: "Lisbon", group: "Europe" },
{ value: "london", title: "London", group: "Europe" },
{ value: "tokyo", title: "Tokyo", group: "Asia" },
{ value: "seoul", title: "Seoul", group: "Asia", disabled: true },
]}
groupClass="px-2 pb-1 pt-2 text-xs text-muted-foreground"
trigger={trigger}
/>
Le righe sono un elenco virtualizzato nella scroll view del popup: un elenco di migliaia di elementi genera solo le righe in vista, e il popup scorre quando le sue righe sono più alte dello spazio disponibile.
#Valore
Con value, l’app possiede l’elemento scelto: il select mostra l’elemento con quel valore e segnala una scelta con onChange({ value }); l’app imposta value per seguirla, oppure mantiene il proprio valore. value={null} significa nessuna scelta. Se omesso, il select mantiene un proprio valore, inizialmente preso da defaultValue. onChange segnala i cambiamenti: scegliere di nuovo l’elemento già scelto chiude l’elenco.
cleanable aggiunge un pulsante di cancellazione finché un elemento è scelto: una pressione azzera il valore, segnalato come onChange({ value: null }), e l’elenco resta chiuso. Il pulsante viene dopo il trigger nella view del select, non al suo interno, quindi una pressione su di esso non apre mai l’elenco; è l’app a posizionarlo (sopra l’estremità del trigger con absolute, dove ci sarebbe un chevron) e a riempirlo:
<Select
items={fonts}
value={font()}
onChange={({ value }) => font.set(value)}
cleanable
clearButtonAccessibilityLabel="Clear the font"
clearButtonClass="absolute right-2 top-2 size-5 items-center justify-center rounded-full hover:bg-accent"
clearButton={<Text class="text-xs text-muted-foreground">✕</Text>}
trigger={trigger}
/>
Il trigger è un elemento dell’app, quindi mostra ciò che l’app vuole per l’elemento scelto: il titolo, un’icona, un placeholder finché non è scelto nulla. In TSX è l’elemento che passi: è il combobox e gestisce da sé le pressioni, il focus da tastiera, lo stato espanso e disabled, quindi le sue classi active:, focus: e disabled: seguono il select, anche il suo onPress viene eseguito, e l’elenco è posizionato rispetto a esso ed è largo quanto lui.
#Stato di apertura
Senza open, il select mantiene un proprio stato di apertura (a partire da defaultOpen). Con open, lo possiede l’app e lo cambia in onOpenChange({ open, reason }): "triggerPress" (una pressione sul trigger, o uno dei suoi tasti), "select" (una scelta), "pressOutside", "backdropPress" o "cancel" (Escape o indietro). onOpen e onClose indicano quando l’elenco è mostrato e quando è scomparso (per uno sheet, una volta entrato e una volta uscito), onCancel che l’utente lo ha chiuso senza scegliere.
#Ricerca
searchable inserisce un campo di ricerca sopra gli elementi: digitando li filtra per titolo, ignorando maiuscole e minuscole, e una sezione senza corrispondenze sparisce insieme alla sua intestazione. L’evidenziazione si sposta sulla prima corrispondenza, i tasti continuano a spostarla mentre il campo ha la tastiera, e Invio la sceglie. empty è ciò che l’elenco mostra finché nulla corrisponde:
<Select
items={zones}
searchable
searchPlaceholder="Search time zones"
searchAccessibilityLabel="Search time zones"
searchClass="mx-1 mb-1 rounded-md border border-input px-2 py-1.5 text-sm"
empty={<Text class="px-2 py-6 text-center text-sm text-muted-foreground">No time zone found</Text>}
trigger={trigger}
/>
Sui desktop il campo di ricerca prende la tastiera quando l’elenco si apre; sui touch screen la tastiera compare quando si preme il campo. Ogni apertura parte con una ricerca vuota.
#Righe disegnate dall’app
itemClass stila la riga di ogni elemento, che mostra il titolo dell’elemento finché renderItem non la disegna: focus: si applica alla riga evidenziata (dove si trovano i tasti o il puntatore), selected: a quella scelta e disabled: a una disabilitata. Per disegnare una riga, fornisci renderItem: riceve ciò che è l’elemento (SelectEntry) e restituisce il contenuto della riga, per le righe in vista. Non riceve mai lo stato della riga: con group in itemClass, il contenuto segue gli stati della riga con le classi group-focus:, group-selected: e group-disabled:, quindi il disegno avviene una sola volta per elemento e uno spostamento dell’evidenziazione o una scelta non ridisegnano nulla. renderGroup disegna allo stesso modo l’intestazione di una sezione:
<Select
items={fonts}
itemClass="group rounded-md focus:bg-accent disabled:opacity-50"
renderItem={(item) => (
<View class="flex-row items-center gap-2 px-2 py-1.5">
<Text class="flex-1 text-sm" style={{ fontFamily: item.value }}>{item.title}</Text>
<Text class="w-4 text-right text-sm opacity-0 group-selected:opacity-100">✓</Text>
</View>
)}
renderGroup={(group) => <Text class="px-2 pt-2 text-xs font-medium text-muted-foreground">{group.title}</Text>}
trigger={trigger}
/>
#Props
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
items | .items(..) | SelectItem[] | [] | Gli elementi, in ordine. |
value | .value(..) | string | null | nessuno | Il value dell’elemento scelto, gestito dall’app; null per nessuno. Se omesso, il select mantiene il proprio. |
defaultValue | .default_value(..) | string | nessuno | Il valore iniziale, senza value. |
open | .open(..) | boolean | nessuno | Aperto, gestito dall’app. |
defaultOpen | .default_open(..) | boolean | false | Aperto inizialmente, senza open. |
searchable | .searchable(..) | boolean | false | Un campo di ricerca sopra gli elementi li filtra per titolo. |
searchPlaceholder | .search_placeholder(..) | string | nessuno | Il placeholder del campo di ricerca. |
searchAccessibilityLabel | .search_accessibility_label(..) | string | nessuno | Come gli screen reader chiamano il campo di ricerca: ciò che cerca. |
cleanable | .cleanable(..) | boolean | false | Un pulsante di cancellazione finché un elemento è scelto; una pressione azzera il valore (onChange({ value: null })). |
disabled | .disabled(..) | boolean | false | Resta chiuso: il trigger non accetta pressioni né focus da tastiera, e si applicano gli stili disabled:. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Come lo chiamano gli screen reader: l’oggetto della scelta. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | Desktop: il lato del trigger su cui va l’elenco, o l’altro lato se solo quello ha spazio. |
align | .align(..) | "start" | "center" | "end" | "start" | Desktop: come si allinea al trigger. |
offset | .offset(..) | number | 0 | Desktop: la distanza tra il trigger e l’elenco. |
margin | .margin(..) | number | 0 | Desktop: la distanza che mantiene dai bordi della safe area. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | nessuno | Il popup (desktop) o lo sheet (touch screen) in cui si trova l’elenco. |
backdropClass, backdropStyle | .backdrop_class(..), .backdrop_style(..) | string, StyleProp<ViewStyle> | nessuno | Touch screen: il backdrop dietro lo sheet. |
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, con gli stili di testo ereditati dal suo titolo: focus: quando è evidenziata, selected: quando è scelta, disabled: quando non può esserlo. |
groupClass, groupStyle | .group_class(..), .group_style(..) | string, StyleProp<ViewStyle> | nessuno | L’intestazione di ogni sezione. |
searchClass, searchStyle | .search_class(..), .search_style(..) | string, StyleProp<InputStyle> | nessuno | Il campo di ricerca: il suo box e il suo testo (color, fontSize, placeholderColor). |
clearButtonClass, clearButtonStyle | .clear_button_class(..), .clear_button_style(..) | string, StyleProp<ViewStyle> | nessuno | Il pulsante di cancellazione (cleanable); disabled: con il select. |
clearButton | .clear_button(..) | JSX.Element | nessuno | Il contenuto del pulsante di cancellazione: una croce, un’icona. |
clearButtonAccessibilityLabel | .clear_button_accessibility_label(..) | string | nessuno | Come gli screen reader chiamano il pulsante di cancellazione. |
trigger | .trigger(..) | JSX.Element | nessuno | Ciò che apre l’elenco quando viene premuto; l’elenco è posizionato rispetto a esso. |
empty | .empty(..) | JSX.Element | nessuno | Ciò che l’elenco mostra quando nessun elemento è visibile (una ricerca senza corrispondenze). |
renderItem | .render_item(..) | (item: SelectEntry) => JSX.Element | nessuno | La riga di un elemento, disegnata dall’app a partire da ciò che l’elemento è (i suoi stati sono le classi della riga); 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 | La view attorno al trigger. |
#Eventi
| Evento | Rust | Descrizione |
|---|---|---|
onChange | .on_change(|value| ..) | L’utente ha scelto un altro elemento (una pressione, Invio o Spazio, il typeahead sul trigger): il suo value; null quando il pulsante di cancellazione lo ha azzerato. Imposta value per seguirlo. |
onOpenChange | .on_open_change(|e| ..) | L’elenco si è aperto o chiuso, con il motivo. Imposta open per seguirlo. |
onOpen | .on_open(|| ..) | L’elenco è mostrato; come sheet, una volta entrato. |
onClose | .on_close(|| ..) | L’elenco è scomparso; come sheet, una volta uscito. Anche quando l’app lo ha chiuso. |
onCancel | .on_cancel(|| ..) | L’utente ha chiuso l’elenco senza scegliere (Escape o indietro, una pressione all’esterno o sul backdrop). |
Arrivano in quest’ordine: onChange per una scelta, oppure onCancel per una chiusura senza scelta, poi onOpenChange, quindi onOpen o onClose quando l’elenco è mostrato o scomparso.
#Tipi
| Tipo | Membri | Descrizione |
|---|---|---|
SelectItem | value: string, title: string, disabled?: boolean, group?: string | Un elemento di items. |
SelectEntry | value, title, disabled, group? (dell’elemento), index: number | Ciò che riceve renderItem: l’elemento e il suo indice tra gli items, mai il suo stato (focus:, selected: e disabled: della riga). |
SelectGroup | title: string | Ciò che riceve renderGroup: il group della sezione. |
In Rust sono nsh_base::SelectItem, SelectEntry e SelectGroup; value è un SelectValue (Controlled(Some(..)) per un valore, Controlled(None) per null), che .value(..) crea da un Option<SharedString> o da una stringa.
#Parti
| Parte | Dove | Stilizzata con |
|---|---|---|
| Il trigger | la view del select | l’elemento dell’app (trigger) |
| Il pulsante di cancellazione | la view del select, dopo il trigger (cleanable, finché un elemento è scelto) | clearButtonClass, clearButtonStyle; il suo contenuto clearButton |
| Il popup o lo sheet | sopra tutto, sul trigger (desktop) o in basso (touch screen) | popupClass, popupStyle |
| Il backdrop | dietro lo sheet (touch screen) | backdropClass, backdropStyle |
| Il campo di ricerca | il popup, per primo (searchable) | searchClass, searchStyle, searchPlaceholder, searchAccessibilityLabel |
| L’elenco | il popup, dopo il campo di ricerca | 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 popup è una colonna: la sua altezza segue il campo di ricerca e le righe, quindi un max-h-* su popupClass la limita e l’elenco scorre al suo interno.
#Tastiera
Sui desktop, Tab raggiunge il trigger di un select che non è disabled. Con l’elenco chiuso, Freccia giù, Freccia su, Invio e Spazio lo aprono sull’elemento scelto (senza alcuna scelta, Freccia su apre sull’ultimo elemento abilitato e gli altri tasti sul primo), e lettere e cifre scelgono l’elemento il cui titolo inizia con quanto digitato, come fa un select nativo. Con l’elenco aperto:
| Tasto | Azione |
|---|---|
| Freccia giù, Freccia su | L’elemento abilitato successivo o precedente, con ritorno circolare agli estremi. |
| Home, End | Il primo o l’ultimo elemento abilitato (quelli propri del campo di ricerca mentre questo ha la tastiera). |
| Pagina giù, Pagina su | Una pagina di righe avanti o indietro. |
| Invio | Sceglie l’elemento evidenziato e chiude l’elenco. |
| Spazio | Sceglie l’elemento evidenziato (senza campo di ricerca). |
| Lettere, cifre | Il successivo elemento il cui titolo inizia con quanto digitato nell’ultimo secondo (senza campo di ricerca). |
| Escape | Chiude l’elenco senza scegliere. |
Quando l’elenco si chiude, il focus da tastiera torna al trigger, a meno che una pressione all’esterno non lo abbia portato altrove. Il puntatore evidenzia la riga su cui si muove; la riga evidenziata viene resa visibile quando i tasti si spostano su di essa.
#Su ogni dispositivo
| Desktop e browser desktop | Telefoni e tablet | |
|---|---|---|
| L’elenco | un popup sopra il trigger, con la riga selezionata sovrapposta (sotto di esso se non ce n’è nessuna), largo quanto il trigger a meno che popupStyle non imposti una larghezza, alto quanto le sue righe fino allo spazio disponibile sullo schermo | uno sheet in basso sopra un backdrop, alto quanto le sue righe fino alla barra di stato |
| Chiusura | una scelta, Escape, una pressione all’esterno (che prosegue verso ciò che si trova sotto il puntatore) | una scelta, il backdrop, il pulsante indietro di Android |
| Ricerca | il campo prende la tastiera quando l’elenco si apre | la tastiera compare quando si preme il campo |
| Una scelta | la pressione in discesa sceglie, così il campo di ricerca mantiene la tastiera | un tap |
popupClass stila il popup sui desktop e lo sheet su telefoni e tablet; uno stile che differisce tra i due può confrontare platform.idiom (nsh/device) con "desktop".
#Accessibilità
- Il trigger è un combobox per gli screen reader: la sua etichetta è
accessibilityLabel, il suo valore è il titolo dell’elemento scelto, e indica se l’elenco è espanso.disabledlo attenua. - Il pulsante di cancellazione è un pulsante denominato da
clearButtonAccessibilityLabel; non è una tappa del tasto Tab. - Il campo di ricerca è un campo di testo denominato da
searchAccessibilityLabel; il suo segnaposto è un suggerimento mostrato finché è vuoto. - L’elenco è una listbox e ogni riga è un’opzione, denominata dal suo titolo; l’opzione scelta è selezionata e quella disabilitata è attenuata. Le intestazioni delle sezioni sono intestazioni.
- Mentre i tasti spostano l’evidenziazione, il focus dello screen reader si sposta sulla riga evidenziata; mentre il campo di ricerca ha la tastiera, viene invece annunciato il titolo dell’elemento evidenziato, così la digitazione può continuare.
Vedi Accessibilità per etichette, ruoli e annunci in generale.