Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Elementos

Combobox

Um combobox escolhe valores de uma lista de itens, um só ou, com multiple, vários: as tags de um post, os membros de um canal, os idiomas de um documento. Seu gatilho é um campo de texto: digitar abre a lista e a filtra, como faz o combobox do shadcn/ui. Ele é irmão do Select e compartilha sua lista, apresentada como cada dispositivo espera:

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);
  • Em desktops e em navegadores desktop, um popup sob o campo, com a largura do campo (a largura popupStyle / popupClass do app define outra) e com a altura de suas linhas, até o limite do espaço disponível ao lado.
  • Em celulares e tablets, uma sheet na parte inferior da tela, sobre um backdrop.

O combobox é dono do comportamento: o valor (do app ou próprio), o estado de abertura, o teclado, a busca, as seções, a linha destacada e o que os leitores de tela anunciam. Com multiple, uma escolha alterna o item no valor e a lista permanece aberta, de modo que vários itens são escolhidos de uma só vez. A aparência é do app: ele estiliza o campo, o popup, a lista, as linhas, os cabeçalhos das seções, o input e o rodapé, ou desenha as linhas por conta própria.

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 vem de nsh/base, o crate nsh-base em Rust. Em Rust, combobox() cria um com um setter por prop (.items(..), .value(..), .multiple(..)) e um listener por evento (.on_change(..)). Seus itens são os de Select: SelectItem::new(value, title), com .disabled(..) e .group(..), em seções como as de um select (veja Itens).

#Valor

O valor é um array com os valores dos itens selecionados. Com value, o app é o dono dele: o combobox mostra esses itens como selecionados e informa cada mudança com onChange({ value }); o app define value para acompanhá-la, ou mantém o seu próprio. Se omitido, o combobox mantém seu próprio valor, a partir de defaultValue no início.

  • Com multiple, um toque, Return ou Espaço alterna o item destacado: entra no valor se estava fora, sai se estava dentro. A lista permanece aberta.
  • Sem ele, uma escolha substitui o valor pelo desse item e fecha a lista; escolher o item já selecionado apenas a fecha.

onConfirm({ value }) informa o valor quando a lista fecha: uma vez por abertura, não importa como ela feche (uma escolha, Escape, um toque fora, o botão de gatilho), com o valor como o usuário o deixou. Cmd+Return no Mac (Ctrl+Return nos demais) fecha a lista e confirma.

cleanable adiciona um botão de limpar enquanto houver um item selecionado: um toque esvazia o valor (onChange({ value: [] })). O botão vem depois do input e antes do botão de gatilho; um toque nele nunca abre a lista. O app o preenche, como no botão de limpar de um select.

#Input

O gatilho é um campo de texto na linha do combobox (seus class e style: a borda e o preenchimento do campo), com o prefix do app antes dele (os itens escolhidos como chips, um ícone) e, depois dele, o botão de limpar, o triggerButton do app (um chevron) e suffix.

  • Digitar abre a lista e filtra os itens pelo título, ignorando maiúsculas e minúsculas; empty aparece enquanto nada corresponder. Seta para baixo e seta para cima também a abrem, um toque no botão de gatilho a abre e a fecha, e em telas sensíveis ao toque um toque no input a abre.
  • Com um único valor, o input mostra o título do item escolhido (placeholder enquanto não houver nenhum). Ele fica selecionado quando o input recebe o foco, então o que for digitado o substitui.
  • Com multiple, o input mostra a consulta; o app mostra os itens escolhidos, em prefix ou ao lado do combobox.

A consulta é do próprio combobox, começando cada abertura em defaultQuery (ou vazia), ou do app com query: onQueryChange({ query }) informa o que foi digitado, e o app define query para acompanhá-lo, para filtrar à sua maneira ou para sugerir um ponto de partida.

#Rodapé

footer é o conteúdo abaixo das linhas, em uma parte própria (footerClass, footerStyle): uma ação (“Gerenciar tags”), uma contagem, uma dica. Ele permanece no lugar enquanto as linhas rolam.

#Props

