Files
malio-layer-ui/docs/superpowers/plans/2026-05-26-accordion.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

1011 lines
35 KiB
Markdown

# 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 (`<MalioAccordion>` + `<MalioAccordionItem>`) à `@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 `<button>` + un panneau animé (`grid-template-rows: 0fr → 1fr`) contenant son slot. 100 % natif, sans Reka UI.
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (palette `m-*`), `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
**Spec de référence:** `docs/superpowers/specs/2026-05-26-accordion-design.md`
---
## Structure des fichiers
| Fichier | Responsabilité |
|---------|----------------|
| `app/components/malio/accordion/context.ts` | Type `AccordionContext` + `InjectionKey` partagé parent/enfant |
| `app/components/malio/accordion/Accordion.vue` | Parent : état, mode, provide, navigation clavier |
| `app/components/malio/accordion/AccordionItem.vue` | Enfant : en-tête bouton + panneau animé + slot |
| `app/components/malio/accordion/Accordion.test.ts` | Tests d'intégration (comportement des deux composants ensemble) |
| `app/components/malio/accordion/AccordionItem.test.ts` | Tests unitaires de l'enfant (garde provider, overrides de classes, value auto) |
| `.playground/pages/composant/accordion/accordion.vue` | Page playground (route `/composant/accordion/accordion`) |
| `.playground/playground.nav.ts` | Ajout de l'entrée nav (modif) |
| `app/story/accordion/accordion.story.vue` | Story Histoire |
| `CHANGELOG.md` | Ligne `### Added` (modif) |
| `COMPONENTS.md` | Section de doc du composant (modif) |
---
## Task 1: Contexte + composants Accordion & AccordionItem (rendu + toggle mode multiple)
**Files:**
- Create: `app/components/malio/accordion/context.ts`
- Create: `app/components/malio/accordion/Accordion.vue`
- Create: `app/components/malio/accordion/AccordionItem.vue`
- Test: `app/components/malio/accordion/Accordion.test.ts`
- [ ] **Step 1: Write the failing test**
Créer `app/components/malio/accordion/Accordion.test.ts` :
```ts
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 = `
<MalioAccordionItem title="Prix" value="prix"><p>Contenu prix</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat"><p>Contenu catégorie</p></MalioAccordionItem>
`
function mountAccordion(props: Record<string, unknown> = {}, 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()
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run app/components/malio/accordion/Accordion.test.ts`
Expected: FAIL — `Failed to resolve import "./Accordion.vue"` (les fichiers n'existent pas encore).
- [ ] **Step 3: Create the context module**
Créer `app/components/malio/accordion/context.ts` :
```ts
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<string>
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<AccordionContext> = Symbol('MalioAccordion')
```
- [ ] **Step 4: Create the parent component**
Créer `app/components/malio/accordion/Accordion.vue` :
```vue
<template>
<div v-bind="$attrs" :class="rootClass">
<slot />
</div>
</template>
<script setup lang="ts">
import {computed, provide, ref, useId} from 'vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey, type AccordionItemRegistration} from './context'
defineOptions({name: 'MalioAccordion', inheritAttrs: false})
const props = withDefaults(defineProps<{
mode?: 'single' | 'multiple'
modelValue?: string | string[]
id?: string
groupClass?: string
}>(), {
mode: 'multiple',
modelValue: undefined,
id: '',
groupClass: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const generatedId = useId()
const baseId = computed(() => props.id || `malio-accordion-${generatedId}`)
const mode = computed(() => props.mode)
const isControlled = computed(() => props.modelValue !== undefined)
const localOpen = ref<string[]>([])
const items = ref<AccordionItemRegistration[]>([])
const openKeys = computed<string[]>(() => {
if (isControlled.value) {
const v = props.modelValue
if (props.mode === 'single') return v ? [v as string] : []
if (Array.isArray(v)) return v
return v ? [v as string] : []
}
return localOpen.value
})
function isOpen(value: string) {
return openKeys.value.includes(value)
}
function toggle(value: string) {
const current = openKeys.value
let next: string[]
if (props.mode === 'single') {
next = current.includes(value) ? [] : [value]
} else {
next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
}
if (!isControlled.value) {
localOpen.value = next
}
emit('update:modelValue', props.mode === 'single' ? (next[0] ?? '') : next)
}
function register(item: AccordionItemRegistration, defaultOpen: boolean) {
items.value.push(item)
if (defaultOpen && !isControlled.value) {
if (props.mode === 'single') {
if (localOpen.value.length === 0) localOpen.value = [item.value]
} else if (!localOpen.value.includes(item.value)) {
localOpen.value.push(item.value)
}
}
}
function unregister(value: string) {
items.value = items.value.filter(i => i.value !== value)
}
function focusSibling(value: string, offset: 1 | -1) {
const enabled = items.value.filter(i => !i.isDisabled())
const idx = enabled.findIndex(i => i.value === value)
if (idx === -1) return
const next = enabled[(idx + offset + enabled.length) % enabled.length]
next?.getHeaderEl()?.focus()
}
const rootClass = computed(() =>
twMerge('divide-y divide-m-border overflow-hidden rounded-malio border border-m-border', props.groupClass),
)
provide(accordionContextKey, {
mode,
baseId,
isOpen,
toggle,
register,
unregister,
focusSibling,
})
</script>
```
- [ ] **Step 5: Create the child component**
Créer `app/components/malio/accordion/AccordionItem.vue` :
```vue
<template>
<div>
<h3 class="m-0">
<button
:id="headerId"
ref="headerRef"
type="button"
:class="headerClasses"
:aria-expanded="open"
:aria-controls="panelId"
:disabled="disabled"
:aria-disabled="disabled || undefined"
@click="onToggle"
@keydown.down.prevent="ctx.focusSibling(value, 1)"
@keydown.up.prevent="ctx.focusSibling(value, -1)"
>
<span>{{ title }}</span>
<IconifyIcon
icon="mdi:chevron-down"
:width="24"
class="shrink-0 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
/>
</button>
</h3>
<div
:id="panelId"
role="region"
:aria-labelledby="headerId"
class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="overflow-hidden" :inert="!open || undefined">
<div :class="panelInnerClass">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, ref, useId} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import {accordionContextKey} from './context'
defineOptions({name: 'MalioAccordionItem', inheritAttrs: false})
const props = withDefaults(defineProps<{
title: string
value?: string
defaultOpen?: boolean
disabled?: boolean
headerClass?: string
panelClass?: string
}>(), {
value: '',
defaultOpen: false,
disabled: false,
headerClass: '',
panelClass: '',
})
const ctx = inject(accordionContextKey)
if (!ctx) {
throw new Error('MalioAccordionItem doit être utilisé à l\'intérieur de MalioAccordion')
}
const generatedId = useId()
const value = computed(() => props.value || `malio-accordion-item-${generatedId}`)
const headerRef = ref<HTMLButtonElement | null>(null)
const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
const open = computed(() => ctx.isOpen(value.value))
function onToggle() {
if (props.disabled) return
ctx.toggle(value.value)
}
const headerClasses = computed(() =>
twMerge(
'flex w-full items-center justify-between gap-4 px-4 py-3 text-left font-medium text-m-text transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass,
),
)
const panelInnerClass = computed(() => twMerge('px-4 py-3 text-m-text', props.panelClass))
onMounted(() => {
ctx.register(
{
value: value.value,
getHeaderEl: () => headerRef.value,
isDisabled: () => props.disabled,
},
props.defaultOpen,
)
})
onBeforeUnmount(() => ctx.unregister(value.value))
</script>
```
- [ ] **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) <noreply@anthropic.com>"
```
> 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) <noreply@anthropic.com>"
```
---
## 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 = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :default-open="true"><p>C</p></MalioAccordionItem>
`
const WITH_DISABLED = `
<MalioAccordionItem title="Prix" value="prix"><p>P</p></MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat" :disabled="true"><p>C</p></MalioAccordionItem>
`
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) <noreply@anthropic.com>"
```
---
## 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<string, unknown> = {}) {
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(
`<MalioAccordionItem title="Sans value"><p>X</p></MalioAccordionItem>`,
)
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(
`<MalioAccordionItem title="T" value="t" header-class="bg-red-500" panel-class="text-lg"><p>X</p></MalioAccordionItem>`,
)
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(
`<MalioAccordionItem title="T" value="t"><p>X</p></MalioAccordionItem>`,
)
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
> 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
<template>
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouverts : {{ multiple }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
<p class="mt-2 text-sm text-gray-500">Ouvert : {{ single }}</p>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non contrôlé + defaultOpen</h2>
<MalioAccordion>
<MalioAccordionItem title="Section A" value="a" :default-open="true">
<p>Ouverte au montage</p>
</MalioAccordionItem>
<MalioAccordionItem title="Section B" value="b">
<p>Fermée au montage</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
```
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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
<template>
<Story title="Disclosure/Accordion">
<div class="grid grid-cols-1 gap-6">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Multiple (filtres) défaut</h2>
<MalioAccordion v-model="multiple">
<MalioAccordionItem title="Prix" value="prix">
<p>Slider de prix ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<p>Liste de checkboxes ici</p>
</MalioAccordionItem>
<MalioAccordionItem title="Marque" value="marque">
<p>Recherche + liste ici</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Single (FAQ)</h2>
<MalioAccordion v-model="single" mode="single">
<MalioAccordionItem title="Question 1" value="q1">
<p>Réponse 1</p>
</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">
<p>Réponse 2</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Section désactivée</h2>
<MalioAccordion>
<MalioAccordionItem title="Active" value="ok">
<p>Contenu accessible</p>
</MalioAccordionItem>
<MalioAccordionItem title="Désactivée" value="ko" :disabled="true">
<p>Inaccessible</p>
</MalioAccordionItem>
</MalioAccordion>
</div>
</div>
</Story>
</template>
<docs lang="md">
# 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 = `<button>` 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é).
</docs>
<script setup lang="ts">
import {ref} from 'vue'
import MalioAccordion from '../../components/malio/accordion/Accordion.vue'
import MalioAccordionItem from '../../components/malio/accordion/AccordionItem.vue'
const multiple = ref<string[]>(['prix'])
const single = ref('q1')
</script>
```
> 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) <noreply@anthropic.com>"
```
---
## 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 : `<MalioAccordion>` enveloppe des `<MalioAccordionItem>`. 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
<!-- Filtres : plusieurs sections ouvertes -->
<MalioAccordion v-model="ouverts">
<MalioAccordionItem title="Prix" value="prix">
<MalioInputAmount v-model="prix" />
</MalioAccordionItem>
<MalioAccordionItem title="Catégorie" value="cat">
<MalioCheckbox v-model="cats" />
</MalioAccordionItem>
</MalioAccordion>
<!-- FAQ : une seule section ouverte -->
<MalioAccordion mode="single">
<MalioAccordionItem title="Question 1" value="q1">Réponse 1</MalioAccordionItem>
<MalioAccordionItem title="Question 2" value="q2">Réponse 2</MalioAccordionItem>
</MalioAccordion>
\`\`\`
```
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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.
```