docs(accordion): spec de conception du composant MalioAccordion [#MUI-37]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
# 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) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user