| 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é --------- Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-authored-by: matthieu <matthieu@yuno.malio.fr> Reviewed-on: #56 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
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-verifyest 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 blocdescribe) -
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 blocdescribe) -
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.activeElementne change pas : vérifier que le composant est bien monté avecattachTo(le helper passeroot), 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/MalioAccordionItemsont nécessaires dans les stories (pas d'auto-import dans Histoire), conformément au pattern detabList.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 interneoverflow-hiddenmasque le débordement pendant la transition. inertsur 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 ; lecomputedsuitopenKeys(lui-mêmecomputeddu 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
focusSiblingcorrect pour la navigation ↑/↓. - Convention commits :
--no-verifytoléré sur ce repo (hook pre-commit flaky, cf. mémoire projet). Le lint est validé manuellement en Task 5.