Elemente
Accordion
Ein Accordion stapelt Abschnitte, die sich unter ihren Überschriften auf- und zuklappen: die Fragen einer FAQ, die Gruppen einer Einstellungsseite, die Schritte eines Checkouts. Es besteht aus fünf Teilen, von denen jedes ein Stück Verhalten verantwortet und nichts vom Aussehen:
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, der Stapel: Er hält denvalueder App, also die Werte der geöffneten Items, und meldet den nächsten mitonChange. Ein Item ist jeweils offen, oder mehrere mitmultiple.AccordionItem, ein Abschnitt, identifiziert durch seinenvalue. Er ist offen, solange dervaluedes Accordions ihn enthält.AccordionHeader, die Überschrift des Items, mit einerlevel, die Screenreader ansagen.AccordionTrigger, die Schaltfläche in der Überschrift, die ihr Item auf- und zuklappt und angibt, ob es aufgeklappt ist.AccordionPanel, der Inhalt des Items, sichtbar, solange es offen ist; mitrevealfedert seine Höhe auf und zu.
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);Die Teile stammen aus nsh/base, dem nsh-base-Crate in Rust. Der Wert gehört der App: Das Drücken eines
Triggers meldet den nächsten value mit onChange, und das Accordion öffnet die Items, deren Werte die App zurückgibt. Jedes Teil findet das jeweils umgebende selbst, sodass die App sie frei anordnen kann: Trennlinien zwischen den Items, ein Badge
in einer Überschrift, ein Icon vor einem Titel. In Rust erzeugen accordion(), accordion_item(), accordion_header(),
accordion_trigger() und accordion_panel() sie, mit einem Setter pro Prop und .child(..) für den Inhalt.
#Props
#Accordion
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
value |
.value(..) |
string[] |
[] |
Die values der geöffneten Items. Die App besitzt sie: Das Drücken eines Triggers meldet die nächsten mit onChange, und das Accordion zeigt die, die die App zurückgibt. |
multiple |
.multiple(..) |
boolean |
false |
Mehrere Items gleichzeitig offen: Das Öffnen eines Items lässt die anderen offen. Ohne dies schließt das Öffnen eines Items das bisher offene. |
disabled |
.disabled(..) |
boolean |
false |
Jedes Item ist deaktiviert: Kein Trigger nimmt Drücke an, und Screenreader geben jedes als abgeblendet an. |
children |
.child(..) |
JSX.Element |
keiner | Die Items und alles, was die App dazwischen anordnet (Trennlinien). |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Layout und Aussehen des Stapels: eine Karte um die Items, der Abstand zwischen ihnen. |
#AccordionItem
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
value |
.value(..) |
string |
erforderlich | Was das Item im value des Accordions identifiziert: die eigene ID der App, nie ihr Titel, der sich mit der Sprache ändert. |
disabled |
.disabled(..) |
boolean |
false |
Sein Trigger nimmt keine Drücke an, und Screenreader geben ihn als abgeblendet an. Es bleibt offen oder geschlossen, wie der value des Accordions es vorgibt. |
children |
.child(..) |
JSX.Element |
keiner | Header und Panel des Items und alles, was die App sonst darin anordnet. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Der Abschnitt: eine Trennlinie darunter, eine Karte darum. |
#AccordionHeader
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
level |
.level(..) |
number |
3 |
Die Ebene der Überschrift unter den Überschriften der Seite, für Screenreader (1 ist die oberste). |
children |
.child(..) |
JSX.Element |
keiner | Der Trigger des Items und alles, was der Header sonst enthält (ein Badge, eine eigene Aktion). |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Das Layout der Überschrift. |
#AccordionTrigger
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
expandedStyle |
.expanded_style(|s| ..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Triggers, solange sein Item offen ist, als aria-expanded:-Klassen. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Was Screenreader sagen, wenn die Kinder keinen Text enthalten, der es sagt. |
children |
.child(..) |
JSX.Element |
keiner | Der Titel und der Chevron; group-aria-expanded:-Klassen darin folgen dem Trigger mit group auf seinem class. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Der Trigger selbst: die Zeile, die der Nutzer drückt. |
activeStyle, hoverStyle, focusStyle, disabledStyle |
.active(|s| ..), .hover(..), .focus(..), .disabled_style(..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Triggers, solange er gedrückt, überfahren, fokussiert oder deaktiviert ist, als <state>:-Klassen. |
#AccordionPanel
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
reveal |
.reveal(..) |
boolean |
false |
Öffnet und schließt mit einem Reveal wie bei Collapsible: Das Panel beschneidet seinen Inhalt, und seine Höhe federt von null auf die des Inhalts und zurück, kritisch gedämpft (etwa 265 ms, ohne Überschwingen); die nachfolgenden Items bewegen sich mit. Bei reduzierter Bewegung öffnet und schließt es sofort. |
keepMounted |
.keep_mounted(..) |
boolean |
false |
Der Inhalt bleibt im Baum, solange das Item geschlossen ist, unsichtbar, außerhalb des Tastaturfokus und für Screenreader nicht wahrnehmbar: Ein Video behält seine Position, eine Web View ihre Seite. Ohne dies verlässt der Inhalt den Baum, wenn das Panel schließt (sobald der Reveal es geschlossen hat), und kommt so zurück, wie er war. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Wie Screenreader die Region nennen: meist der Titel des Items, wie seine Überschrift ihn angibt. |
children |
.child(..) |
JSX.Element |
keiner | Der Inhalt. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Layout und Aussehen der Region. Mit reveal liegt das Panel im geschlossenen Zustand im Layout, null hoch; Padding und Rahmen gehören daher auf eine View seines Inhalts. |
#Events
| Event | Rust | Beschreibung |
|---|---|---|
onChange({ value }) (Accordion) |
.on_change(|value: &Vec<SharedString>| ..) |
Ein Trigger wurde gedrückt: ein Tap, ein Klick, Space oder Return, während er den Tastaturfokus hat, oder die Aktivierung durch einen Screenreader. value sind die Werte der geöffneten Items, wie sie sein würden: Der Wert eines offenen Items verlässt sie, der eines geschlossenen wird am Ende angefügt oder ersetzt, ohne multiple, den bisherigen. Es läuft, bevor sich etwas ändert: Setze value, um ihm zu folgen. |
#Teile
| Teil | Enthalten in | Gestaltet mit | Zustände |
|---|---|---|---|
| Der Stapel | Accordion mit class, style |
||
| Ein Item | dem Stapel | AccordionItem mit class, style |
der Wert der App |
| Die Überschrift | dem Item | AccordionHeader mit class, style |
|
| Der Trigger | der Überschrift | AccordionTrigger mit class, style |
aria-expanded:, disabled:, active:, hover:, focus: (oder expandedStyle, disabledStyle, …) |
| Titel und Chevron | dem Trigger | eigene Elemente (children) |
group-aria-expanded:, group-disabled: mit group auf dem class des Triggers |
| Das Panel | dem Item, nach der Überschrift | AccordionPanel mit class, style |
|
| Der Inhalt | dem Panel | eigene Elemente (children) |
Ohne reveal liegt ein geschlossenes Panel außerhalb des Layouts und sein Inhalt außerhalb des Baums. Mit reveal ist die Änderung
eine Layout-Animation des Commits, der value ändert (wie LayoutAnimation.configureNext, nativ ausgeführt, sodass
beim Bewegen kein JavaScript läuft): Das sich öffnende Panel wächst von null auf die Höhe seines Inhalts, das sich schließende schrumpft
zurück, beide mit derselben Feder, und die nachfolgenden Items bewegen sich mit. Eine Transition der App läuft mit: Der
Chevron dreht sich (transition-transform group-aria-expanded:rotate-180).
#Deaktiviert
Der Trigger eines deaktivierten Items nimmt keine Drücke an, und Screenreader geben ihn als abgeblendet an; disabled:-Klassen am
Trigger und group-disabled: darin folgen. Das disabled des Accordions deaktiviert jedes Item. Ein deaktiviertes Item zeigt weiterhin,
was der value der App vorgibt: Die App kann es öffnen.
#Barrierefreiheit
- Der Stapel ist eine Gruppe, jeder Header eine Überschrift seiner
levelund jeder Trigger eine Schaltfläche darin, die angibt, ob ihr Item aufgeklappt ist. Screenreader können von Überschrift zu Überschrift springen. - Ein offenes Panel ist eine Region, benannt durch ihr
accessibilityLabel; ein geschlossenes ist für Screenreader verborgen, auch erhalten gebliebener Inhalt. - Das Aussehen des offenen Items liegt bei der App und sollte sich nicht allein auf Farbe stützen: Drehe den Chevron, wie es die Beispiele tun.
Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.
#Tastatur
Wo Views, die Drücke annehmen, Stationen des Tastaturfokus sind (macOS und das Web), ist jeder Trigger eine Station, in der Reihenfolge der Seite, und Space und Return drücken ihn. Deaktivierte Trigger werden übersprungen, ebenso der Inhalt eines geschlossenen Panels. Tab springt von einem Trigger zu den Steuerelementen seines offenen Panels und weiter zum nächsten Trigger.
#Hinweise zu den Plattformen
- Jede Plattform zeigt das Panel eines offenen Items unter seiner Überschrift und lässt ein geschlossenes außerhalb des Layouts (null hoch
mit
reveal). - Mit
revealfedert jede Plattform die Höhen der Panels mit ihrem eigenen Animator (iOS, Android, macOS, Windows, Linux, GPUI und das Web) und öffnet und schließt sie sofort, solange das System reduzierte Bewegung verlangt. - Der Screenreader jeder Plattform hört die Überschrift und ihre Ebene beim Lesen von Überschriften und den Trigger darin als Schaltfläche mit ihrem Aufklappzustand; unter iOS liest VoiceOver den Trigger selbst.
- iOS, Android und Mac Catalyst: Das Drücken eines Triggers ist ein Tap. Desktops (macOS, Windows, Linux, GPUI) und das Web: ein Klick, und unter macOS und im Web Space oder Return auf dem fokussierten Trigger.