Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Combobox

Un Combobox permet de choisir des valeurs dans une liste d’éléments, une seule ou, avec multiple, plusieurs : les tags d’un article, les membres d’un canal, les langues d’un document. Son déclencheur est un champ de saisie : taper ouvre la liste et la filtre, comme le combobox de shadcn/ui. C’est le frère de Select et il partage sa liste, présentée comme chaque appareil l’attend :

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);
  • Sur les ordinateurs et dans les navigateurs de bureau, une popup sous le champ, aussi large que le champ (la largeur popupStyle / popupClass de l’app en définit une autre) et aussi haute que ses lignes, dans la limite de la place disponible à côté.
  • Sur les téléphones et les tablettes, une sheet en bas de l’écran, au-dessus d’un backdrop.

Le Combobox gère le comportement : la valeur (celle de l’app, ou la sienne), l’état ouvert, le clavier, la recherche, les sections, la ligne en surbrillance et ce qu’entendent les lecteurs d’écran. Avec multiple, un choix bascule l’élément dans la valeur et la liste reste ouverte, ce qui permet d’en choisir plusieurs d’un coup. L’apparence revient à l’app : elle stylise le champ, la popup, la liste, les lignes, les en-têtes de sections, le champ de saisie et le pied, ou dessine elle-même les lignes.

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 vient de nsh/base, la crate nsh-base en Rust. En Rust, combobox() en crée un avec un setter par prop (.items(..), .value(..), .multiple(..)) et un listener par événement (.on_change(..)). Ses éléments sont ceux de Select : SelectItem::new(value, title), avec .disabled(..) et .group(..), répartis en sections comme ceux d’un select (voir Items).

#Valeur

La valeur est un tableau des valeurs des éléments sélectionnés. Avec value, l’app la possède : le Combobox affiche ces éléments comme sélectionnés et signale chaque changement avec onChange({ value }) ; l’app définit value pour le suivre, ou garde sa propre valeur. Si elle est omise, le Combobox garde sa propre valeur, initialisée à partir de defaultValue.

  • Avec multiple, un appui, Entrée ou Espace bascule l’élément en surbrillance : dans la valeur s’il n’y était pas, hors de la valeur s’il y était. La liste reste ouverte.
  • Sans lui, un choix remplace la valeur par celle de cet élément et ferme la liste ; choisir à nouveau l’élément sélectionné ne fait que fermer la liste.

onConfirm({ value }) communique la valeur à la fermeture de la liste : une fois par ouverture, quelle que soit la façon dont elle se ferme (un choix, Échap, un appui à l’extérieur, le bouton déclencheur), avec la valeur telle que l’utilisateur l’a laissée. Cmd+Entrée sur Mac (Ctrl+Entrée ailleurs) ferme la liste et confirme.

cleanable ajoute un bouton d’effacement tant qu’un élément est sélectionné : un appui vide la valeur (onChange({ value: [] })). Le bouton se place après le champ de saisie, avant le bouton déclencheur ; un appui dessus n’ouvre jamais la liste. L’app le remplit, comme le bouton d’effacement d’un select.

#Champ de saisie

Le déclencheur est un champ de texte dans la ligne du Combobox (ses class et style : la bordure et le fond du champ), avec le prefix de l’app devant lui (les éléments choisis sous forme de chips, une icône) et, après lui, le bouton d’effacement, le triggerButton de l’app (un chevron) et suffix.

  • Taper ouvre la liste et filtre les éléments par titre, sans tenir compte de la casse ; empty s’affiche tant que rien ne correspond. Bas et Haut l’ouvrent aussi, un appui sur le bouton déclencheur l’ouvre et la ferme, et sur les écrans tactiles un appui sur le champ de saisie l’ouvre.
  • Avec une seule valeur, le champ affiche le titre de l’élément choisi (placeholder tant qu’il n’y en a pas). Il est sélectionné quand le champ reçoit le focus, de sorte que ce qui est tapé le remplace.
  • Avec multiple, le champ affiche la requête ; l’app affiche les éléments choisis, dans prefix ou à côté du Combobox.

