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

Elementos

Accordion

Un acordeón apila secciones que se expanden y se contraen bajo sus encabezados: las preguntas de unas FAQ, los grupos de una página de ajustes, los pasos de un proceso de pago. Lo forman cinco partes, cada una con una pieza del comportamiento y ninguna con el aspecto:

el-accordion.tsx
el-accordion.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
import { desktop, focusRing, hoverFill, Icon, Log, Panel, Segmented, size, Split, ui } from "./palette";

// An FAQ: each item a heading whose trigger (a button that says whether it
// is expanded) opens its answer. The app owns which items are open
// (`value`) and sets what `onChange` reports; one at a time, or several
// with `multiple`. The panels spring open and shut (`reveal`) and the
// items below move with them; the chevron turns with `group-aria-expanded:`.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the reveal and the chevron stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";

const FAQ: { value: string; question: string; answer: string; disabled?: boolean }[] = [
  { value: "shipping", question: "How long does shipping take?", answer: "Orders leave the warehouse within a day and arrive in two to five working days." },
  { value: "returns", question: "Can I return an item?", answer: "Within 30 days, unused and in its box: print the label from your order and drop it off." },
  { value: "warranty", question: "What does the warranty cover?", answer: "Two years against defects in materials and workmanship, parts and labour included." },
  { value: "gift", question: "Can I send it as a gift?", answer: "Gift wrapping opens with the holiday season.", disabled: true },
];

const MODES = ["one at a time", "several"] as const;

function App() {
  const open = signal<string[]>(["shipping"]);
  const mode = signal<(typeof MODES)[number]>("one at a time");
  const events = signal<string[]>([]);
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>accordion</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a question, or Tab to it and press Space" : "Tap a question to read its answer"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="multiple">
              <Segmented
                options={MODES}
                value={mode()}
                onChange={(value) => {
                  mode.set(value);
                  // One at a time keeps the item opened last.
                  if (value === "one at a time") open.set(open().slice(-1));
                }}
              />
            </Panel>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{open().length ? open().join(", ") : "none open"}</Text>
            </Panel>
            <Log title="onChange({ value })" lines={events()} minHeight={92} />
          </>
        }
      >
        <Accordion
          value={open()}
          multiple={mode() === "several"}
          onChange={({ value }) => {
            open.set(value);
            events.set([`[${value.join(", ")}]`, ...events()].slice(0, 4));
          }}
          style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}
        >
          {FAQ.map((item, i) => (
            <AccordionItem value={item.value} disabled={item.disabled}>
              <View style={{ height: i === 0 ? 0 : 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />
              <AccordionHeader>
                <AccordionTrigger
                  hoverStyle={item.disabled ? undefined : hoverFill()}
                  focusStyle={focusRing()}
                  class={`group flex-row items-center gap-3 ${pad} active:opacity-70 disabled:opacity-40`}
                  style={{ minHeight: size.row + 8 }}
                >
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{item.question}</Text>
                  <View class="transition-transform duration-200 motion-reduce:transition-none group-aria-expanded:rotate-180">
                    <Icon name="chevron-down" size={14} tint={ui().muted} />
                  </View>
                </AccordionTrigger>
              </AccordionHeader>
              <AccordionPanel reveal accessibilityLabel={item.question}>
                <View class={`${pad} pb-3`}>
                  <Text style={{ fontSize: size.label, lineHeight: size.label * 1.45, color: ui().muted }}>{item.answer}</Text>
                </View>
              </AccordionPanel>
            </AccordionItem>
          ))}
        </Accordion>
      </Split>
    </View>
  );
}

mount(App);
  • Accordion, la pila: guarda los value de la app, los valores de los ítems abiertos, y informa del siguiente con onChange. Se abre un ítem a la vez, o varios con multiple.
  • AccordionItem, una sección, identificada por su value. Está abierta mientras el value del acordeón la contenga.
  • AccordionHeader, el encabezado del ítem, de un level que los lectores de pantalla anuncian.
  • AccordionTrigger, el botón del encabezado que expande y contrae su ítem, e indica si está expandido.
  • AccordionPanel, el contenido del ítem, visible mientras está abierto; con reveal su altura se abre y se cierra con un resorte.
use nsh_base::{accordion, accordion_header, accordion_item, accordion_panel, accordion_trigger, theme};
use nsh_core::prelude::*;

const FAQ: [(&str, &str, &str); 2] = [
    ("shipping", "When will my order ship?", "Orders placed before 2 pm leave the same day."),
    ("returns", "How do I return an item?", "Print the label from your order within 30 days."),
];

