Elementi
Accordion
Un accordion impila sezioni che si espandono e si comprimono sotto i propri titoli: le domande di una FAQ, i gruppi di una pagina di impostazioni, i passaggi di un checkout. Lo compongono cinque parti, ciascuna responsabile di un solo aspetto del comportamento e di nessun aspetto dell’estetica:
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: contiene ivaluedell’app, cioè i valori degli elementi aperti, e comunica il successivo cononChange. Si apre un elemento alla volta, oppure più elementi conmultiple.AccordionItem, una sezione, identificata dal suovalue. È aperta finché ilvaluedell’accordion la contiene.AccordionHeader, il titolo dell’elemento, con unlevelche gli screen reader annunciano.AccordionTrigger, il pulsante nel titolo che espande e comprime il proprio elemento e indica se è espanso.AccordionPanel, il contenuto dell’elemento, visibile finché è aperto; conrevealla sua altezza si apre e si chiude con un effetto elastico.
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);Le parti provengono da nsh/base, il crate nsh-base in Rust. Il valore appartiene all’app: la pressione di un
trigger comunica il prossimo value con onChange, e l’accordion apre gli elementi i cui valori l’app gli restituisce.
Ogni parte trova da sola quella che la circonda, quindi l’app le dispone liberamente: divisori tra gli elementi, un badge
in un titolo, un’icona prima di un titolo di sezione. In Rust, accordion(), accordion_item(), accordion_header(),
accordion_trigger() e accordion_panel() le creano, con un setter per ogni prop e .child(..) per ciò che contengono.
#Props
#Accordion
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
value | .value(..) | string[] | [] | I value degli elementi aperti. Appartengono all’app: la pressione di un trigger comunica i successivi con onChange, e l’accordion mostra quelli che l’app gli restituisce. |
multiple | .multiple(..) | boolean | false | Più elementi aperti contemporaneamente: aprirne uno lascia aperti gli altri. Senza questa prop, aprire un elemento chiude quello che era aperto. |
disabled | .disabled(..) | boolean | false | Ogni elemento è disabilitato: nessun trigger accetta pressioni e gli screen reader indicano ciascuno come attenuato. |
children | .child(..) | JSX.Element | nessuno | Gli elementi, e qualunque cosa l’app disponga tra di essi (divisori). |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il layout e l’aspetto della pila: una card attorno agli elementi, lo spazio tra di essi. |
#AccordionItem
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
value | .value(..) | string | obbligatorio | Ciò che identifica l’elemento nel value dell’accordion: l’id proprio dell’app, mai il titolo, che cambia con la lingua. |
disabled | .disabled(..) | boolean | false | Il suo trigger non accetta pressioni e gli screen reader lo indicano come attenuato. Resta aperto o chiuso secondo il value dell’accordion. |
children | .child(..) | JSX.Element | nessuno | L’header e il panel dell’elemento, e qualunque altra cosa l’app vi disponga. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | La sezione: un divisore sotto di essa, una card attorno. |
#AccordionHeader
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
level | .level(..) | number | 3 | Il livello del titolo tra i titoli della pagina, per gli screen reader (1 è il più alto). |
children | .child(..) | JSX.Element | nessuno | Il trigger dell’elemento, e qualunque altra cosa contenga l’header (un badge, un’azione propria). |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il layout del titolo. |
#AccordionTrigger
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
expandedStyle | .expanded_style(|s| ..) | StyleProp<ViewStyle> | nessuno | Lo stile del trigger mentre il suo elemento è aperto, come classi aria-expanded:. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che dicono gli screen reader, quando i children non contengono testo che lo dica. |
children | .child(..) | JSX.Element | nessuno | Il titolo e il chevron; le classi group-aria-expanded: al loro interno seguono il trigger con group sul suo class. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il trigger stesso: la riga che l’utente preme. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile del trigger mentre è premuto, in hover, con il focus o disabilitato, come le classi <state>:. |
#AccordionPanel
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
reveal | .reveal(..) | boolean | false | Si apre e si chiude con un effetto di rivelazione, come quello di Collapsible: il panel ritaglia il proprio contenuto e la sua altezza passa con un effetto elastico da zero a quella del contenuto e viceversa, con smorzamento critico (circa 265 ms, senza overshoot), mentre gli elementi successivi si spostano di conseguenza. Con il movimento ridotto si apre e si chiude all’istante. |
keepMounted | .keep_mounted(..) | boolean | false | Il contenuto resta nell’albero mentre l’elemento è chiuso, fuori dalla vista, dal focus da tastiera e dagli screen reader: un video mantiene la sua posizione, una web view la sua pagina. Senza questa prop il contenuto esce dall’albero quando il panel si chiude (una volta che la rivelazione lo ha chiuso) e ritorna com’era. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Come gli screen reader chiamano la regione: di solito il titolo dell’elemento, così come lo dice il suo header. |
children | .child(..) | JSX.Element | nessuno | Il contenuto. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il layout e l’aspetto della regione. Con reveal il panel resta nel layout anche da chiuso, con altezza zero, quindi padding e bordi vanno messi su una view del suo contenuto. |
#Eventi
| Evento | Rust | Descrizione |
|---|---|---|
onChange({ value }) (Accordion) | .on_change(|value: &Vec<SharedString>| ..) | Un trigger è stato premuto: un tap, un clic, Spazio o Invio mentre ha il focus da tastiera, oppure l’attivazione da parte di uno screen reader. value contiene i valori degli elementi aperti così come sarebbero: il valore di un elemento aperto lo abbandona, quello di un elemento chiuso si aggiunge in coda oppure, senza multiple, lo sostituisce. Viene eseguito prima che cambi qualsiasi cosa: imposta value per seguirlo. |
#Parti
| Parte | Dentro | Stilizzata con | Stati |
|---|---|---|---|
| La pila | Accordion: class, style | ||
| Un elemento | la pila | AccordionItem: class, style | il valore dell’app |
| Il titolo | l’elemento | AccordionHeader: class, style | |
| Il trigger | il titolo | AccordionTrigger: class, style | aria-expanded:, disabled:, active:, hover:, focus: (oppure expandedStyle, disabledStyle, …) |
| Il titolo di sezione e il chevron | il trigger | i loro elementi (children) | group-aria-expanded:, group-disabled: con group sul class del trigger |
| Il panel | l’elemento, dopo il titolo | AccordionPanel: class, style | |
| Il contenuto | il panel | i suoi elementi (children) |
Senza reveal, un panel chiuso è fuori dal layout e il suo contenuto fuori dall’albero. Con reveal, il cambiamento è un’animazione di layout del commit che modifica value (come LayoutAnimation.configureNext, eseguita in modo nativo, quindi nessun
JavaScript viene eseguito mentre si muove): il panel che si apre cresce da zero all’altezza del proprio contenuto, quello che si chiude si riduce
fino a zero, entrambi con la stessa molla, e gli elementi successivi si spostano di conseguenza. Con essa va una transizione dell’app: il
chevron ruota (transition-transform group-aria-expanded:rotate-180).
#Disabilitato
Il trigger di un elemento disabilitato non accetta pressioni e gli screen reader lo annunciano come attenuato; le classi disabled: sul
trigger e group-disabled: al suo interno ne seguono lo stato. Il disabled dell’accordion disabilita ogni elemento. Un elemento disabilitato
mostra comunque ciò che dice il value dell’app: l’app può aprirlo.
#Accessibilità
- La pila è un gruppo, ogni header è un titolo del proprio
levele ogni trigger è un pulsante al suo interno che indica se il suo elemento è espanso. Gli screen reader possono spostarsi da un titolo all’altro. - Un panel aperto è una regione, denominata dal proprio
accessibilityLabel; uno chiuso è nascosto agli screen reader, compreso il contenuto mantenuto. - L’aspetto dell’elemento aperto spetta all’app e non dovrebbe basarsi solo sul colore: ruota il chevron, come fanno gli esempi.
Vedi Accessibilità per etichette, ruoli e annunci in generale.
#Tastiera
Dove le view che accettano pressioni sono tappe del focus da tastiera (macOS e web), ogni trigger è una tappa, nell’ ordine della pagina, e Spazio e Invio lo premono. I trigger disabilitati vengono saltati, così come il contenuto di un panel chiuso. Tab passa da un trigger ai controlli del suo panel aperto e poi al trigger successivo.
#Note sulle piattaforme
- Ogni piattaforma mostra il panel di un elemento aperto sotto il suo titolo e lascia fuori dal layout quello di un elemento chiuso (altezza zero
con
reveal). - Con
reveal, ogni piattaforma anima con effetto elastico l’altezza dei panel tramite il proprio animatore (iOS, Android, macOS, Windows, Linux, GPUI e web), e li apre e li chiude all’istante quando il sistema richiede il movimento ridotto. - Lo screen reader di ogni piattaforma legge il titolo e il suo livello quando legge i titoli, e il trigger al suo interno come pulsante con il suo stato di espansione; su iOS, VoiceOver legge il trigger stesso.
- iOS, Android e Mac Catalyst: la pressione di un trigger è un tap. Desktop (macOS, Windows, Linux, GPUI) e web: un clic, e Spazio o Invio sul trigger con il focus su macOS e sul web.