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

Éléments

Tabs

Les tabs permettent de passer d’une section à l’autre au sein d’un même endroit : la vue d’ensemble et l’activité d’un projet, les vues d’un document. Chaque Tab gère le comportement d’un onglet : les appuis, l’activation au clavier, l’état désactivé, ainsi que le rôle d’onglet et son état sélectionné pour les lecteurs d’écran. Un Tabs placé autour des onglets est la liste d’onglets, que les lecteurs d’écran annoncent comme un seul ensemble. L’apparence relève de l’app : un onglet n’a ni couleurs, ni tailles, ni libellé propres. Ses enfants forment le libellé de l’app, et les classes selected: sur l’onglet, ou les classes group-selected: sur son contenu, suivent son état. Le contenu de chaque section relève lui aussi de l’app : elle affiche celle que nomme sa valeur, à côté de la liste. L’indicateur de la liste, un soulignement ou une pastille que l’app stylise, se place sur l’onglet sélectionné et se déplace vers le suivant de façon native (Indicateur).

el-tabs.tsx
el-tabs.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Tab, Tabs } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, size, Split, tint, ui } from "./palette";

// What each section shows: the app switches the content by its own value.
const sections = [
  {
    id: "overview",
    label: "Overview",
    rows: [
      ["Visitors", "12,480"],
      ["Sign-ups", "342"],
      ["Conversion", "2.74%"],
    ],
  },
  {
    id: "activity",
    label: "Activity",
    rows: [
      ["Deploy to production", "09:41"],
      ["Invoice paid", "08:15"],
      ["New member joined", "07:02"],
    ],
  },
  {
    id: "settings",
    label: "Settings",
    rows: [
      ["Region", "Frankfurt"],
      ["Plan", "Pro"],
      ["Seats", "8 of 10"],
    ],
  },
];
const folders = [
  { id: "inbox", label: "Inbox", count: 12 },
  { id: "archive", label: "Archive", count: 0 },
  { id: "spam", label: "Spam", count: 3, locked: true },
];