fn faq() -> impl IntoElement {
    let open = signal(vec![SharedString::from("shipping")]);
    let mut list = accordion()
        .value(open)
        .on_change(move |value: &Vec<SharedString>| open.set(value.clone()));
    for (value, question, answer) in FAQ {
        list = list.child(
            accordion_item()
                .value(value)
                .border_b_1()
                .border_color(move || theme().border)
                .child(
                    accordion_header().child(
                        accordion_trigger()
                            .flex_row()
                            .items_center()
                            .justify_between()
                            .py_3()
                            .child(text(question).font_weight(FontWeight::MEDIUM))
                            .child(text("⌄").transition_transform().rotate(move || {
                                let open = open.get().iter().any(|v| &**v == value);
                                if open { deg(180.0) } else { deg(0.0) }
                            })),
                    ),
                )
                .child(
                    accordion_panel()
                        .reveal(true)
                        .accessibility_label(question)
                        .child(view().pb_3().child(text(answer).text_color(move || theme().muted_foreground))),
                ),
        );
    }
    list
}

Las partes provienen de nsh/base, el crate nsh-base de Rust. El valor pertenece a la app: la pulsación de un trigger informa del siguiente value con onChange, y el acordeón abre los ítems cuyos valores la app le devuelve. Cada parte encuentra por sí sola la que la rodea, de modo que la app las dispone con libertad: divisores entre los ítems, una insignia en un encabezado, un icono antes de un título. En Rust, accordion(), accordion_item(), accordion_header(), accordion_trigger() y accordion_panel() las crean, con un setter por cada prop y .child(..) para lo que va dentro.

#Props

#Accordion

Prop Rust Tipo Valor por defecto Descripción
value .value(..) string[] [] Los value de los ítems abiertos. Pertenecen a la app: la pulsación de un trigger informa de los siguientes con onChange, y el acordeón muestra los que la app le devuelve.
multiple .multiple(..) boolean false Varios ítems abiertos a la vez: abrir uno deja abiertos los demás. Sin esta prop, abrir un ítem cierra el que estaba abierto.
disabled .disabled(..) boolean false Todos los ítems están deshabilitados: ningún trigger recibe pulsaciones y los lectores de pantalla indican que cada uno aparece atenuado.
children .child(..) JSX.Element ninguno Los ítems, y lo que la app disponga entre ellos (divisores).
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El diseño y el aspecto de la pila: una tarjeta alrededor de los ítems, el espacio entre ellos.

#AccordionItem

Prop Rust Tipo Valor por defecto Descripción
value .value(..) string obligatorio Lo que identifica al ítem en el value del acordeón: el id propio de la app, nunca su título, que cambia con el idioma.
disabled .disabled(..) boolean false Su trigger no recibe pulsaciones y los lectores de pantalla indican que aparece atenuado. Sigue abierto o cerrado según lo que diga el value del acordeón.
children .child(..) JSX.Element ninguno El encabezado y el panel del ítem, y cualquier otra cosa que la app disponga en él.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno La sección: un divisor debajo, una tarjeta alrededor.

#AccordionHeader

Prop Rust Tipo Valor por defecto Descripción
level .level(..) number 3 El nivel del encabezado entre los encabezados de la página, para los lectores de pantalla (1 es el más alto).
children .child(..) JSX.Element ninguno El trigger del ítem, y cualquier otra cosa que contenga el encabezado (una insignia, una acción propia).
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El diseño del encabezado.

#AccordionTrigger

Prop Rust Tipo Valor por defecto Descripción
expandedStyle .expanded_style(|s| ..) StyleProp<ViewStyle> ninguno El estilo del trigger mientras su ítem está abierto, como clases aria-expanded:.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla, cuando los hijos no contienen texto que lo diga.
children .child(..) JSX.Element ninguno El título y el chevron; las clases group-aria-expanded: de su interior siguen al trigger con group en su class.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El trigger en sí: la fila que pulsa el usuario.
activeStyle, hoverStyle, focusStyle, disabledStyle .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) StyleProp<ViewStyle> ninguno El estilo del trigger mientras está pulsado, con el puntero encima, enfocado o deshabilitado, como las clases <state>:.

#AccordionPanel