La requête est celle du Combobox, qui repart à chaque ouverture de defaultQuery (sinon vide), ou celle de l’app avec query : onQueryChange({ query }) signale ce qui est tapé, et l’app définit query pour le suivre, afin de filtrer à sa manière ou de suggérer un point de départ.

#Pied

footer est un contenu sous les lignes, dans une partie à part (footerClass, footerStyle) : une action (« Gérer les tags »), un compteur, une indication. Il reste en place pendant que les lignes défilent.

#Props

PropRustTypeValeur par défautDescription
items.items(..)SelectItem[][]Les éléments, dans l’ordre.
multiple.multiple(..)booleanfalseUn choix bascule l’élément dans la valeur et la liste reste ouverte.
value.value(..)string[]aucuneLes valeurs des éléments sélectionnés, possédées par l’app.
defaultValue.default_value(..)string[][]La valeur initiale, sans value.
open, defaultOpen.open(..), .default_open(..)booleanfalseL’état ouvert, celui de l’app ou celui du Combobox.
placeholder.placeholder(..)stringaucunLe placeholder du champ de saisie, tant qu’il est vide.
query.query(..)stringaucuneLa requête, possédée par l’app.
defaultQuery.default_query(..)stringaucuneLa requête de départ de chaque ouverture, sans query.
cleanable.cleanable(..)booleanfalseUn bouton d’effacement tant qu’un élément est sélectionné ; un appui vide la valeur.
disabled.disabled(..)booleanfalseIl reste fermé et n’accepte ni appuis ni focus clavier.
accessibilityLabel.accessibility_label(..)stringaucunCe sur quoi porte le choix, pour les lecteurs d’écran.
placement, align, offset, margin.placement(..), …comme ceux de Select"bottom", "start", 0, 0Ordinateurs : où placer la popup par rapport au champ.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>aucunLa popup (ordinateurs) ou la sheet (écrans tactiles).
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>aucunLe backdrop derrière la sheet.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>aucunLa liste, la vue défilante des lignes.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>aucunLa ligne de chaque élément : focus: quand elle est en surbrillance, selected: quand elle est sélectionnée, disabled: quand elle ne peut pas l’être.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<ViewStyle>aucunL’en-tête de chaque section.
inputClass, inputStyle.input_class(..), .input_style(..)string, StyleProp<InputStyle>aucunLe champ de saisie : focus: pendant l’édition, disabled: avec le Combobox.
footerClass, footerStyle.footer_class(..), .footer_style(..)string, StyleProp<ViewStyle>aucunLe pied, sous les lignes.
clearButtonClass, clearButtonStyle.clear_button_class(..), .clear_button_style(..)string, StyleProp<ViewStyle>aucunLe bouton d’effacement (cleanable).
clearButton.clear_button(..)JSX.ElementaucunLe contenu du bouton d’effacement.
clearButtonAccessibilityLabel.clear_button_accessibility_label(..)stringaucunLe nom que les lecteurs d’écran donnent au bouton d’effacement.
triggerButton.trigger_button(..)JSX.ElementaucunUn bouton après le champ de saisie (un chevron) : un appui ouvre et ferme la liste.
prefix.prefix(..)JSX.ElementaucunContenu avant le champ de saisie : chips, icône.
suffix.suffix(..)JSX.ElementaucunContenu après le bouton déclencheur.
empty.empty(..)JSX.ElementaucunCe que la liste affiche quand aucun élément ne s’affiche.
footer.footer(..)JSX.ElementaucunCe qui s’affiche sous les lignes.
renderItem.render_item(..)(item: SelectEntry) => JSX.ElementaucunLa ligne d’un élément, dessinée par l’app à partir de ce qu’est l’élément, jamais de son état : le contenu suit les group-focus:, group-selected: et group-disabled: de la ligne (group dans itemClass), si bien qu’un déplacement de la surbrillance ou une bascule ne redessine rien ; sinon, le titre de l’élément.
renderGroup.render_group(..)(group: SelectGroup) => JSX.ElementaucunL’en-tête d’une section, dessiné par l’app ; sinon, son titre.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLe champ : la ligne du préfixe, du champ de saisie, des boutons et du suffixe ; la liste a sa largeur.

