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

35 KiB

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 :

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 :

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 :

<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 :

<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
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) :

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
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 :

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
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 :

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
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)
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 :

<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 :

      {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
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 :

<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
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) :

## 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
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.