Prop Rust Tipo Valor por defecto Descripción
reveal .reveal(..) boolean false Se abre y se cierra con un efecto de revelado, como el de Collapsible: el panel recorta su contenido y su altura pasa con un resorte de cero a la del contenido y de vuelta, con amortiguación crítica (unos 265 ms, sin rebote), y los ítems siguientes se desplazan con él. Con movimiento reducido se abre y se cierra al instante.
keepMounted .keep_mounted(..) boolean false El contenido permanece en el árbol mientras el ítem está cerrado, fuera de la vista, del foco del teclado y de los lectores de pantalla: un vídeo conserva su posición, una web view su página. Sin esta prop, el contenido sale del árbol cuando el panel se cierra (una vez que el revelado lo ha cerrado) y vuelve tal como estaba.
accessibilityLabel .accessibility_label(..) string ninguno Cómo llaman los lectores de pantalla a la región: normalmente el título del ítem, tal como lo dice su encabezado.
children .child(..) JSX.Element ninguno El contenido.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El diseño y el aspecto de la región. Con reveal el panel está en el layout mientras está cerrado, con altura cero, así que su relleno y sus bordes van en una vista de su contenido.

#Eventos

Evento Rust Descripción
onChange({ value }) (Accordion) .on_change(|value: &Vec<SharedString>| ..) Se pulsó un trigger: un toque, un clic, Espacio o Retorno mientras tiene el foco del teclado, o la activación de un lector de pantalla. value son los valores de los ítems abiertos tal como quedarían: el valor de un ítem abierto sale de la lista, el de uno cerrado se añade al final o, sin multiple, reemplaza al anterior. Se ejecuta antes de que cambie nada: asigna value para seguirlo.

#Partes

Parte Dentro de Se estiliza con Estados
La pila class, style de Accordion
Un ítem la pila class, style de AccordionItem el valor de la app
El encabezado el ítem class, style de AccordionHeader
El trigger el encabezado class, style de AccordionTrigger aria-expanded:, disabled:, active:, hover:, focus: (o expandedStyle, disabledStyle, …)
El título y el chevron el trigger sus propios elementos (children) group-aria-expanded:, group-disabled: con group en el class del trigger
El panel el ítem, después del encabezado class, style de AccordionPanel
El contenido el panel sus propios elementos (children)

Sin reveal, un panel cerrado queda fuera del layout y su contenido fuera del árbol. Con reveal, el cambio es una animación de layout del commit que modifica value (como LayoutAnimation.configureNext, ejecutada de forma nativa, por lo que no se ejecuta JavaScript mientras se mueve): el panel que se abre crece de cero a la altura de su contenido, el que se cierra vuelve a encogerse, ambos con el mismo resorte, y los ítems siguientes se desplazan con ellos. Una transición de la app los acompaña: el chevron gira (transition-transform group-aria-expanded:rotate-180).

#Deshabilitado

El trigger de un ítem deshabilitado no recibe pulsaciones y los lectores de pantalla lo anuncian como atenuado; las clases disabled: del trigger y las group-disabled: de su interior lo siguen. El disabled del acordeón deshabilita todos los ítems. Un ítem deshabilitado sigue mostrando lo que dice el value de la app: la app puede abrirlo.

#Accesibilidad

  • La pila es un grupo, cada encabezado es un heading de su level y cada trigger es un botón dentro de él que indica si su ítem está expandido. Los lectores de pantalla pueden ir de encabezado en encabezado.
  • Un panel abierto es una región, nombrada por su accessibilityLabel; uno cerrado queda oculto para los lectores de pantalla, incluido el contenido que se mantiene.
  • El aspecto del ítem abierto es decisión de la app y no debería depender solo del color: gira el chevron, como hacen los ejemplos.

Consulta Accessibility para ver las etiquetas, los roles y los anuncios en general.

#Teclado

Donde las vistas que reciben pulsaciones son paradas del foco del teclado (macOS y la web), cada trigger es una parada, en el orden de la página, y Espacio y Retorno lo pulsan. Los triggers deshabilitados se omiten, y también el contenido de un panel cerrado. Tab pasa de un trigger a los controles de su panel abierto y de ahí al siguiente trigger.

#Notas de plataforma

  • Todas las plataformas muestran el panel de un ítem abierto bajo su encabezado y dejan uno cerrado fuera del layout (con altura cero con reveal).
  • Con reveal, todas las plataformas animan con un resorte la altura de los paneles en su propio animador (iOS, Android, macOS, Windows, Linux, GPUI y la web), y los abren y cierran al instante cuando el sistema pide movimiento reducido.
  • El lector de pantalla de cada plataforma oye el encabezado y su nivel al leer los encabezados, y el trigger de su interior como un botón con su estado expandido; en iOS, VoiceOver lee el propio trigger.
  • iOS, Android y Mac Catalyst: la pulsación de un trigger es un toque. Escritorio (macOS, Windows, Linux, GPUI) y la web: un clic, y Espacio o Retorno sobre el trigger enfocado en macOS y la web.