Éléments
Accordion
Un accordion empile des sections qui se déploient et se replient sous leurs en-têtes : les questions d’une FAQ, les groupes d’une page de réglages, les étapes d’un paiement. Il se compose de cinq parties, dont chacune porte un seul aspect du comportement et aucun du rendu :
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 pile : elle contient levaluede l’app, les valeurs des éléments ouverts, et signale le suivant aveconChange. Un seul élément s’ouvre à la fois, ou plusieurs avecmultiple.AccordionItem, une section, identifiée par savalue. Elle est ouverte tant que levaluede l’accordion la contient.AccordionHeader, l’en-tête de l’élément, d’unlevelque les lecteurs d’écran annoncent.AccordionTrigger, le bouton de l’en-tête qui déploie et replie son élément, et indique s’il est déployé.AccordionPanel, le contenu de l’élément, affiché tant qu’il est ouvert ; avecreveal, sa hauteur s’ouvre et se ferme avec un effet de ressort.
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);Les parties viennent de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : l’appui sur un
déclencheur signale le value suivant avec onChange, et l’accordion ouvre les éléments dont l’app lui renvoie les valeurs.
Chaque partie trouve seule celle qui l’entoure, si bien que l’app les agence librement : des séparateurs entre les éléments, un badge
dans un en-tête, une icône avant un titre. En Rust, accordion(), accordion_item(), accordion_header(),
accordion_trigger() et accordion_panel() les construisent, avec un setter par prop et .child(..) pour leur contenu.
#Props
#Accordion
| Prop | Rust | Type | Valeur par défaut | Description |
|---|---|---|---|---|
value | .value(..) | string[] | [] | Les values des éléments ouverts. Ils appartiennent à l’app : l’appui sur un déclencheur signale les suivants avec onChange, et l’accordion affiche ceux que l’app lui renvoie. |
multiple | .multiple(..) | boolean | false | Plusieurs éléments ouverts à la fois : en ouvrir un laisse les autres ouverts. Sans cette prop, ouvrir un élément ferme celui qui l’était. |
disabled | .disabled(..) | boolean | false | Tous les éléments sont désactivés : aucun déclencheur ne reçoit d’appuis, et les lecteurs d’écran indiquent que chacun est grisé. |
children | .child(..) | JSX.Element | aucune | Les éléments, et tout ce que l’app place entre eux (séparateurs). |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucune | La mise en page et le rendu de la pile : une carte autour des éléments, l’espace entre eux. |
#AccordionItem
| Prop | Rust | Type | Valeur par défaut | Description |
|---|---|---|---|---|
value | .value(..) | string | requis | Ce qui identifie l’élément dans le value de l’accordion : l’identifiant propre à l’app, jamais son titre, qui change avec la langue. |
disabled | .disabled(..) | boolean | false | Son déclencheur ne reçoit aucun appui, et les lecteurs d’écran indiquent qu’il est grisé. Il reste ouvert ou fermé selon le value de l’accordion. |
children | .child(..) | JSX.Element | aucune | L’en-tête et le panneau de l’élément, et tout ce que l’app y place d’autre. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucune | La section : un séparateur en dessous, une carte autour. |
#AccordionHeader
| Prop | Rust | Type | Valeur par défaut | Description |
|---|---|---|---|---|
level | .level(..) | number | 3 | Le niveau de l’en-tête parmi les titres de la page, pour les lecteurs d’écran (1 étant le plus haut). |
children | .child(..) | JSX.Element | aucune | Le déclencheur de l’élément, et tout ce que l’en-tête contient d’autre (un badge, une action qui lui est propre). |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucune | La mise en page de l’en-tête. |
#AccordionTrigger
| Prop | Rust | Type | Valeur par défaut | Description |
|---|---|---|---|---|
expandedStyle | .expanded_style(|s| ..) | StyleProp<ViewStyle> | aucune | Le style du déclencheur tant que son élément est ouvert, sous forme de classes aria-expanded:. |
accessibilityLabel | .accessibility_label(..) | string | aucune | Ce que disent les lecteurs d’écran, lorsque les enfants ne contiennent aucun texte qui le dise. |
children | .child(..) | JSX.Element | aucune | Le titre et le chevron ; les classes group-aria-expanded: à l’intérieur suivent le déclencheur grâce à group sur son class. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucune | Le déclencheur lui-même : la ligne sur laquelle l’utilisateur appuie. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | aucune | Le style du déclencheur lorsqu’il est pressé, survolé, ciblé par le focus ou désactivé, sous forme de classes <state>:. |
#AccordionPanel
| Prop | Rust | Type | Valeur par défaut | Description |
|---|---|---|---|---|
reveal | .reveal(..) | boolean | false | S’ouvre et se ferme avec un effet de révélation, comme celui de Collapsible : le panneau rogne son contenu et sa hauteur passe de zéro à celle du contenu, et inversement, avec un amortissement critique (environ 265 ms, sans dépassement), et les éléments qui le suivent se déplacent avec lui. Avec la réduction des animations, il s’ouvre et se ferme instantanément. |
keepMounted | .keep_mounted(..) | boolean | false | Le contenu reste dans l’arbre tant que l’élément est fermé, hors de vue, du focus clavier et des lecteurs d’écran : une vidéo garde sa position, une web view sa page. Sans cette prop, le contenu quitte l’arbre à la fermeture du panneau (une fois l’effet de révélation terminé) et revient tel qu’il était. |
accessibilityLabel | .accessibility_label(..) | string | aucune | Le nom que les lecteurs d’écran donnent à la région : en général le titre de l’élément, tel que son en-tête l’énonce. |
children | .child(..) | JSX.Element | aucune | Le contenu. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucune | La mise en page et le rendu de la région. Avec reveal, le panneau reste dans la mise en page lorsqu’il est fermé, avec une hauteur nulle : son remplissage et ses bordures vont donc sur une vue de son contenu. |
#Événements
| Événement | Rust | Description |
|---|---|---|
onChange({ value }) (Accordion) | .on_change(|value: &Vec<SharedString>| ..) | Un déclencheur a été pressé : un toucher, un clic, Espace ou Entrée lorsqu’il a le focus clavier, ou une activation par un lecteur d’écran. value contient les valeurs des éléments ouverts telles qu’elles seraient : la valeur d’un élément ouvert la quitte, celle d’un élément fermé la rejoint à la fin ou, sans multiple, la remplace. Il s’exécute avant tout changement : définissez value pour le suivre. |
#Parties
| Partie | Dans | Stylée avec | États |
|---|---|---|---|
| La pile | Accordion : class, style | ||
| Un élément | la pile | AccordionItem : class, style | la valeur de l’app |
| L’en-tête | l’élément | AccordionHeader : class, style | |
| Le déclencheur | l’en-tête | AccordionTrigger : class, style | aria-expanded:, disabled:, active:, hover:, focus: (ou expandedStyle, disabledStyle, …) |
| Le titre et le chevron | le déclencheur | leurs propres éléments (children) | group-aria-expanded:, group-disabled: avec group sur le class du déclencheur |
| Le panneau | l’élément, après l’en-tête | AccordionPanel : class, style | |
| Le contenu | le panneau | ses propres éléments (children) |
Sans reveal, un panneau fermé est hors de la mise en page et son contenu hors de l’arbre. Avec reveal, le changement est une
animation de mise en page du commit qui modifie value (comme LayoutAnimation.configureNext, exécutée nativement : aucun
JavaScript ne s’exécute pendant le mouvement) : le panneau qui s’ouvre passe de zéro à la hauteur de son contenu, celui qui se ferme
se rétracte, tous deux avec le même ressort, et les éléments qui les suivent se déplacent avec eux. Une transition propre à l’app l’accompagne : le
chevron pivote (transition-transform group-aria-expanded:rotate-180).
#Désactivation
Le déclencheur d’un élément désactivé ne reçoit aucun appui, et les lecteurs d’écran l’annoncent comme grisé ; les classes disabled: sur le
déclencheur et group-disabled: à l’intérieur suivent. La prop disabled de l’accordion désactive chaque élément. Un élément désactivé
affiche toujours ce que dit le value de l’app : l’app peut l’ouvrir.
#Accessibilité
- La pile est un groupe, chaque en-tête un titre de son
level, et chaque déclencheur un bouton qui s’y trouve et indique si son élément est déployé. Les lecteurs d’écran peuvent passer d’un en-tête à l’autre. - Un panneau ouvert est une région, nommée par son
accessibilityLabel; un panneau fermé est masqué aux lecteurs d’écran, y compris son contenu conservé. - Le rendu de l’élément ouvert relève de l’app, et ne devrait pas reposer sur la seule couleur : faites pivoter le chevron, comme le font les exemples.
Voir Accessibilité pour les libellés, les rôles et les annonces en général.
#Clavier
Là où les vues qui reçoivent des appuis sont des arrêts du focus clavier (macOS et le web), chaque déclencheur est un arrêt, dans l’ordre de la page, et Espace et Entrée l’activent. Les déclencheurs désactivés sont ignorés, tout comme le contenu d’un panneau fermé. Tab passe d’un déclencheur aux contrôles de son panneau ouvert, puis au déclencheur suivant.
#Notes par plateforme
- Chaque plateforme affiche le panneau d’un élément ouvert sous son en-tête et laisse un panneau fermé hors de la mise en page (hauteur nulle
avec
reveal). - Avec
reveal, chaque plateforme anime la hauteur des panneaux avec un ressort sur son propre animateur (iOS, Android, macOS, Windows, Linux, GPUI et le web), et les ouvre et les ferme instantanément lorsque le système demande de réduire les animations. - Le lecteur d’écran de chaque plateforme lit l’en-tête et son niveau comme il lit les titres, et le déclencheur qu’il contient comme un bouton avec son état déployé ; sur iOS, VoiceOver lit le déclencheur lui-même.
- iOS, Android et Mac Catalyst : l’appui sur un déclencheur est un toucher. Ordinateurs (macOS, Windows, Linux, GPUI) et web : un clic, ainsi qu’Espace ou Entrée sur le déclencheur ciblé par le focus sous macOS et sur le web.