function App() {
  const section = signal("overview");
  const folder = signal("inbox");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 3));
  const current = () => sections.find((s) => s.id === section())!;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tabs</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a tab, or Tab to a list and move with the arrow keys" : "Tap a tab to show its section"}
      </Text>

      <Split gap={14} inspector={<Log title={`onPress · ${section()}, ${folder()}`} lines={events()} minHeight={76} />}>
        {/* A segmented tab list: each tab is a segment, and the list's indicator is the raised one, inside the
            list's padding, which springs to the tab the app selects. */}
        <Tabs
          style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}
          indicatorStyle={{ top: 3, bottom: 3, borderRadius: 7, ...ui().raised }}
        >
          <Each of={sections}>
            {(s) => (
              <Tab
                selected={section() === s().id}
                onPress={() => {
                  section.set(s().id);
                  log(`${s().label} pressed`);
                }}
                style={
                  desktop
                    ? { height: 26, paddingHorizontal: 14, borderRadius: 7, alignItems: "center", justifyContent: "center" }
                    : { flex: 1, height: 30, borderRadius: 7, alignItems: "center", justifyContent: "center" }
                }
                class="active:opacity-70"
                hoverStyle={section() === s().id ? undefined : hoverFill()}
                focusStyle={focusRing()}
              >
                <Text style={{ fontSize: 13, fontWeight: "500", color: section() === s().id ? ui().text : ui().muted }}>
                  {s().label}
                </Text>
              </Tab>
            )}
          </Each>
        </Tabs>

        {/* The selected section's content, which the app shows by its value. */}
        <View style={{ ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <Each of={current().rows}>
            {(row, i) => (
              <>
                {i() > 0 ? <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} /> : null}
                <View
                  style={
                    desktop
                      ? { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, height: size.row }
                      : { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12 }
                  }
                >
                  <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{row()[0]}</Text>
                  <Text style={{ fontSize: 14, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().muted }}>{row()[1]}</Text>
                </View>
              </>
            )}
          </Each>
        </View>

        {/* Tabs with counts: the selected one's own fill, a disabled one dimmed. */}
        <Tabs style={{ flexDirection: "row", gap: 6 }}>
          <Each of={folders}>
            {(f) => (
              <Tab
                selected={folder() === f().id}
                disabled={f().locked}
                onPress={() => {
                  folder.set(f().id);
                  log(`${f().label} pressed`);
                }}
                class="active:opacity-70 disabled:opacity-40"
                hoverStyle={f().locked || folder() === f().id ? undefined : hoverFill()}
                focusStyle={focusRing()}
                style={{ flexDirection: "row", alignItems: "center", gap: 6, height: desktop ? size.controlSm : 30, paddingHorizontal: desktop ? 10 : 12, borderRadius: 8 }}
                selectedStyle={{ backgroundColor: tint(ui().accent, 0.16) }}
              >
                <Text style={{ fontSize: 13, fontWeight: "500", color: folder() === f().id ? ui().accent : ui().muted }}>
                  {f().label}
                </Text>
                {f().count > 0 ? (
                  <Text style={{ fontSize: 12, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().faint }}>{f().count}</Text>
                ) : null}
              </Tab>
            )}
          </Each>
        </Tabs>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{tab, tabs, theme};
use nsh_core::prelude::*;

fn sections() -> impl IntoElement {
    let section = signal("overview");
    let item = move |value: &'static str, label: &'static str| {
        let selected = move || section.get() == value;
        tab()
            .selected(selected)
            .on_press(move |_: &()| section.set(value))
            .rounded(move || theme().radius)
            .px_3()
            .py_2()
            .child(text(label).text_sm().text_color(move || {
                if selected() { theme().foreground } else { theme().muted_foreground }
            }))
    };
    view()
        .gap_4()
        .child(
            tabs()
                .flex_row()
                .self_start()
                .gap_1()
                .p_1()
                .rounded(move || theme().radius)
                .bg(move || theme().muted)
                .indicator_style(|s| {
                    s.top_1()
                        .bottom_1()
                        .rounded(move || theme().radius)
                        .bg(move || theme().background)
                })
                .child(item("overview", "Overview"))
                .child(item("activity", "Activity")),
        )
        .child(text(move || {
            if section.get() == "overview" {
                "Visitors, sign-ups and conversion this week."
            } else {
                "Deploys, invoices and new members, newest first."
            }
        }).text_color(move || theme().foreground))
}

Tab et Tabs viennent de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : la liste n’en détient aucune. Chaque onglet sait s’il est sélectionné ; un appui signale onPress, l’app définit sa valeur et affiche cette section, et chaque onglet affiche le selected que l’app lui renvoie. Les coins de la liste et ceux des onglets sont concentriques : le padding de la liste (p-1) est la différence entre radius.lg et radius. Le fond de l’onglet sélectionné est l’indicateur de la liste (indicatorClass, à l’intérieur de ce padding : inset-y-1), qui bondit vers l’onglet que l’app sélectionne. En Rust, tab() et tabs() les créent, avec un setter par prop (.selected(..)), un écouteur par événement (.on_press(..)), les méthodes de style typées et .child(..) pour leur contenu.

#Props

#Tab

PropRustTypeDéfautDescription
selected.selected(..)booleanfalseL’onglet courant de sa liste : les lecteurs d’écran indiquent qu’il est sélectionné, et les styles selected: s’appliquent. Un appui signale onPress ; l’onglet affiche la valeur que l’app lui renvoie.
disabled.disabled(..)booleanfalseAucun appui et aucun retour visuel à l’appui ; les styles disabled: s’appliquent, et les lecteurs d’écran indiquent qu’il est grisé.
selectedStyle.selected_style(|s| ..)StyleProp<ViewStyle>aucunLe style de l’onglet lorsqu’il est sélectionné et non désactivé, sous forme de classes selected:.
accessibilityLabel.accessibility_label(..)stringaucunCe que disent les lecteurs d’écran : le nom de l’onglet, lorsque ses enfants n’ont aucun texte qui le donne (une icône).
children.child(..)JSX.ElementaucunLe libellé, l’icône ou le badge de l’app, que les lecteurs d’écran énoncent.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunL’onglet lui-même : la surface autour de son libellé.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>aucunLe style de l’onglet lorsqu’il est pressé, survolé, focalisé ou désactivé, sous forme de classes <state>:.

#Tabs

PropRustTypeDéfautDescription
indicatorClass.indicator_class(..)stringaucunLes classes Tailwind de l’indicateur sur l’onglet sélectionné : son apparence et sa position en travers de la liste (bottom-0 h-0.5 pour un soulignement dans une ligne, inset-y-1 pour une pastille). Le long de la liste, il couvre l’onglet sélectionné, à l’intérieur de ses propres marges sur cet axe. Avec indicatorClass ou indicatorStyle, la liste a un indicateur.
indicatorStyle.indicator_style(|s| ..)StyleProp<ViewStyle>aucunLe style de l’indicateur, par-dessus indicatorClass.
children.child(..)JSX.ElementaucunLes onglets de la liste, et tout ce que l’app dispose entre eux.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLa disposition et l’apparence de la liste : une colonne par défaut, flex-row pour une ligne.

#Événements

ÉvénementRustDescription
onPress (Tab).on_press(|_: &()| ..)L’onglet a été pressé : un tap, un clic, Espace ou Entrée lorsqu’il a le focus clavier, ou l’activation par un lecteur d’écran ; ou bien une flèche, Début ou Fin dans sa liste a déplacé le focus clavier vers lui. Il s’exécute avant tout changement : l’onglet affiche le selected que l’app lui renvoie.

L’onglet sélectionné signale lui aussi ses appuis, afin que l’app puisse réagir à un second appui (remonter sa section en haut, par exemple). Un onglet désactivé ne signale rien.

#Parties

PartieDansStylisée avecÉtats
La listeTabs : class, style
L’indicateurla liste, derrière les ongletsindicatorClass, indicatorStylesur l’onglet sélectionné
L’ongletla listeTab : class, styleselected:, disabled:, active:, hover:, focus: (ou selectedStyle, disabledStyle, …)
Le libellél’ongletses propres éléments (children)group-selected:, group-disabled: et les autres variantes group-*: avec group sur le class de l’onglet
Le contenu de la sectionà côté de la listeles éléments de l’appla valeur de l’app

Montrez l’onglet sélectionné par sa propre surface : un fond, un libellé plus marqué, une graisse plus forte (voir Guides de conception).

#Indicateur

Un soulignement sous l’onglet sélectionné, une pastille derrière lui ou le segment surélevé d’un contrôle segmenté constituent l’indicateur de la liste : une vue que l’app stylise avec indicatorClass et indicatorStyle, dessinée derrière les onglets et positionnée de manière absolue dans la liste. Native Shell la place sur l’onglet sélectionné après chaque mise en page. Le long de la liste (la largeur d’une ligne, la hauteur d’une colonne), elle couvre cet onglet, à l’intérieur des marges propres de l’indicateur sur cet axe (mx-3 pour un soulignement plus court que son onglet) ; en travers de la liste, ses classes la positionnent :

// An underline at the bottom of a row of tabs.
<Tabs class="flex-row gap-4 border-b border-border" indicatorClass="bottom-0 h-0.5 bg-foreground">
  {item("overview", "Overview")}
  {item("activity", "Activity")}
</Tabs>

// A pill behind the selected tab, inside the list's padding.
<Tabs class="flex-row self-start gap-1 rounded-(--radius-lg) bg-muted p-1" indicatorClass="inset-y-1 rounded-(--radius) bg-background shadow-sm">
  {item("overview", "Overview")}
  {item("activity", "Activity")}
</Tabs>

Lorsque l’app sélectionne un autre onglet, l’indicateur s’y déplace de façon native avec un effet de ressort, comme l’indicateur de la barre d’onglets de GPUI Kit ; avec la réduction des animations, il s’y place instantanément. Tant qu’aucun onglet n’est sélectionné, il est masqué ; si plusieurs le sont, il se place sur le premier. Un redimensionnement ou un changement de largeur d’un onglet le déplace dans la même frame que les onglets. L’indicateur n’a besoin d’aucun événement de mise en page ni d’aucun code de l’app : l’onglet sélectionné est celui dont selected est vrai. Dans une liste qui défile, la vue de défilement entoure le Tabs, et l’indicateur défile avec les onglets.

#Désactivé

Un onglet désactivé ne reçoit aucun appui, et les lecteurs d’écran l’annoncent comme grisé. Ses classes disabled: s’appliquent par-dessus ses classes selected:, et les classes group-disabled: à l’intérieur suivent :

<Tab
  selected={section() === "billing"}
  disabled={!isOwner()}
  onPress={() => section.set("billing")}
  class="group rounded-(--radius) px-3 py-2 selected:bg-background disabled:opacity-50"
>
  <Text class="text-sm text-muted-foreground group-selected:text-foreground">Billing</Text>
</Tab>

#Accessibilité

  • Un onglet est un seul élément pour les lecteurs d’écran, avec le rôle "tab" et son état sélectionné. Son libellé est le texte de ses enfants, ou accessibilityLabel.
  • Tabs a le rôle "tablist" : les lecteurs d’écran entendent les onglets qu’il contient comme un seul ensemble, chacun restant un élément à part entière.
  • disabled est annoncé comme la plateforme l’annonce (VoiceOver, TalkBack, UI Automation, l’arbre d’accessibilité du navigateur).
  • Sur macOS et le web, la liste constitue un seul arrêt du focus clavier, et les flèches se déplacent entre ses onglets (voir ci-dessous).
  • L’indicateur est masqué pour les lecteurs d’écran et ne prend pas le focus : seuls les onglets sont les éléments de la liste.

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

#Clavier

Sur macOS et le web, où Tab atteint les vues qui reçoivent des appuis, une liste Tabs suit le motif des onglets WAI-ARIA :

ToucheEffet
Tab, Shift+TabEntre dans la liste sur l’onglet sélectionné (le premier onglet non désactivé si aucun ne l’est), et en sort en une seule étape.
Flèche droite, Flèche basDéplace le focus vers l’onglet suivant, du dernier au premier.
Flèche gauche, Flèche hautDéplace le focus vers l’onglet précédent, du premier au dernier.
Début, FinDéplace le focus vers le premier ou le dernier onglet.
Espace, EntréeAppuie sur l’onglet focalisé.

Les flèches ignorent les onglets désactivés, et l’onglet vers lequel elles se déplacent signale onPress, de sorte que l’app le sélectionne au fil du déplacement du focus : la section suit le focus. Les deux paires de flèches déplacent le focus, quelle que soit la disposition de la liste, laquelle relève des classes de l’app. Dans une disposition de droite à gauche, la ligne commence à droite, donc Gauche et Droite s’inversent : Gauche va à l’onglet suivant et Droite au précédent ; Haut, Bas, Début et Fin gardent leur effet. Les styles focus: montrent où se trouve le focus. Un Tab en dehors d’un Tabs constitue un arrêt à part.

#Notes sur les plateformes

  • iOS, Android et Mac Catalyst : un appui est un tap qui se termine sur l’onglet, libellé compris.
  • Ordinateurs de bureau (macOS, Windows, Linux, GPUI) et web : un appui est un clic. Sur macOS et le web, Espace et Entrée appuient aussi sur l’onglet qui a le focus clavier, et les flèches, Début et Fin le déplacent (Clavier).
  • Chaque plateforme dessine ce que l’app stylise : les onglets, la surface de celui qui est sélectionné, l’indicateur et le contenu des sections. Le déplacement de l’indicateur est une animation de mise en page native sur chaque plateforme.