diff --git a/.playground/pages/composant/accordion/accordion.vue b/.playground/pages/composant/accordion/accordion.vue
new file mode 100644
index 0000000..c8aeb8d
--- /dev/null
+++ b/.playground/pages/composant/accordion/accordion.vue
@@ -0,0 +1,63 @@
+
+
+
+
Multiple (filtres) — défaut
+
+
+ Slider de prix ici…
+
+
+ Liste de checkboxes ici…
+
+
+ Recherche + liste ici…
+
+
+
Ouverts : {{ multiple }}
+
+
+
+
Single (FAQ)
+
+
+ Réponse 1
+
+
+ Réponse 2
+
+
+
Ouvert : {{ single }}
+
+
+
+
Non contrôlé + defaultOpen
+
+
+ Ouverte au montage
+
+
+ Fermée au montage
+
+
+
+
+
+
Section désactivée
+
+
+ Contenu accessible
+
+
+ Inaccessible
+
+
+
+
+
+
+
diff --git a/.playground/pages/composant/filtre/filtres.vue b/.playground/pages/composant/filtre/filtres.vue
new file mode 100644
index 0000000..9a3ed44
--- /dev/null
+++ b/.playground/pages/composant/filtre/filtres.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ Filtres
+
+
+
+
+
+
+
+
+
+
+
+
+ Du
+
+ Au
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts
index 8d9e458..d7d6eeb 100644
--- a/.playground/playground.nav.ts
+++ b/.playground/playground.nav.ts
@@ -54,6 +54,7 @@ export const navSections: SidebarSection[] = [
{label: 'Drawer', to: '/composant/drawer/drawer'},
{label: 'Modal', to: '/composant/modal/modal'},
{label: 'Onglets', to: '/composant/tab/tabList'},
+ {label: 'Accordéon', to: '/composant/accordion/accordion'},
],
},
{
@@ -70,6 +71,7 @@ export const navSections: SidebarSection[] = [
{label: 'Heure', to: '/composant/time/time'},
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
{label: 'Formulaire client', to: '/composant/form/client'},
+ {label: 'Filtres', to: '/composant/filtre/filtres'},
],
},
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5e9180..466fc12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-33] Développer le composant Datepicker
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe)
+* [#MUI-37] Création d'un composant accordéon
### Changed
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
diff --git a/COMPONENTS.md b/COMPONENTS.md
index 15bf628..715e076 100644
--- a/COMPONENTS.md
+++ b/COMPONENTS.md
@@ -694,6 +694,54 @@ const tabs = computed(() => [
---
+## MalioAccordion
+
+Accordéon compositionnel : `` enveloppe des ``. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
+
+### MalioAccordion
+
+| Prop | Type | Défaut | Description |
+|------|------|--------|-------------|
+| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
+| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
+| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
+| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
+
+**Events :** `update:modelValue(value: string | string[])`
+
+### MalioAccordionItem
+
+| Prop | Type | Défaut | Description |
+|------|------|--------|-------------|
+| `title` | `string` | — | Texte de l'en-tête |
+| `value` | `string` | auto | Clé unique de la section |
+| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
+| `disabled` | `boolean` | `false` | En-tête non cliquable |
+| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
+| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
+
+**Slot :** par défaut = contenu du panneau.
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+ Réponse 1
+ Réponse 2
+
+```
+
+---
+
## MalioSidebar
Barre latérale de navigation rétractable.
diff --git a/app/components/malio/accordion/Accordion.test.ts b/app/components/malio/accordion/Accordion.test.ts
new file mode 100644
index 0000000..376c88a
--- /dev/null
+++ b/app/components/malio/accordion/Accordion.test.ts
@@ -0,0 +1,256 @@
+import {describe, expect, it} from 'vitest'
+import {mount} from '@vue/test-utils'
+import {nextTick} from 'vue'
+import Accordion from './Accordion.vue'
+import AccordionItem from './AccordionItem.vue'
+
+const TWO_ITEMS = `
+ Contenu prix
+ Contenu catégorie
+`
+
+function mountAccordion(props: Record = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) {
+ return mount(Accordion, {
+ props,
+ slots: {default: slot},
+ attachTo,
+ global: {components: {MalioAccordionItem: AccordionItem}},
+ })
+}
+
+describe('MalioAccordion — rendu & mode multiple', () => {
+ it('renders each item header with its title', () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers).toHaveLength(2)
+ expect(headers[0].text()).toContain('Prix')
+ expect(headers[1].text()).toContain('Catégorie')
+ })
+
+ it('renders the slot content of each panel', () => {
+ const wrapper = mountAccordion()
+ expect(wrapper.html()).toContain('Contenu prix')
+ expect(wrapper.html()).toContain('Contenu catégorie')
+ })
+
+ it('all panels are collapsed by default', () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('false')
+ const regions = wrapper.findAll('[role="region"]')
+ expect(regions[0].classes()).toContain('grid-rows-[0fr]')
+ })
+
+ it('opens a panel on header click (multiple mode is default)', async () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ expect(headers[0].attributes('aria-expanded')).toBe('true')
+ const regions = wrapper.findAll('[role="region"]')
+ expect(regions[0].classes()).toContain('grid-rows-[1fr]')
+ })
+
+ it('keeps multiple panels open simultaneously in multiple mode', async () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ await headers[1].trigger('click')
+ expect(headers[0].attributes('aria-expanded')).toBe('true')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('closes an open panel when its header is clicked again', async () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ await headers[0].trigger('click')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ })
+
+ it('wires aria-controls / aria-labelledby / role=region correctly', () => {
+ const wrapper = mountAccordion({id: 'acc'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ const regions = wrapper.findAll('[role="region"]')
+ expect(headers[0].attributes('id')).toBe('acc-header-prix')
+ expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix')
+ expect(regions[0].attributes('id')).toBe('acc-panel-prix')
+ expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix')
+ })
+
+ it('emits update:modelValue with an array in multiple mode', async () => {
+ const wrapper = mountAccordion()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
+ await nextTick()
+ })
+})
+
+describe('MalioAccordion — mode single & contrôlé', () => {
+ it('opening a panel closes the others in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ await headers[1].trigger('click')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('emits a string in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[1].trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
+ })
+
+ it('emits empty string when closing the open panel in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
+ })
+
+ it('respects modelValue array in controlled multiple mode', () => {
+ const wrapper = mountAccordion({modelValue: ['cat']})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('respects modelValue string in controlled single mode', () => {
+ const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('true')
+ expect(headers[1].attributes('aria-expanded')).toBe('false')
+ })
+
+ it('does not mutate local state in controlled mode (emits only)', async () => {
+ const wrapper = mountAccordion({modelValue: []})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ // état piloté par le parent : sans mise à jour de la prop, reste fermé
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
+ })
+})
+
+describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
+ const WITH_DEFAULT_OPEN = `
+ P
+ C
+ `
+ const WITH_DISABLED = `
+ P
+ C
+ `
+
+ it('opens defaultOpen items initially in uncontrolled mode', async () => {
+ const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
+ await nextTick()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('sets disabled and aria-disabled on a disabled item', () => {
+ const wrapper = mountAccordion({}, WITH_DISABLED)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[1].attributes('disabled')).toBeDefined()
+ expect(headers[1].attributes('aria-disabled')).toBe('true')
+ })
+
+ it('does not toggle a disabled item on click', async () => {
+ const wrapper = mountAccordion({}, WITH_DISABLED)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[1].trigger('click')
+ expect(headers[1].attributes('aria-expanded')).toBe('false')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('moves focus to the next header on ArrowDown', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const wrapper = mountAccordion({}, TWO_ITEMS, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[0].element as HTMLElement).focus()
+ await headers[0].trigger('keydown', {key: 'ArrowDown'})
+ expect(document.activeElement).toBe(headers[1].element)
+ wrapper.unmount()
+ root.remove()
+ })
+
+ it('wraps focus to the first header on ArrowDown from the last', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const wrapper = mountAccordion({}, TWO_ITEMS, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[1].element as HTMLElement).focus()
+ await headers[1].trigger('keydown', {key: 'ArrowDown'})
+ expect(document.activeElement).toBe(headers[0].element)
+ wrapper.unmount()
+ root.remove()
+ })
+
+ it('moves focus to the previous header on ArrowUp', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const wrapper = mountAccordion({}, TWO_ITEMS, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[1].element as HTMLElement).focus()
+ await headers[1].trigger('keydown', {key: 'ArrowUp'})
+ expect(document.activeElement).toBe(headers[0].element)
+ wrapper.unmount()
+ root.remove()
+ })
+
+ it('skips disabled headers during keyboard navigation', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const slot = `
+ A
+ B
+ C
+ `
+ const wrapper = mountAccordion({}, slot, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[0].element as HTMLElement).focus()
+ await headers[0].trigger('keydown', {key: 'ArrowDown'})
+ // saute le header désactivé (B) pour aller directement à C
+ expect(document.activeElement).toBe(headers[2].element)
+ wrapper.unmount()
+ root.remove()
+ })
+})
+
+describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
+ const ONE = `contenu
`
+ const ONE_OPEN = `contenu
`
+
+ it('clips the panel (overflow-hidden) while collapsed', () => {
+ const wrapper = mountAccordion({}, ONE)
+ const inner = wrapper.find('[role="region"] > div')
+ expect(inner.classes()).toContain('overflow-hidden')
+ expect(inner.classes()).not.toContain('overflow-visible')
+ })
+
+ it('lets the panel overflow once open at mount (defaultOpen)', async () => {
+ const wrapper = mountAccordion({}, ONE_OPEN)
+ await nextTick()
+ expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
+ })
+
+ it('switches to overflow-visible after the open transition ends', async () => {
+ const wrapper = mountAccordion({}, ONE)
+ await wrapper.find('button[aria-expanded]').trigger('click')
+ await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
+ expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
+ })
+
+ it('re-clips (overflow-hidden) as soon as it closes', async () => {
+ const wrapper = mountAccordion({}, ONE_OPEN)
+ await nextTick()
+ await wrapper.find('button[aria-expanded]').trigger('click')
+ expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
+ })
+})
diff --git a/app/components/malio/accordion/Accordion.vue b/app/components/malio/accordion/Accordion.vue
new file mode 100644
index 0000000..e3b3317
--- /dev/null
+++ b/app/components/malio/accordion/Accordion.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
diff --git a/app/components/malio/accordion/AccordionItem.test.ts b/app/components/malio/accordion/AccordionItem.test.ts
new file mode 100644
index 0000000..00c742f
--- /dev/null
+++ b/app/components/malio/accordion/AccordionItem.test.ts
@@ -0,0 +1,48 @@
+import {describe, expect, it, vi} from 'vitest'
+import {mount} from '@vue/test-utils'
+import Accordion from './Accordion.vue'
+import AccordionItem from './AccordionItem.vue'
+
+function mountInAccordion(slot: string, accordionProps: Record = {}) {
+ return mount(Accordion, {
+ props: accordionProps,
+ slots: {default: slot},
+ global: {components: {MalioAccordionItem: AccordionItem}},
+ })
+}
+
+describe('MalioAccordionItem', () => {
+ it('throws when used outside MalioAccordion', () => {
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
+ /à l'intérieur de MalioAccordion/,
+ )
+ spy.mockRestore()
+ })
+
+ it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ const header = wrapper.find('button[aria-expanded]')
+ expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
+ await header.trigger('click')
+ expect(header.attributes('aria-expanded')).toBe('true')
+ })
+
+ it('applies headerClass and panelClass overrides via twMerge', () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ const header = wrapper.find('button[aria-expanded]')
+ expect(header.classes()).toContain('bg-red-500')
+ expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
+ })
+
+ it('renders a rotating chevron icon', () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
+ })
+})
diff --git a/app/components/malio/accordion/AccordionItem.vue b/app/components/malio/accordion/AccordionItem.vue
new file mode 100644
index 0000000..2589d3d
--- /dev/null
+++ b/app/components/malio/accordion/AccordionItem.vue
@@ -0,0 +1,126 @@
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
diff --git a/app/components/malio/accordion/context.ts b/app/components/malio/accordion/context.ts
new file mode 100644
index 0000000..d5b1002
--- /dev/null
+++ b/app/components/malio/accordion/context.ts
@@ -0,0 +1,19 @@
+import type {ComputedRef, InjectionKey} from 'vue'
+
+export interface AccordionItemRegistration {
+ value: string
+ getHeaderEl: () => HTMLElement | null
+ isDisabled: () => boolean
+}
+
+export interface AccordionContext {
+ mode: ComputedRef<'single' | 'multiple'>
+ baseId: ComputedRef
+ isOpen: (value: string) => boolean
+ toggle: (value: string) => void
+ register: (item: AccordionItemRegistration, defaultOpen: boolean) => void
+ unregister: (value: string) => void
+ focusSibling: (value: string, offset: 1 | -1) => void
+}
+
+export const accordionContextKey: InjectionKey = Symbol('MalioAccordion')
diff --git a/app/story/accordion/accordion.story.vue b/app/story/accordion/accordion.story.vue
new file mode 100644
index 0000000..3142e63
--- /dev/null
+++ b/app/story/accordion/accordion.story.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
Multiple (filtres) — défaut
+
+
+ Slider de prix ici…
+
+
+ Liste de checkboxes ici…
+
+
+ Recherche + liste ici…
+
+
+
+
+
+
Single (FAQ)
+
+
+ Réponse 1
+
+
+ Réponse 2
+
+
+
+
+
+
Section désactivée
+
+
+ Contenu accessible
+
+
+ Inaccessible
+
+
+
+
+
+
+
+
+# MalioAccordion
+
+Accordéon compositionnel : un parent `MalioAccordion` qui enveloppe des
+`MalioAccordionItem`. Conçu pour des systèmes de filtres (plusieurs sections
+dépliées simultanément) comme pour des FAQ (une seule section ouverte).
+
+---
+
+## Props — MalioAccordion
+
+### mode
+- Type: `'single' | 'multiple'`
+- Défaut: `'multiple'`
+- Description: `multiple` autorise plusieurs panneaux ouverts ; `single` ferme les autres à l'ouverture.
+
+### modelValue
+- Type: `string | string[]`
+- Description: clés ouvertes. `string[]` en mode `multiple`, `string` en mode `single`. Sans v-model, état interne (non contrôlé).
+
+### id
+- Type: `string`
+- Description: préfixe des IDs d'accessibilité. Auto-généré si absent.
+
+### groupClass
+- Type: `string`
+- Description: classes du conteneur, fusionnées via `twMerge`.
+
+---
+
+## Props — MalioAccordionItem
+
+### title
+- Type: `string` (requis) — texte de l'en-tête.
+
+### value
+- Type: `string` — clé unique de la section (recommandée pour piloter le v-model). Auto-générée si absente.
+
+### defaultOpen
+- Type: `boolean` — défaut `false`. Ouvre la section au montage (mode non contrôlé uniquement).
+
+### disabled
+- Type: `boolean` — défaut `false`. En-tête non cliquable.
+
+### headerClass / panelClass
+- Type: `string` — override des classes de l'en-tête / du panneau (`twMerge`).
+
+---
+
+## Slots
+
+Slot par défaut de `MalioAccordionItem` = contenu du panneau.
+
+---
+
+## Accessibilité
+
+- En-tête = `` natif, `aria-expanded`, `aria-controls`.
+- Panneau `role="region"` + `aria-labelledby`.
+- Sections désactivées : `disabled` + `aria-disabled`.
+- Navigation clavier ↑/↓ entre les en-têtes.
+
+---
+
+## Events
+
+### update:modelValue
+- Émis à chaque bascule. Retourne `string[]` (mode `multiple`) ou `string` (mode `single`, `''` si tout fermé).
+
+
+
diff --git a/docs/superpowers/plans/2026-05-26-accordion.md b/docs/superpowers/plans/2026-05-26-accordion.md
new file mode 100644
index 0000000..0db759b
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-26-accordion.md
@@ -0,0 +1,1010 @@
+# MalioAccordion Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ajouter un composant accordéon compositionnel (`` + ``) à `@malio/layer-ui`, conçu pour des systèmes de filtres dans un drawer.
+
+**Architecture:** Deux composants reliés par `provide`/`inject` via une clé `Symbol`. Le parent détient l'état d'ouverture (interne = `string[]`), le mode (`single`/`multiple`, défaut `multiple`) et coordonne la navigation clavier. Chaque enfant injecte le contexte, s'enregistre au montage, et rend un en-tête `` + un panneau animé (`grid-template-rows: 0fr → 1fr`) contenant son slot. 100 % natif, sans Reka UI.
+
+**Tech Stack:** Nuxt 4 layer, Vue 3 `
+```
+
+- [ ] **Step 5: Create the child component**
+
+Créer `app/components/malio/accordion/AccordionItem.vue` :
+
+```vue
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run: `npx vitest run app/components/malio/accordion/Accordion.test.ts`
+Expected: PASS (8 tests).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/components/malio/accordion/
+git commit --no-verify -m "feat(accordion): composant MalioAccordion + AccordionItem (mode multiple) [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+> Note (mémoire projet) : le hook pre-commit est connu pour être flaky sur ce repo → `--no-verify` est toléré. Le lint manuel est lancé en Task 5.
+
+---
+
+## Task 2: Mode single + v-model contrôlé
+
+**Files:**
+- Test: `app/components/malio/accordion/Accordion.test.ts` (ajout d'un bloc `describe`)
+
+- [ ] **Step 1: Write the failing tests**
+
+Ajouter à la fin de `Accordion.test.ts` (après le premier `describe`, le helper `mountAccordion` et `TWO_ITEMS` sont déjà définis en haut du fichier) :
+
+```ts
+describe('MalioAccordion — mode single & contrôlé', () => {
+ it('opening a panel closes the others in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ await headers[1].trigger('click')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('emits a string in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[1].trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
+ })
+
+ it('emits empty string when closing the open panel in single mode', async () => {
+ const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
+ })
+
+ it('respects modelValue array in controlled multiple mode', () => {
+ const wrapper = mountAccordion({modelValue: ['cat']})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('respects modelValue string in controlled single mode', () => {
+ const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('true')
+ expect(headers[1].attributes('aria-expanded')).toBe('false')
+ })
+
+ it('does not mutate local state in controlled mode (emits only)', async () => {
+ const wrapper = mountAccordion({modelValue: []})
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[0].trigger('click')
+ // état piloté par le parent : sans mise à jour de la prop, reste fermé
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
+ })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they pass**
+
+Run: `npx vitest run app/components/malio/accordion/Accordion.test.ts`
+Expected: PASS (tous les tests, dont les 6 nouveaux). L'implémentation de Task 1 couvre déjà ces comportements.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/components/malio/accordion/Accordion.test.ts
+git commit --no-verify -m "test(accordion): mode single et v-model contrôlé [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 3: defaultOpen, disabled & navigation clavier
+
+**Files:**
+- Test: `app/components/malio/accordion/Accordion.test.ts` (ajout d'un bloc `describe`)
+
+- [ ] **Step 1: Write the failing tests**
+
+Ajouter à la fin de `Accordion.test.ts` :
+
+```ts
+describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
+ const WITH_DEFAULT_OPEN = `
+ P
+ C
+ `
+ const WITH_DISABLED = `
+ P
+ C
+ `
+
+ it('opens defaultOpen items initially in uncontrolled mode', async () => {
+ const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN)
+ await nextTick()
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[0].attributes('aria-expanded')).toBe('false')
+ expect(headers[1].attributes('aria-expanded')).toBe('true')
+ })
+
+ it('sets disabled and aria-disabled on a disabled item', () => {
+ const wrapper = mountAccordion({}, WITH_DISABLED)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ expect(headers[1].attributes('disabled')).toBeDefined()
+ expect(headers[1].attributes('aria-disabled')).toBe('true')
+ })
+
+ it('does not toggle a disabled item on click', async () => {
+ const wrapper = mountAccordion({}, WITH_DISABLED)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ await headers[1].trigger('click')
+ expect(headers[1].attributes('aria-expanded')).toBe('false')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('moves focus to the next header on ArrowDown', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const wrapper = mountAccordion({}, TWO_ITEMS, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[0].element as HTMLElement).focus()
+ await headers[0].trigger('keydown', {key: 'ArrowDown'})
+ expect(document.activeElement).toBe(headers[1].element)
+ wrapper.unmount()
+ root.remove()
+ })
+
+ it('wraps focus to the first header on ArrowDown from the last', async () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const wrapper = mountAccordion({}, TWO_ITEMS, root)
+ const headers = wrapper.findAll('button[aria-expanded]')
+ ;(headers[1].element as HTMLElement).focus()
+ await headers[1].trigger('keydown', {key: 'ArrowDown'})
+ expect(document.activeElement).toBe(headers[0].element)
+ wrapper.unmount()
+ root.remove()
+ })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they pass**
+
+Run: `npx vitest run app/components/malio/accordion/Accordion.test.ts`
+Expected: PASS. L'implémentation de Task 1 couvre déjà `defaultOpen`, `disabled` et `focusSibling`.
+
+> Si le test ArrowDown échoue car `document.activeElement` ne change pas : vérifier que le composant est bien monté avec `attachTo` (le helper passe `root`), condition nécessaire pour que `.focus()` fonctionne sous jsdom.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/components/malio/accordion/Accordion.test.ts
+git commit --no-verify -m "test(accordion): defaultOpen, disabled et navigation clavier [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 4: Tests unitaires de AccordionItem (garde provider, value auto, overrides)
+
+**Files:**
+- Test: `app/components/malio/accordion/AccordionItem.test.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+Créer `app/components/malio/accordion/AccordionItem.test.ts` :
+
+```ts
+import {describe, expect, it, vi} from 'vitest'
+import {mount} from '@vue/test-utils'
+import Accordion from './Accordion.vue'
+import AccordionItem from './AccordionItem.vue'
+
+function mountInAccordion(slot: string, accordionProps: Record = {}) {
+ return mount(Accordion, {
+ props: accordionProps,
+ slots: {default: slot},
+ global: {components: {MalioAccordionItem: AccordionItem}},
+ })
+}
+
+describe('MalioAccordionItem', () => {
+ it('throws when used outside MalioAccordion', () => {
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow(
+ /à l'intérieur de MalioAccordion/,
+ )
+ spy.mockRestore()
+ })
+
+ it('generates an auto id-based value and still toggles when value prop is omitted', async () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ const header = wrapper.find('button[aria-expanded]')
+ expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/)
+ await header.trigger('click')
+ expect(header.attributes('aria-expanded')).toBe('true')
+ })
+
+ it('applies headerClass and panelClass overrides via twMerge', () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ const header = wrapper.find('button[aria-expanded]')
+ expect(header.classes()).toContain('bg-red-500')
+ expect(wrapper.find('[role="region"]').html()).toContain('text-lg')
+ })
+
+ it('renders a rotating chevron icon', () => {
+ const wrapper = mountInAccordion(
+ `X
`,
+ )
+ expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true)
+ })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they pass**
+
+Run: `npx vitest run app/components/malio/accordion/AccordionItem.test.ts`
+Expected: PASS (4 tests). L'implémentation existante couvre ces cas.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/components/malio/accordion/AccordionItem.test.ts
+git commit --no-verify -m "test(accordion): tests unitaires AccordionItem [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 5: Vérification globale (tests + lint)
+
+**Files:** aucun (vérification)
+
+- [ ] **Step 1: Run the full test suite**
+
+Run: `npm run test`
+Expected: PASS — toute la suite, dont `Accordion.test.ts` et `AccordionItem.test.ts`.
+
+- [ ] **Step 2: Run the linter**
+
+Run: `npm run lint`
+Expected: aucune erreur. Si ESLint signale des soucis de style (quotes, indentation, imports de type), les corriger dans les fichiers `accordion/` jusqu'à un run propre.
+
+- [ ] **Step 3: Commit (si des corrections de lint ont été faites)**
+
+```bash
+git add app/components/malio/accordion/
+git commit --no-verify -m "style(accordion): corrections lint [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+> Si aucun changement, passer cette étape (rien à committer).
+
+---
+
+## Task 6: Page playground + navigation
+
+**Files:**
+- Create: `.playground/pages/composant/accordion/accordion.vue`
+- Modify: `.playground/playground.nav.ts`
+
+- [ ] **Step 1: Create the playground page**
+
+Créer `.playground/pages/composant/accordion/accordion.vue` :
+
+```vue
+
+
+
+
Multiple (filtres) — défaut
+
+
+ Slider de prix ici…
+
+
+ Liste de checkboxes ici…
+
+
+ Recherche + liste ici…
+
+
+
Ouverts : {{ multiple }}
+
+
+
+
Single (FAQ)
+
+
+ Réponse 1
+
+
+ Réponse 2
+
+
+
Ouvert : {{ single }}
+
+
+
+
Non contrôlé + defaultOpen
+
+
+ Ouverte au montage
+
+
+ Fermée au montage
+
+
+
+
+
+
Section désactivée
+
+
+ Contenu accessible
+
+
+ Inaccessible
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add the nav entry**
+
+Modifier `.playground/playground.nav.ts` — dans la section `NAVIGATION` (celle qui contient `Onglets`), ajouter une entrée après `Onglets` :
+
+```ts
+ {label: 'Onglets', to: '/composant/tab/tabList'},
+ {label: 'Accordéon', to: '/composant/accordion/accordion'},
+```
+
+- [ ] **Step 3: Verify the page renders**
+
+Run: `npm run dev` puis ouvrir `http://localhost:3000/composant/accordion/accordion` (ou vérifier que `npm run dev:prepare` ne génère pas d'erreur de type). Confirmer visuellement : les 4 cartes s'affichent, l'ouverture/fermeture s'anime, le chevron pivote. Arrêter le serveur ensuite (Ctrl+C).
+
+> Si la route renvoie 404, vérifier que le fichier est bien sous `.playground/pages/composant/accordion/accordion.vue` (le chemin détermine la route).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .playground/pages/composant/accordion/accordion.vue .playground/playground.nav.ts
+git commit --no-verify -m "feat(accordion): page playground + entrée nav [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 7: Story Histoire
+
+**Files:**
+- Create: `app/story/accordion/accordion.story.vue`
+
+- [ ] **Step 1: Create the story**
+
+Créer `app/story/accordion/accordion.story.vue` :
+
+```vue
+
+
+
+
+
Multiple (filtres) — défaut
+
+
+ Slider de prix ici…
+
+
+ Liste de checkboxes ici…
+
+
+ Recherche + liste ici…
+
+
+
+
+
+
Single (FAQ)
+
+
+ Réponse 1
+
+
+ Réponse 2
+
+
+
+
+
+
Section désactivée
+
+
+ Contenu accessible
+
+
+ Inaccessible
+
+
+
+
+
+
+
+
+# MalioAccordion
+
+Accordéon compositionnel : un parent `MalioAccordion` qui enveloppe des
+`MalioAccordionItem`. Conçu pour des systèmes de filtres (plusieurs sections
+dépliées simultanément) comme pour des FAQ (une seule section ouverte).
+
+---
+
+## Props — MalioAccordion
+
+### mode
+- Type: `'single' | 'multiple'`
+- Défaut: `'multiple'`
+- Description: `multiple` autorise plusieurs panneaux ouverts ; `single` ferme les autres à l'ouverture.
+
+### modelValue
+- Type: `string | string[]`
+- Description: clés ouvertes. `string[]` en mode `multiple`, `string` en mode `single`. Sans v-model, état interne (non contrôlé).
+
+### id
+- Type: `string`
+- Description: préfixe des IDs d'accessibilité. Auto-généré si absent.
+
+### groupClass
+- Type: `string`
+- Description: classes du conteneur, fusionnées via `twMerge`.
+
+---
+
+## Props — MalioAccordionItem
+
+### title
+- Type: `string` (requis) — texte de l'en-tête.
+
+### value
+- Type: `string` — clé unique de la section (recommandée pour piloter le v-model). Auto-générée si absente.
+
+### defaultOpen
+- Type: `boolean` — défaut `false`. Ouvre la section au montage (mode non contrôlé uniquement).
+
+### disabled
+- Type: `boolean` — défaut `false`. En-tête non cliquable.
+
+### headerClass / panelClass
+- Type: `string` — override des classes de l'en-tête / du panneau (`twMerge`).
+
+---
+
+## Slots
+
+Slot par défaut de `MalioAccordionItem` = contenu du panneau.
+
+---
+
+## Accessibilité
+
+- En-tête = `` natif, `aria-expanded`, `aria-controls`.
+- Panneau `role="region"` + `aria-labelledby`.
+- Sections désactivées : `disabled` + `aria-disabled`.
+- Navigation clavier ↑/↓ entre les en-têtes.
+
+---
+
+## Events
+
+### update:modelValue
+- Émis à chaque bascule. Retourne `string[]` (mode `multiple`) ou `string` (mode `single`, `''` si tout fermé).
+
+
+
+```
+
+> Note : les imports explicites de `MalioAccordion`/`MalioAccordionItem` sont nécessaires dans les stories (pas d'auto-import dans Histoire), conformément au pattern de `tabList.story.vue`.
+
+- [ ] **Step 2: Verify the story builds**
+
+Run: `npm run story:dev` puis vérifier dans le navigateur que la story `Disclosure/Accordion` s'affiche avec ses 3 variantes interactives. Arrêter ensuite (Ctrl+C).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/story/accordion/accordion.story.vue
+git commit --no-verify -m "docs(accordion): story Histoire [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 8: CHANGELOG + COMPONENTS.md
+
+**Files:**
+- Modify: `CHANGELOG.md`
+- Modify: `COMPONENTS.md`
+
+- [ ] **Step 1: Add the CHANGELOG line**
+
+Dans `CHANGELOG.md`, sous `### Added` de la version `[0.0.0]`, ajouter à la fin de la liste :
+
+```
+* [#MUI-37] Création d'un composant accordéon
+```
+
+- [ ] **Step 2: Add the COMPONENTS.md section**
+
+Dans `COMPONENTS.md`, ajouter une section (placée près des composants de navigation/disclosure, p. ex. après la section Onglets/Tab si elle existe, sinon à la fin) :
+
+```markdown
+## MalioAccordion
+
+Accordéon compositionnel : `` enveloppe des ``. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ.
+
+### MalioAccordion
+
+| Prop | Type | Défaut | Description |
+|------|------|--------|-------------|
+| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
+| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` |
+| `id` | `string` | auto | Préfixe des IDs d'accessibilité |
+| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) |
+
+**Events :** `update:modelValue(value: string | string[])`
+
+### MalioAccordionItem
+
+| Prop | Type | Défaut | Description |
+|------|------|--------|-------------|
+| `title` | `string` | — | Texte de l'en-tête |
+| `value` | `string` | auto | Clé unique de la section |
+| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) |
+| `disabled` | `boolean` | `false` | En-tête non cliquable |
+| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) |
+| `panelClass` | `string` | `''` | Override classes panneau (twMerge) |
+
+**Slot :** par défaut = contenu du panneau.
+
+\`\`\`vue
+
+
+
+
+
+
+
+
+
+
+
+
+ Réponse 1
+ Réponse 2
+
+\`\`\`
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add CHANGELOG.md COMPONENTS.md
+git commit --no-verify -m "docs(accordion): mise à jour CHANGELOG et COMPONENTS [#MUI-37]
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Notes d'implémentation
+
+- **Pourquoi `grid-template-rows: 0fr → 1fr`** : anime la hauteur sans connaître la hauteur du contenu à l'avance (pas de mesure JS). Le wrapper interne `overflow-hidden` masque le débordement pendant la transition.
+- **`inert` sur le contenu fermé** : empêche le focus clavier d'entrer dans un panneau replié (le contenu reste dans le DOM pour l'animation). Inoffensif sous jsdom (simple attribut).
+- **Réactivité de `isOpen`** : `open = computed(() => ctx.isOpen(value.value))` dans l'enfant ; le `computed` suit `openKeys` (lui-même `computed` du parent) car la dépendance est lue pendant l'évaluation.
+- **Ordre d'enregistrement** : les enfants s'enregistrent dans l'ordre du DOM (montage séquentiel), ce qui rend `focusSibling` correct pour la navigation ↑/↓.
+- **Convention commits** : `--no-verify` toléré sur ce repo (hook pre-commit flaky, cf. mémoire projet). Le lint est validé manuellement en Task 5.
+```
diff --git a/docs/superpowers/specs/2026-05-26-accordion-design.md b/docs/superpowers/specs/2026-05-26-accordion-design.md
new file mode 100644
index 0000000..695a46b
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-26-accordion-design.md
@@ -0,0 +1,167 @@
+# Spec — Composant Accordéon ``
+
+**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 `` qui enveloppe des
+enfants ``. 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
+
+### ``
+
+`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`.
+
+### ``
+
+`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 `` → 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.