#Événements

ÉvénementRustDescription
onChange.on_change(|value| ..)La valeur a changé (un choix, une bascule, le bouton d’effacement) : { value }, les valeurs des éléments sélectionnés. Définissez value pour le suivre.
onConfirm.on_confirm(|value| ..)La liste s’est fermée : { value } tel qu’il est alors, une fois par ouverture.
onQueryChange.on_query_change(|query| ..)L’utilisateur a tapé dans le champ de saisie : { query }. Définissez query pour le suivre.
onOpenChange.on_open_change(|e| ..)La liste s’est ouverte ou fermée, avec la raison ({ open, reason }). Définissez open pour le suivre.
onOpen, onClose.on_open(..), .on_close(..)La liste est affichée, ou a disparu.
onCancel.on_cancel(..)L’utilisateur a fermé la liste sans faire de choix qui la ferme (Échap ou retour, un appui à l’extérieur ou sur le backdrop).

Ils arrivent dans cet ordre : onChange pour un choix, ou onCancel pour une fermeture sans choix, puis onOpenChange, puis onConfirm à la fermeture de la liste, puis onOpen ou onClose une fois la liste affichée ou disparue.

#Parties

PartieEmplacementStylisée avec
Le champla vue du Combobox, une ligneclass, style
Le préfixele champ, en premierl’élément propre à l’app (prefix)
Le champ de saisiele champ, après le préfixeinputClass, inputStyle, placeholder
Le bouton d’effacementle champ, après le champ de saisie (cleanable, tant qu’un élément est sélectionné)clearButtonClass, clearButtonStyle ; son contenu clearButton
Le bouton déclencheurle champ, après le bouton d’effacementl’élément propre à l’app (triggerButton)
Le suffixele champ, en dernierl’élément propre à l’app (suffix)
La popup ou la sheetau-dessus de tout, sous le champ (ordinateurs) ou en bas (écrans tactiles)popupClass, popupStyle
Le backdropderrière la sheet (écrans tactiles)backdropClass, backdropStyle
La listela popuplistClass, listStyle
L’en-tête d’une sectionla liste, avant ses élémentsgroupClass, groupStyle, renderGroup
La ligne d’un élémentla listeitemClass, itemStyle, renderItem (group-focus:, group-selected:, group-disabled: à l’intérieur, sous group dans itemClass)
L’état videla popup, tant qu’aucun élément ne s’afficheson propre élément (empty)
Le piedla popup, en dernierfooterClass, footerStyle ; son contenu footer

#Clavier

Les touches sont celles d’un select (voir Keyboard) tant que le champ de saisie garde le focus, avec ces différences : les lettres et les chiffres sont saisis dans le champ, qui filtre la liste, au lieu d’une recherche par frappe (typeahead) ; Bas et Haut ouvrent la liste fermée ; Entrée bascule l’élément en surbrillance avec multiple et garde la liste ouverte ; Cmd+Entrée sur Mac (Ctrl+Entrée ailleurs) la ferme et confirme. Quand la liste se ferme sur les ordinateurs, le champ de saisie garde le focus.

#Accessibilité

  • Le champ de saisie est un combobox pour les lecteurs d’écran : son libellé est accessibilityLabel, sa valeur les titres des éléments sélectionnés, séparés par des virgules, et il indique si la liste est développée. disabled l’atténue.
  • La liste est une listbox et chaque ligne une option, nommée par son titre ; les options sélectionnées sont indiquées comme telles et une option désactivée est atténuée. Les en-têtes de sections sont des en-têtes.
  • Le bouton d’effacement est un bouton nommé par clearButtonAccessibilityLabel ; ce n’est pas un arrêt de Tab. Le bouton déclencheur est masqué aux lecteurs d’écran, puisque le champ de saisie indique si la liste est ouverte, et n’est pas non plus un arrêt de Tab.

Voir Accessibility pour les libellés, les rôles et les annonces en général.