Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Tabs

Las pestañas cambian entre las secciones de un mismo lugar: el resumen y la actividad de un proyecto, las vistas de un documento. Cada Tab gestiona el comportamiento de una pestaña: pulsaciones, activación por teclado, el estado deshabilitado, y el rol de pestaña y su estado seleccionado para los lectores de pantalla. Un Tabs que las envuelve es la lista de pestañas, que los lectores de pantalla anuncian como un solo conjunto. El aspecto es el de la app: una pestaña no tiene colores, tamaños ni etiqueta propios. Sus hijos son la etiqueta de la app, y las clases selected: en la pestaña, o las clases group-selected: en lo que hay dentro de ella, siguen su estado. El contenido de cada sección también es de la app: muestra la que nombra su valor, junto a la lista. El indicador de la lista, un subrayado o una píldora que la app estiliza, se sitúa sobre la pestaña seleccionada y viaja a la siguiente de forma nativa (Indicador).

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 y Tabs provienen de nsh/base, el crate nsh-base de Rust. El valor es de la app: la lista no guarda ninguno. A cada pestaña se le indica si está seleccionada; una pulsación informa onPress, la app establece su valor y muestra esa sección, y cada pestaña muestra el selected que la app devuelve. Las esquinas de la lista y las de las pestañas son concéntricas: el relleno de la lista (p-1) es la diferencia entre radius.lg y radius. El relleno de la pestaña seleccionada es el indicador de la lista (indicatorClass, dentro de ese relleno: inset-y-1), que se desplaza con un efecto de resorte hacia la pestaña que la app selecciona. En Rust, tab() y tabs() los crean, con un setter por prop (.selected(..)), un listener por evento (.on_press(..)), los métodos de estilo tipados y .child(..) para su contenido.

#Props

#Tab

Prop Rust Tipo Predeterminado Descripción
selected .selected(..) boolean false La pestaña actual de su lista: los lectores de pantalla dicen que está seleccionada y se aplican los estilos selected:. Una pulsación informa onPress; la pestaña muestra el valor que la app devuelve.
disabled .disabled(..) boolean false Sin pulsaciones ni respuesta visual a la pulsación; se aplican los estilos disabled: y los lectores de pantalla dicen que está atenuada.
selectedStyle .selected_style(|s| ..) StyleProp<ViewStyle> ninguno El estilo de la pestaña mientras está seleccionada y no deshabilitada, como clases selected:.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla: el nombre de la pestaña, cuando sus hijos no tienen texto que lo indique (un icono).
children .child(..) JSX.Element ninguno La etiqueta, el icono o la insignia de la app, que los lectores de pantalla dicen.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno La propia pestaña: la superficie que rodea su etiqueta.
activeStyle, hoverStyle, focusStyle, disabledStyle .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) StyleProp<ViewStyle> ninguno El estilo de la pestaña mientras está pulsada, con el cursor encima, enfocada o deshabilitada, como clases <state>:.

#Tabs

Prop Rust Tipo Predeterminado Descripción
indicatorClass .indicator_class(..) string ninguno Las clases de Tailwind del indicador sobre la pestaña seleccionada: su aspecto y su posición a lo ancho de la lista (bottom-0 h-0.5 para un subrayado en una fila, inset-y-1 para una píldora). A lo largo de la lista abarca la pestaña seleccionada, dentro de sus propios márgenes en ese eje. Con indicatorClass o indicatorStyle la lista tiene un indicador.
indicatorStyle .indicator_style(|s| ..) StyleProp<ViewStyle> ninguno El estilo del indicador, sobre indicatorClass.
children .child(..) JSX.Element ninguno Las pestañas de la lista, y todo lo que la app disponga entre ellas.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El diseño y el aspecto de la lista: una columna por defecto, flex-row para una fila.

#Eventos

Evento Rust Descripción
onPress (Tab) .on_press(|_: &()| ..) Se pulsó la pestaña: un toque, un clic, Espacio o Return mientras tiene el foco del teclado, o la activación de un lector de pantalla; o bien una tecla de flecha, Home o End de su lista movió el foco del teclado hacia ella. Se ejecuta antes de que cambie nada: la pestaña muestra el selected que la app devuelve.

La pestaña seleccionada también informa de sus pulsaciones, de modo que la app puede actuar ante una segunda pulsación (por ejemplo, desplazar su sección al principio). Una pestaña deshabilitada no informa de nada.

#Partes

Parte Dentro de Estilizada con Estados
La lista Tabs: class, style
El indicador la lista, detrás de las pestañas indicatorClass, indicatorStyle sobre la pestaña seleccionada
La pestaña la lista Tab: class, style selected:, disabled:, active:, hover:, focus: (o selectedStyle, disabledStyle, …)
La etiqueta la pestaña sus propios elementos (children) group-selected:, group-disabled: y las demás variantes group-*: con group en el class de la pestaña
El contenido de la sección junto a la lista los elementos de la app el valor de la app

