[#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>
This commit was merged in pull request #54.
This commit is contained in:
2026-05-27 07:12:10 +00:00
committed by Autin
parent 7b838c60ca
commit 6efb830ffe
13 changed files with 2062 additions and 0 deletions
@@ -0,0 +1,256 @@
import {describe, expect, it} from 'vitest'
import {mount} from '@vue/test-utils'
import {nextTick} from 'vue'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
const TWO_ITEMS = `
<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()
})
})
describe('MalioAccordion — mode single & contrôlé', () => {
it('opening a panel closes the others in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
await headers[1].trigger('click')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('emits a string in single mode', async () => {
const wrapper = mountAccordion({mode: 'single'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[1].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat'])
})
it('emits empty string when closing the open panel in single mode', async () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('respects modelValue array in controlled multiple mode', () => {
const wrapper = mountAccordion({modelValue: ['cat']})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(headers[1].attributes('aria-expanded')).toBe('true')
})
it('respects modelValue string in controlled single mode', () => {
const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'})
const headers = wrapper.findAll('button[aria-expanded]')
expect(headers[0].attributes('aria-expanded')).toBe('true')
expect(headers[1].attributes('aria-expanded')).toBe('false')
})
it('does not mutate local state in controlled mode (emits only)', async () => {
const wrapper = mountAccordion({modelValue: []})
const headers = wrapper.findAll('button[aria-expanded]')
await headers[0].trigger('click')
// état piloté par le parent : sans mise à jour de la prop, reste fermé
expect(headers[0].attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']])
})
})
describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
const WITH_DEFAULT_OPEN = `
<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()
})
it('moves focus to the previous header on ArrowUp', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const wrapper = mountAccordion({}, TWO_ITEMS, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[1].element as HTMLElement).focus()
await headers[1].trigger('keydown', {key: 'ArrowUp'})
expect(document.activeElement).toBe(headers[0].element)
wrapper.unmount()
root.remove()
})
it('skips disabled headers during keyboard navigation', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const slot = `
<MalioAccordionItem title="A" value="a"><p>A</p></MalioAccordionItem>
<MalioAccordionItem title="B" value="b" :disabled="true"><p>B</p></MalioAccordionItem>
<MalioAccordionItem title="C" value="c"><p>C</p></MalioAccordionItem>
`
const wrapper = mountAccordion({}, slot, root)
const headers = wrapper.findAll('button[aria-expanded]')
;(headers[0].element as HTMLElement).focus()
await headers[0].trigger('keydown', {key: 'ArrowDown'})
// saute le header désactivé (B) pour aller directement à C
expect(document.activeElement).toBe(headers[2].element)
wrapper.unmount()
root.remove()
})
})
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
it('clips the panel (overflow-hidden) while collapsed', () => {
const wrapper = mountAccordion({}, ONE)
const inner = wrapper.find('[role="region"] > div')
expect(inner.classes()).toContain('overflow-hidden')
expect(inner.classes()).not.toContain('overflow-visible')
})
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('switches to overflow-visible after the open transition ends', async () => {
const wrapper = mountAccordion({}, ONE)
await wrapper.find('button[aria-expanded]').trigger('click')
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('re-clips (overflow-hidden) as soon as it closes', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
await wrapper.find('button[aria-expanded]').trigger('click')
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
})
})
@@ -0,0 +1,109 @@
<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)
}
// `items` est ordonné par ordre de montage (= ordre du DOM pour des sections
// statiques/ajoutées en fin). Si un consommateur réordonne dynamiquement les
// items, cet ordre peut diverger de l'ordre visuel ; trier par position DOM
// serait alors nécessaire (hors périmètre v1).
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-black border-y border-black', props.groupClass),
)
provide(accordionContextKey, {
mode,
baseId,
isOpen,
toggle,
register,
unregister,
focusSibling,
})
</script>
@@ -0,0 +1,48 @@
import {describe, expect, it, vi} from 'vitest'
import {mount} from '@vue/test-utils'
import Accordion from './Accordion.vue'
import AccordionItem from './AccordionItem.vue'
function mountInAccordion(slot: string, accordionProps: Record<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)
})
})
@@ -0,0 +1,126 @@
<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]'"
@transitionend="onPanelTransitionEnd"
>
<div
:class="overflowVisible ? 'overflow-visible' : '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, watch} 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))
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
// pas rogné. On re-clippe dès le début de la fermeture.
const overflowVisible = ref(false)
watch(open, (isOpen) => {
if (!isOpen) overflowVisible.value = false
})
function onPanelTransitionEnd(e: TransitionEvent) {
if (e.propertyName === 'grid-template-rows' && open.value) {
overflowVisible.value = true
}
}
function onToggle() {
if (props.disabled) return
ctx.toggle(value.value)
}
const headerClasses = computed(() =>
twMerge(
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass,
),
)
const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
onMounted(() => {
ctx.register(
{
value: value.value,
getHeaderEl: () => headerRef.value,
isDisabled: () => props.disabled,
},
props.defaultOpen,
)
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
if (open.value) overflowVisible.value = true
})
onBeforeUnmount(() => ctx.unregister(value.value))
</script>
+19
View File
@@ -0,0 +1,19 @@
import type {ComputedRef, InjectionKey} from 'vue'
export interface AccordionItemRegistration {
value: string
getHeaderEl: () => HTMLElement | null
isDisabled: () => boolean
}
export interface AccordionContext {
mode: ComputedRef<'single' | 'multiple'>
baseId: ComputedRef<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')