| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #54 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
6.7 KiB
Spec — Composant Accordéon <MalioAccordion>
Date : 2026-05-26 Ticket : MUI-37 Statut : Validé (design), prêt pour planification
Contexte & objectif
Ajouter un composant accordéon à @malio/layer-ui. Cas d'usage principal :
un système de filtres dans un drawer d'ERP, où plusieurs sections de
critères (prix, catégorie, marque…) doivent pouvoir être dépliées
simultanément, chaque section ayant un contenu hétérogène (checkboxes,
slider, recherche…).
Décision d'API : composants enfants (compositional)
Plutôt que l'API « tableau items + slots » de NuxtUI (qui impose un template
#content unique avec un switch central sur l'item courant), on adopte une
API compositionnelle : un parent <MalioAccordion> qui enveloppe des
enfants <MalioAccordionItem>. Chaque section déclare son titre et son
contenu au même endroit, sans switch central, et s'ajoute/se retire
indépendamment.
Rationale : pour des filtres au contenu hétérogène, c'est nettement plus
lisible et évolutif. On reste 100 % natif (pas de dépendance Reka UI,
contrairement à NuxtUI), cohérent avec le TabList maison et les conventions
du layer (@iconify/vue, twMerge, props *Class).
Architecture
MalioAccordion (parent : état d'ouverture, mode, coordination)
└─ MalioAccordionItem (enfant : en-tête cliquable + panneau animé + slot)
Le parent fournit (provide) un contexte d'accordéon ; chaque enfant
l'injecte (inject) pour connaître son état d'ouverture et déclencher les
bascules. Communication via une clé Symbol (InjectionKey).
Contexte fourni (forme indicative) :
interface AccordionContext {
mode: ComputedRef<'single' | 'multiple'>
isOpen: (value: string) => boolean
toggle: (value: string) => void
register: (value: string, defaultOpen: boolean) => void // enfant → parent au montage
unregister: (value: string) => void
baseId: string // pour générer les ids ARIA
registerHeader / focus nav helpers // pour la navigation flèches
}
Fichiers :
app/components/malio/accordion/Accordion.vue
app/components/malio/accordion/AccordionItem.vue
app/components/malio/accordion/Accordion.test.ts
app/components/malio/accordion/AccordionItem.test.ts
(+ page playground et story Histoire, cf. skill creating-malio-component.)
API publique
<MalioAccordion>
defineOptions({ name: 'MalioAccordion', inheritAttrs: false })
| Prop | Type | Défaut | Rôle |
|---|---|---|---|
mode |
'single' | 'multiple' |
'multiple' |
Un seul ou plusieurs panneaux ouverts |
modelValue |
string | string[] |
undefined |
v-model des clés ouvertes (string en single, string[] en multiple) |
id |
string |
auto (useId) |
Base d'id pour les attributs ARIA |
groupClass |
string |
'' |
Classes du conteneur (fusion twMerge) |
Events : update:modelValue(value: string | string[])
Pattern contrôlé / non-contrôlé (convention maison) :
isControlled = computed(() => props.modelValue !== undefined), avec
localValue en fallback. En non-contrôlé, l'état initial est dérivé des
enfants ayant defaultOpen.
<MalioAccordionItem>
defineOptions({ name: 'MalioAccordionItem', inheritAttrs: false })
| Prop | Type | Défaut | Rôle |
|---|---|---|---|
title |
string |
— | Texte de l'en-tête |
value |
string |
auto (useId) |
Clé unique (recommandée pour piloter le v-model) |
defaultOpen |
boolean |
false |
Ouvert au montage (mode non-contrôlé) |
disabled |
boolean |
false |
En-tête non cliquable |
headerClass |
string |
'' |
Override classes de l'en-tête (twMerge) |
panelClass |
string |
'' |
Override classes du panneau (twMerge) |
Slot par défaut = contenu du panneau.
Comportement : mode single vs multiple
multiple(défaut) :modelValueest unstring[]. Basculer une section ajoute/retire sa clé du tableau, sans affecter les autres.single:modelValueest unstring(clé ouverte, ou''/undefinedsi tout fermé). Ouvrir une section ferme la précédente.
L'en-tête minimal : titre + chevron animé uniquement. Pas de badge, pas d'icône leading, pas de slot d'en-tête custom dans cette version (extensible plus tard si besoin métier).
Animation & rendu
- Ouverture/fermeture : transition de hauteur via
grid-template-rows: 0fr → 1frsur un wrapper enoverflow: hidden(gère la hauteur dynamique du contenu sans mesure JS). - Chevron :
mdi:chevron-downvia@iconify/vue, rotation 180° en transition synchronisée avec l'ouverture. - Tokens Malio : séparateurs
border-m-border, titretext-m-text,rounded-malioau besoin. Tout surchargeable viaheaderClass/panelClassfusionnés avectwMerge().
Accessibilité (WAI-ARIA Accordion Pattern)
- En-tête = vrai
<button type="button">→ focusable nativement, Entrée/Espace pour basculer. aria-expandedsur le bouton,aria-controls→ id du panneau.- Panneau :
role="region"+aria-labelledby→ id du bouton. - Sections désactivées :
disabled+aria-disabledsur le bouton. - Navigation clavier ↑/↓ entre les en-têtes (déplacement du focus d'un
en-tête à l'autre), conformément au pattern WAI-ARIA.
Home/Endoptionnels (nice-to-have).
Tests (Vitest + @vue/test-utils, jsdom)
Helper mountComponent(props) colocalisé. Couverture cible :
Accordion.test.ts
- Rendu des enfants (slots).
- Mode
multiple: plusieurs sections ouvertes simultanément. - Mode
single: ouvrir une section ferme la précédente. - v-model contrôlé :
modelValuepilote l'état ; émission deupdate:modelValue. - Non-contrôlé :
defaultOpensur enfants → état initial correct.
AccordionItem.test.ts
- Toggle au clic sur l'en-tête.
disabled: clic sans effet, attributsdisabled/aria-disabled.- Attributs ARIA :
aria-expanded,aria-controls,role="region",aria-labelledbycorrectement liés. - Navigation clavier ↑/↓ entre en-têtes.
- Override de classes via
headerClass/panelClass.
Livrables documentaires (convention maison)
- Mise à jour de
COMPONENTS.md(tableau de props + exemples). - Mise à jour de
CHANGELOG.md. - Page playground (ajout à
playground.nav.ts). - Story Histoire (
app/story/accordion/).
Hors périmètre (YAGNI, V1)
- Badge / compteur de filtres actifs dans l'en-tête.
- Icône leading.
- Slot d'en-tête personnalisé.
- Persistance d'état (localStorage, URL).
Ces éléments pourront être ajoutés ultérieurement si un besoin métier concret émerge, sans casser l'API.