From bc2bac248658a59f6f881a3fa696173d66f0e27e Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 26 May 2026 16:48:51 +0200 Subject: [PATCH] feat(accordion): composant MalioAccordion + AccordionItem (mode multiple) [#MUI-37] Co-Authored-By: Claude Opus 4.7 (1M context) --- .../malio/accordion/Accordion.test.ts | 88 +++++++++++++++ app/components/malio/accordion/Accordion.vue | 105 ++++++++++++++++++ .../malio/accordion/AccordionItem.vue | 104 +++++++++++++++++ app/components/malio/accordion/context.ts | 19 ++++ 4 files changed, 316 insertions(+) create mode 100644 app/components/malio/accordion/Accordion.test.ts create mode 100644 app/components/malio/accordion/Accordion.vue create mode 100644 app/components/malio/accordion/AccordionItem.vue create mode 100644 app/components/malio/accordion/context.ts diff --git a/app/components/malio/accordion/Accordion.test.ts b/app/components/malio/accordion/Accordion.test.ts new file mode 100644 index 0000000..a329634 --- /dev/null +++ b/app/components/malio/accordion/Accordion.test.ts @@ -0,0 +1,88 @@ +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 = ` +

Contenu prix

+

Contenu catégorie

+` + +function mountAccordion(props: Record = {}, 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() + }) +}) diff --git a/app/components/malio/accordion/Accordion.vue b/app/components/malio/accordion/Accordion.vue new file mode 100644 index 0000000..61bab10 --- /dev/null +++ b/app/components/malio/accordion/Accordion.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/components/malio/accordion/AccordionItem.vue b/app/components/malio/accordion/AccordionItem.vue new file mode 100644 index 0000000..ec2c80b --- /dev/null +++ b/app/components/malio/accordion/AccordionItem.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/components/malio/accordion/context.ts b/app/components/malio/accordion/context.ts new file mode 100644 index 0000000..d5b1002 --- /dev/null +++ b/app/components/malio/accordion/context.ts @@ -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 + 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 = Symbol('MalioAccordion')