From 6efb830ffe358324e449b63e18baa4a645499037 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 27 May 2026 07:12:10 +0000 Subject: [PATCH] =?UTF-8?q?[#MUI-37]=20Cr=C3=A9ation=20d'un=20composant=20?= =?UTF-8?q?accord=C3=A9on=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | 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: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/54 Co-authored-by: tristan Co-committed-by: tristan --- .../pages/composant/accordion/accordion.vue | 63 + .../pages/composant/filtre/filtres.vue | 88 ++ .playground/playground.nav.ts | 2 + CHANGELOG.md | 1 + COMPONENTS.md | 48 + .../malio/accordion/Accordion.test.ts | 256 +++++ app/components/malio/accordion/Accordion.vue | 109 ++ .../malio/accordion/AccordionItem.test.ts | 48 + .../malio/accordion/AccordionItem.vue | 126 ++ app/components/malio/accordion/context.ts | 19 + app/story/accordion/accordion.story.vue | 125 ++ .../superpowers/plans/2026-05-26-accordion.md | 1010 +++++++++++++++++ .../specs/2026-05-26-accordion-design.md | 167 +++ 13 files changed, 2062 insertions(+) create mode 100644 .playground/pages/composant/accordion/accordion.vue create mode 100644 .playground/pages/composant/filtre/filtres.vue 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.test.ts create mode 100644 app/components/malio/accordion/AccordionItem.vue create mode 100644 app/components/malio/accordion/context.ts create mode 100644 app/story/accordion/accordion.story.vue create mode 100644 docs/superpowers/plans/2026-05-26-accordion.md create mode 100644 docs/superpowers/specs/2026-05-26-accordion-design.md diff --git a/.playground/pages/composant/accordion/accordion.vue b/.playground/pages/composant/accordion/accordion.vue new file mode 100644 index 0000000..c8aeb8d --- /dev/null +++ b/.playground/pages/composant/accordion/accordion.vue @@ -0,0 +1,63 @@ + + + diff --git a/.playground/pages/composant/filtre/filtres.vue b/.playground/pages/composant/filtre/filtres.vue new file mode 100644 index 0000000..9a3ed44 --- /dev/null +++ b/.playground/pages/composant/filtre/filtres.vue @@ -0,0 +1,88 @@ + + + diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts index 8d9e458..d7d6eeb 100644 --- a/.playground/playground.nav.ts +++ b/.playground/playground.nav.ts @@ -54,6 +54,7 @@ export const navSections: SidebarSection[] = [ {label: 'Drawer', to: '/composant/drawer/drawer'}, {label: 'Modal', to: '/composant/modal/modal'}, {label: 'Onglets', to: '/composant/tab/tabList'}, + {label: 'Accordéon', to: '/composant/accordion/accordion'}, ], }, { @@ -70,6 +71,7 @@ export const navSections: SidebarSection[] = [ {label: 'Heure', to: '/composant/time/time'}, {label: 'Sélecteur de site', to: '/composant/site/siteSelector'}, {label: 'Formulaire client', to: '/composant/form/client'}, + {label: 'Filtres', to: '/composant/filtre/filtres'}, ], }, ] diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e9180..466fc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-33] Développer le composant Datepicker * [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire) * [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe) +* [#MUI-37] Création d'un composant accordéon ### Changed * [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`. diff --git a/COMPONENTS.md b/COMPONENTS.md index 15bf628..715e076 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -694,6 +694,54 @@ const tabs = computed(() => [ --- +## MalioAccordion + +Accordéon compositionnel : `` enveloppe des ``. 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 + + + + + + + + + + + + + Réponse 1 + Réponse 2 + +``` + +--- + ## MalioSidebar Barre latérale de navigation rétractable. diff --git a/app/components/malio/accordion/Accordion.test.ts b/app/components/malio/accordion/Accordion.test.ts new file mode 100644 index 0000000..376c88a --- /dev/null +++ b/app/components/malio/accordion/Accordion.test.ts @@ -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 = ` +

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() + }) +}) + +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 = ` +

P

+

C

+ ` + const WITH_DISABLED = ` +

P

+

C

+ ` + + 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 = ` +

A

+

B

+

C

+ ` + 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 = `

contenu

` + const ONE_OPEN = `

contenu

` + + 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') + }) +}) diff --git a/app/components/malio/accordion/Accordion.vue b/app/components/malio/accordion/Accordion.vue new file mode 100644 index 0000000..e3b3317 --- /dev/null +++ b/app/components/malio/accordion/Accordion.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/components/malio/accordion/AccordionItem.test.ts b/app/components/malio/accordion/AccordionItem.test.ts new file mode 100644 index 0000000..00c742f --- /dev/null +++ b/app/components/malio/accordion/AccordionItem.test.ts @@ -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 = {}) { + 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( + `

X

`, + ) + 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( + `

X

`, + ) + 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( + `

X

`, + ) + expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true) + }) +}) diff --git a/app/components/malio/accordion/AccordionItem.vue b/app/components/malio/accordion/AccordionItem.vue new file mode 100644 index 0000000..2589d3d --- /dev/null +++ b/app/components/malio/accordion/AccordionItem.vue @@ -0,0 +1,126 @@ + + + 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') diff --git a/app/story/accordion/accordion.story.vue b/app/story/accordion/accordion.story.vue new file mode 100644 index 0000000..3142e63 --- /dev/null +++ b/app/story/accordion/accordion.story.vue @@ -0,0 +1,125 @@ + + + +# 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 = `