6efb830ffe
| 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>
1011 lines
35 KiB
Markdown
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.
|
|
```
|