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:
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 losvaluede la app, los valores de los ítems abiertos, y informa del siguiente cononChange. Se abre un ítem a la vez, o varios conmultiple.AccordionItem, una sección, identificada por suvalue. Está abierta mientras elvaluedel acordeón la contenga.AccordionHeader, el encabezado del ítem, de unlevelque 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; conrevealsu 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
}import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
const FAQ = [
{ value: "shipping", question: "When will my order ship?", answer: "Orders placed before 2 pm leave the same day." },
{ value: "returns", question: "How do I return an item?", answer: "Print the label from your order within 30 days." },
];
function App() {
const open = signal(["shipping"]);
return (
<Accordion value={open()} onChange={({ value }) => open.set(value)}>
{FAQ.map((item) => (
<AccordionItem value={item.value} class="border-b border-border">
<AccordionHeader>
<AccordionTrigger class="group flex-row items-center justify-between py-3 active:opacity-70">
<Text class="font-medium">{item.question}</Text>
<Text class="transition-transform group-aria-expanded:rotate-180">⌄</Text>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel reveal accessibilityLabel={item.question}>
<View class="pb-3">
<Text class="text-muted-foreground">{item.answer}</Text>
</View>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
);
}
mount(App);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
levely 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.