Elementos
Combobox
A combobox elige valores de una lista de elementos, uno o, con multiple, varios: las etiquetas de una publicación, los miembros de un canal, los idiomas de un documento. Su disparador es un campo de texto: al escribir se abre la lista y se filtra, como hace el combobox de shadcn/ui. Es hermano de Select y comparte su lista, presentada como espera cada dispositivo:
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);- En escritorio y en navegadores de escritorio, un popup bajo el campo, tan ancho como el campo (el ancho
popupStyle/popupClassde la app establece otro) y tan alto como sus filas, hasta el espacio disponible a su lado. - En teléfonos y tabletas, una hoja en la parte inferior de la pantalla sobre un fondo (backdrop).
El combobox se encarga del comportamiento: el valor (el de la app o el suyo propio), el estado de apertura, el teclado, la búsqueda, las secciones, la fila resaltada y lo que oyen los lectores de pantalla. Con multiple, una elección activa o desactiva el elemento en el valor y la lista permanece abierta, de modo que se pueden elegir varios de una vez. El aspecto es de la app: da estilo al campo, al popup, a la lista, a las filas, a los encabezados de sección, al campo de texto y al pie, o dibuja ella misma las filas.
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 viene de nsh/base, el crate nsh-base de Rust. En Rust, combobox() crea uno con un setter por prop (.items(..), .value(..), .multiple(..)) y un listener por evento (.on_change(..)). Sus elementos son los de Select: SelectItem::new(value, title), con .disabled(..) y .group(..), en secciones como los de un select (véase Elementos).
#Valor
El valor es un array con los valores de los elementos seleccionados. Con value, la app es su propietaria: el combobox muestra esos elementos como seleccionados e informa de cada cambio con onChange({ value }); la app establece value para seguirlo, o mantiene el suyo. Si se omite, el combobox mantiene su propio valor, a partir de defaultValue al principio.
- Con
multiple, una pulsación, Return o Espacio alterna el elemento resaltado: dentro del valor si estaba fuera, fuera si estaba dentro. La lista permanece abierta. - Sin ello, una elección sustituye el valor por el de ese elemento y cierra la lista; elegir de nuevo el elemento seleccionado solo la cierra.
onConfirm({ value }) informa del valor cuando la lista se cierra: una vez por apertura, se cierre como se cierre (una elección, Escape, una pulsación fuera, el botón disparador), con el valor tal como lo dejó el usuario. Cmd+Return en Mac (Ctrl+Return en otras plataformas) cierra la lista y confirma.
cleanable añade un botón de borrado mientras haya un elemento seleccionado: una pulsación vacía el valor (onChange({ value: [] })). El botón va después del campo de texto y antes del botón disparador; pulsarlo nunca abre la lista. La app lo rellena, como el botón de borrado de un select.
#Campo de texto
El disparador es un campo de texto en la fila del combobox (sus class y style: el borde y el relleno del campo), con el prefix de la app antes (los elementos elegidos como chips, un icono) y, después, el botón de borrado, el triggerButton de la app (una flecha) y suffix.
- Escribir abre la lista y filtra los elementos por título, sin distinguir mayúsculas de minúsculas;
emptyse muestra mientras nada coincide. Abajo y Arriba también la abren, una pulsación en el botón disparador la abre y la cierra, y en pantallas táctiles un toque en el campo de texto la abre. - Con un valor, el campo de texto muestra el título del elemento elegido (
placeholdermientras no hay ninguno). Se selecciona cuando el campo recibe el foco, de modo que lo que se escribe lo sustituye. - Con
multiple, el campo de texto muestra la consulta; la app muestra los elementos elegidos, enprefixo junto al combobox.
La consulta es del propio combobox, y cada apertura empieza desde defaultQuery (o vacía), o es de la app con query: onQueryChange({ query }) informa de lo que se escribe, y la app establece query para seguirlo, para filtrar a su manera o para sugerir un punto de partida.
#Pie
footer es contenido bajo las filas, en una parte propia (footerClass, footerStyle): una acción (“Gestionar etiquetas”), un recuento, una pista. Permanece en su sitio mientras las filas se desplazan.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
items |
.items(..) |
SelectItem[] |
[] |
Los elementos, en orden. |
multiple |
.multiple(..) |
boolean |
false |
Una elección activa o desactiva el elemento en el valor y la lista permanece abierta. |
value |
.value(..) |
string[] |
ninguno | Los valores de los elementos seleccionados, propiedad de la app. |
defaultValue |
.default_value(..) |
string[] |
[] |
El valor inicial, sin value. |
open, defaultOpen |
.open(..), .default_open(..) |
boolean |
false |
El estado de apertura, el de la app o el propio del combobox. |
placeholder |
.placeholder(..) |
string |
ninguno | El placeholder del campo de texto, mientras está vacío. |
query |
.query(..) |
string |
ninguno | La consulta, propiedad de la app. |
defaultQuery |
.default_query(..) |
string |
ninguno | La consulta con la que empieza cada apertura, sin query. |
cleanable |
.cleanable(..) |
boolean |
false |
Un botón de borrado mientras haya un elemento seleccionado; una pulsación vacía el valor. |
disabled |
.disabled(..) |
boolean |
false |
Permanece cerrado y no admite pulsaciones ni foco de teclado. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Sobre qué es la elección, para los lectores de pantalla. |
placement, align, offset, margin |
.placement(..), … |
como los de Select |
"bottom", "start", 0, 0 |
Escritorio: dónde se coloca el popup respecto al campo. |
popupClass, popupStyle |
.popup_class(..), .popup_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El popup (escritorio) o la hoja (pantallas táctiles). |
backdropClass, backdropStyle |
.backdrop_class(..), .backdrop_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El fondo (backdrop) tras la hoja. |
listClass, listStyle |
.list_class(..), .list_style(..) |
string, StyleProp<ViewStyle> |
ninguno | La lista, la vista con scroll de las filas. |
itemClass, itemStyle |
.item_class(..), .item_style(..) |
string, StyleProp<TextStyle> |
ninguno | La fila de cada elemento: focus: mientras está resaltada, selected: mientras está seleccionada, disabled: mientras no puede estarlo. |
groupClass, groupStyle |
.group_class(..), .group_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El encabezado de cada sección. |
inputClass, inputStyle |
.input_class(..), .input_style(..) |
string, StyleProp<InputStyle> |
ninguno | El campo de texto: focus: mientras edita, disabled: con el combobox. |
footerClass, footerStyle |
.footer_class(..), .footer_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El pie, bajo las filas. |
clearButtonClass, clearButtonStyle |
.clear_button_class(..), .clear_button_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El botón de borrado (cleanable). |
clearButton |
.clear_button(..) |
JSX.Element |
ninguno | El contenido del botón de borrado. |
clearButtonAccessibilityLabel |
.clear_button_accessibility_label(..) |
string |
ninguno | Cómo llaman los lectores de pantalla al botón de borrado. |
triggerButton |
.trigger_button(..) |
JSX.Element |
ninguno | Un botón tras el campo de texto (una flecha): una pulsación abre y cierra la lista. |
prefix |
.prefix(..) |
JSX.Element |
ninguno | Contenido antes del campo de texto: chips, un icono. |
suffix |
.suffix(..) |
JSX.Element |
ninguno | Contenido tras el botón disparador. |
empty |
.empty(..) |
JSX.Element |
ninguno | Lo que muestra la lista mientras no hay ningún elemento que mostrar. |
footer |
.footer(..) |
JSX.Element |
ninguno | Lo que se muestra bajo las filas. |
renderItem |
.render_item(..) |
(item: SelectEntry) => JSX.Element |
ninguno | La fila de un elemento, dibujada por la app a partir de lo que es el elemento, nunca de su estado: el contenido sigue a group-focus:, group-selected: y group-disabled: de la fila (group en itemClass), de modo que mover el resaltado o alternar un elemento no vuelve a dibujar nada; en caso contrario, el título del elemento. |
renderGroup |
.render_group(..) |
(group: SelectGroup) => JSX.Element |
ninguno | El encabezado de una sección, dibujado por la app; en caso contrario, su título. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El campo: la fila del prefijo, el campo de texto, los botones y el sufijo; la lista es tan ancha como él. |
#Eventos
| Evento | Rust | Descripción |
|---|---|---|
onChange |
.on_change(|value| ..) |
El valor cambió (una elección, una alternancia, el botón de borrado): { value }, los valores de los elementos seleccionados. Establece value para seguirlo. |
onConfirm |
.on_confirm(|value| ..) |
La lista se cerró: { value } tal como está entonces, una vez por apertura. |
onQueryChange |
.on_query_change(|query| ..) |
El usuario escribió en el campo de texto: { query }. Establece query para seguirlo. |
onOpenChange |
.on_open_change(|e| ..) |
La lista se abrió o se cerró, con el motivo ({ open, reason }). Establece open para seguirlo. |
onOpen, onClose |
.on_open(..), .on_close(..) |
La lista se muestra, o ha desaparecido. |
onCancel |
.on_cancel(..) |
El usuario cerró la lista sin una elección que la cierre (Escape o atrás, una pulsación fuera o en el backdrop). |
Llegan en este orden: onChange para una elección, o onCancel para un cierre sin ella, luego onOpenChange, luego onConfirm al cerrarse la lista, y después onOpen o onClose una vez que la lista se muestra o ha desaparecido.
#Partes
| Parte | Dónde | Estilo con |
|---|---|---|
| El campo | la vista del combobox, una fila | class, style |
| El prefijo | el campo, el primero | el propio elemento de la app (prefix) |
| El campo de texto | el campo, tras el prefijo | inputClass, inputStyle, placeholder |
| El botón de borrado | el campo, tras el campo de texto (cleanable, mientras haya un elemento seleccionado) |
clearButtonClass, clearButtonStyle; su contenido clearButton |
| El botón disparador | el campo, tras el botón de borrado | el propio elemento de la app (triggerButton) |
| El sufijo | el campo, el último | el propio elemento de la app (suffix) |
| El popup o la hoja | sobre todo lo demás, bajo el campo (escritorio) o en la parte inferior (pantallas táctiles) | popupClass, popupStyle |
| El backdrop | tras la hoja (pantallas táctiles) | backdropClass, backdropStyle |
| La lista | el popup | listClass, listStyle |
| El encabezado de una sección | la lista, antes de sus elementos | groupClass, groupStyle, renderGroup |
| La fila de un elemento | la lista | itemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: dentro, bajo group en itemClass) |
| El estado vacío | el popup, mientras no se muestra ningún elemento | su propio elemento (empty) |
| El pie | el popup, el último | footerClass, footerStyle; su contenido footer |
#Teclado
Las teclas son las de un select (véase Teclado) mientras el campo de texto conserva el foco, con estas diferencias: las letras y los dígitos se escriben en el campo de texto, que filtra la lista, en lugar de la búsqueda por tecleo (typeahead); Abajo y Arriba abren la lista cerrada; Return alterna el elemento resaltado con multiple y mantiene la lista abierta; Cmd+Return en Mac (Ctrl+Return en otras plataformas) la cierra y confirma. Cuando la lista se cierra en escritorio, el campo de texto conserva el foco.
#Accesibilidad
- El campo de texto es un combobox para los lectores de pantalla: su etiqueta es
accessibilityLabel, su valor los títulos de los elementos seleccionados, unidos con comas, e indica si la lista está expandida.disabledlo atenúa. - La lista es un listbox y cada fila una opción, nombrada por su título; las opciones seleccionadas figuran como seleccionadas y una deshabilitada aparece atenuada. Los encabezados de sección son encabezados.
- El botón de borrado es un botón nombrado por
clearButtonAccessibilityLabel; no es una parada del tabulador. El botón disparador está oculto para los lectores de pantalla, ya que el campo de texto indica si la lista está abierta, y tampoco es una parada del tabulador.
Consulta Accesibilidad para etiquetas, roles y anuncios en general.