Muestra la pestaña seleccionada mediante su propia superficie: un relleno, una etiqueta más marcada, un peso más grueso (consulta las Guías de diseño).

#Indicador

Un subrayado bajo la pestaña seleccionada, una píldora detrás de ella o el segmento elevado de un control segmentado es el indicador de la lista: una vista que la app estiliza con indicatorClass y indicatorStyle, dibujada detrás de las pestañas y posicionada de forma absoluta en la lista. Native Shell la sitúa sobre la pestaña seleccionada después de cada layout. A lo largo de la lista (el ancho de una fila, el alto de una columna) abarca esa pestaña, dentro de los márgenes propios del indicador en ese eje (mx-3 para un subrayado más corto que su pestaña); a lo ancho de la lista, sus clases lo posicionan:

// 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>

Cuando la app selecciona otra pestaña, el indicador se desplaza hasta ella de forma nativa con un efecto de resorte, como el indicador de la barra de pestañas de GPUI Kit; con movimiento reducido, se mueve de inmediato. Mientras no haya ninguna pestaña seleccionada permanece oculto; mientras haya varias, se sitúa en la primera. Un cambio de tamaño o un cambio en el ancho de una pestaña lo mueve en el mismo fotograma que las pestañas. El indicador no necesita eventos de layout ni código de la app: la pestaña seleccionada es aquella cuyo selected es verdadero. En una lista con desplazamiento, la vista de scroll rodea el Tabs, y el indicador se desplaza junto con las pestañas.

#Deshabilitado

Una pestaña deshabilitada no admite pulsaciones, y los lectores de pantalla la anuncian como atenuada. Sus clases disabled: se aplican sobre las selected:, y las clases group-disabled: que hay dentro de ella las siguen:

<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>

#Accesibilidad

  • Una pestaña es un solo elemento para los lectores de pantalla, con el rol "tab" y su estado seleccionado. Su etiqueta es el texto de sus hijos, o accessibilityLabel.
  • Tabs tiene el rol "tablist": los lectores de pantalla perciben las pestañas que contiene como un solo conjunto, cada una todavía como un elemento independiente.
  • disabled se anuncia como lo anuncia la plataforma (VoiceOver, TalkBack, UI Automation, el árbol de accesibilidad del navegador).
  • En macOS y la web, la lista es una sola parada del foco del teclado, y las teclas de flecha se mueven entre sus pestañas (más abajo).
  • El indicador está oculto para los lectores de pantalla y no recibe el foco: solo las pestañas son los elementos de la lista.

Consulta Accesibilidad para ver etiquetas, roles y anuncios en general.

#Teclado

En macOS y la web, donde Tab llega a las vistas que admiten pulsaciones, una lista Tabs sigue el patrón de pestañas de WAI-ARIA:

Tecla Qué hace
Tab, Shift+Tab Entra en la lista por la pestaña seleccionada (la primera pestaña no deshabilitada si no hay ninguna), y sale de ella en un solo paso.
Derecha, Abajo Mueve el foco a la pestaña siguiente, de la última a la primera.
Izquierda, Arriba Mueve el foco a la pestaña anterior, de la primera a la última.
Home, End Mueve el foco a la primera o a la última pestaña.
Espacio, Return Pulsa la pestaña enfocada.

Las teclas de flecha omiten las pestañas deshabilitadas, y la pestaña a la que se mueven informa onPress, de modo que la app la selecciona a medida que se mueve el foco: la sección sigue al foco. Ambos pares de flechas se mueven, sea cual sea el layout de la lista, que es una clase de la app. En un layout de derecha a izquierda la fila corre desde la derecha, así que Izquierda y Derecha intercambian sus lugares: Izquierda se mueve a la pestaña siguiente y Derecha a la anterior; Arriba, Abajo, Home y End conservan los suyos. Los estilos focus: muestran dónde está el foco. Un Tab fuera de un Tabs es una parada independiente.

#Notas de plataforma

  • iOS, Android y Mac Catalyst: una pulsación es un toque que termina sobre la pestaña, incluida su etiqueta.
  • Escritorios (macOS, Windows, Linux, GPUI) y la web: una pulsación es un clic. En macOS y la web, Espacio y Return también pulsan la pestaña que tiene el foco del teclado, y las teclas de flecha, Home y End lo mueven (Teclado).
  • Cada plataforma dibuja lo que la app estiliza: las pestañas, la superficie de la seleccionada, el indicador y el contenido de las secciones. El desplazamiento del indicador es una animación de layout nativa en todas las plataformas.