Files
malio-layer-ui/docs/superpowers/specs/2026-05-26-accordion-design.md
T
tristan 6efb830ffe [#MUI-37] Création d'un composant accordéon (#54)
| 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>
2026-05-27 07:12:10 +00:00

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) : modelValue est un string[]. Basculer une section ajoute/retire sa clé du tableau, sans affecter les autres.
  • single : modelValue est un string (clé ouverte, ou ''/undefined si 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 → 1fr sur un wrapper en overflow: hidden (gère la hauteur dynamique du contenu sans mesure JS).
  • Chevron : mdi:chevron-down via @iconify/vue, rotation 180° en transition synchronisée avec l'ouverture.
  • Tokens Malio : séparateurs border-m-border, titre text-m-text, rounded-malio au besoin. Tout surchargeable via headerClass / panelClass fusionnés avec twMerge().

Accessibilité (WAI-ARIA Accordion Pattern)

  • En-tête = vrai <button type="button"> → focusable nativement, Entrée/Espace pour basculer.
  • aria-expanded sur le bouton, aria-controls → id du panneau.
  • Panneau : role="region" + aria-labelledby → id du bouton.
  • Sections désactivées : disabled + aria-disabled sur 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/End optionnels (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é : modelValue pilote l'état ; émission de update:modelValue.
  • Non-contrôlé : defaultOpen sur enfants → état initial correct.

AccordionItem.test.ts

  • Toggle au clic sur l'en-tête.
  • disabled : clic sans effet, attributs disabled / aria-disabled.
  • Attributs ARIA : aria-expanded, aria-controls, role="region", aria-labelledby correctement 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.