Prop Rust Tipo Padrão Descrição
items .items(..) SelectItem[] [] Os itens, em ordem.
multiple .multiple(..) boolean false Uma escolha alterna o item no valor e a lista permanece aberta.
value .value(..) string[] nenhum Os valores dos itens selecionados, pertencentes ao app.
defaultValue .default_value(..) string[] [] O valor inicial, sem value.
open, defaultOpen .open(..), .default_open(..) boolean false O estado de abertura, do app ou do próprio combobox.
placeholder .placeholder(..) string nenhum O placeholder do input, enquanto ele está vazio.
query .query(..) string nenhum A consulta, pertencente ao app.
defaultQuery .default_query(..) string nenhum A consulta com que cada abertura começa, sem query.
cleanable .cleanable(..) boolean false Um botão de limpar enquanto houver um item selecionado; um toque esvazia o valor.
disabled .disabled(..) boolean false Permanece fechado e não recebe toques nem foco do teclado.
accessibilityLabel .accessibility_label(..) string nenhum Sobre o que é a escolha, para leitores de tela.
placement, align, offset, margin .placement(..), … como os de Select "bottom", "start", 0, 0 Desktops: onde o popup fica em relação ao campo.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> nenhum O popup (desktops) ou a sheet (telas sensíveis ao toque).
backdropClass, backdropStyle .backdrop_class(..), .backdrop_style(..) string, StyleProp<ViewStyle> nenhum O backdrop atrás da sheet.
listClass, listStyle .list_class(..), .list_style(..) string, StyleProp<ViewStyle> nenhum A lista, a scroll view das linhas.
itemClass, itemStyle .item_class(..), .item_style(..) string, StyleProp<TextStyle> nenhum A linha de cada item: focus: enquanto destacada, selected: enquanto selecionada, disabled: enquanto não pode ser.
groupClass, groupStyle .group_class(..), .group_style(..) string, StyleProp<ViewStyle> nenhum O cabeçalho de cada seção.
inputClass, inputStyle .input_class(..), .input_style(..) string, StyleProp<InputStyle> nenhum O input: focus: enquanto edita, disabled: com o combobox.
footerClass, footerStyle .footer_class(..), .footer_style(..) string, StyleProp<ViewStyle> nenhum O rodapé, abaixo das linhas.
clearButtonClass, clearButtonStyle .clear_button_class(..), .clear_button_style(..) string, StyleProp<ViewStyle> nenhum O botão de limpar (cleanable).
clearButton .clear_button(..) JSX.Element nenhum O conteúdo do botão de limpar.
clearButtonAccessibilityLabel .clear_button_accessibility_label(..) string nenhum Como os leitores de tela chamam o botão de limpar.
triggerButton .trigger_button(..) JSX.Element nenhum Um botão após o input (um chevron): um toque abre e fecha a lista.
prefix .prefix(..) JSX.Element nenhum Conteúdo antes do input: chips, um ícone.
suffix .suffix(..) JSX.Element nenhum Conteúdo após o botão de gatilho.
empty .empty(..) JSX.Element nenhum O que a lista mostra enquanto nenhum item é exibido.
footer .footer(..) JSX.Element nenhum O que aparece abaixo das linhas.
renderItem .render_item(..) (item: SelectEntry) => JSX.Element nenhum A linha de um item, desenhada pelo app a partir do que o item é, nunca do seu estado: o conteúdo acompanha group-focus:, group-selected: e group-disabled: da linha (group em itemClass), de modo que mover o destaque ou alternar um item não redesenha nada; caso contrário, o título do item.
renderGroup .render_group(..) (group: SelectGroup) => JSX.Element nenhum O cabeçalho de uma seção, desenhado pelo app; caso contrário, seu título.
class, style métodos de estilo string, StyleProp<ViewStyle> nenhum O campo: a linha com o prefixo, o input, os botões e o sufixo; a lista tem a mesma largura dele.

#Eventos

Evento Rust Descrição
onChange .on_change(|value| ..) O valor mudou (uma escolha, uma alternância, o botão de limpar): { value }, os valores dos itens selecionados. Defina value para acompanhá-lo.
onConfirm .on_confirm(|value| ..) A lista fechou: { value } como está nesse momento, uma vez por abertura.
onQueryChange .on_query_change(|query| ..) O usuário digitou no input: { query }. Defina query para acompanhá-lo.
onOpenChange .on_open_change(|e| ..) A lista abriu ou fechou, com o motivo ({ open, reason }). Defina open para acompanhá-lo.
onOpen, onClose .on_open(..), .on_close(..) A lista é exibida, ou desapareceu.
onCancel .on_cancel(..) O usuário fechou a lista sem uma escolha que a feche (Escape ou voltar, um toque fora ou no backdrop).

Eles ocorrem nesta ordem: onChange para uma escolha, ou onCancel para um fechamento sem escolha, depois onOpenChange, depois onConfirm quando a lista fecha, e então onOpen ou onClose quando a lista é exibida ou desaparece.

#Partes

Parte Onde Estilizada com
O campo a view do combobox, uma linha class, style
O prefixo o campo, primeiro o elemento do próprio app (prefix)
O input o campo, depois do prefixo inputClass, inputStyle, placeholder
O botão de limpar o campo, depois do input (cleanable, enquanto um item está selecionado) clearButtonClass, clearButtonStyle; seu conteúdo clearButton
O botão de gatilho o campo, depois do botão de limpar o elemento do próprio app (triggerButton)
O sufixo o campo, por último o elemento do próprio app (suffix)
O popup ou a sheet sobre todo o resto, sob o campo (desktops) ou na parte inferior (telas sensíveis ao toque) popupClass, popupStyle
O backdrop atrás da sheet (telas sensíveis ao toque) backdropClass, backdropStyle
A lista o popup listClass, listStyle
O cabeçalho de uma seção a lista, antes de seus itens groupClass, groupStyle, renderGroup
A linha de um item a lista itemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: dentro, sob group em itemClass)
O estado vazio o popup, enquanto nenhum item é exibido seu próprio elemento (empty)
O rodapé o popup, por último footerClass, footerStyle; seu conteúdo footer

#Teclado

As teclas são as de um select (veja Teclado) enquanto o input mantém o foco, com estas diferenças: letras e dígitos são digitados no input, que filtra a lista, em vez de busca por digitação (typeahead); seta para baixo e seta para cima abrem a lista fechada; Return alterna o item destacado com multiple e mantém a lista aberta; Cmd+Return no Mac (Ctrl+Return nos demais) a fecha e confirma. Quando a lista fecha nos desktops, o input mantém o foco.

#Acessibilidade

  • O input é um combobox para leitores de tela: seu rótulo é accessibilityLabel, seu valor são os títulos dos itens selecionados, separados por vírgulas, e ele informa se a lista está expandida. disabled o esmaece.
  • A lista é uma listbox e cada linha uma opção, nomeada por seu título; as opções selecionadas são marcadas como selecionadas e uma desabilitada fica esmaecida. Os cabeçalhos das seções são cabeçalhos.
  • O botão de limpar é um botão nomeado por clearButtonAccessibilityLabel; ele não é uma parada do Tab. O botão de gatilho fica oculto para leitores de tela, já que o input informa se a lista está aberta, e também não é uma parada do Tab.

Veja Acessibilidade para rótulos, papéis e anúncios em geral.