[#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:
@@ -0,0 +1,63 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-[1348px]">
|
||||||
|
<div class="flex items-center justify-between mt-[46px]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] text-m-primary font-bold">Filtres</h1>
|
||||||
|
</div>
|
||||||
|
<MalioButton
|
||||||
|
label="Filtres"
|
||||||
|
variant="tertiary"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[184px] px-2 py-2 justify-start text-black gap-4"
|
||||||
|
@click="drawerOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
side="right"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="sticky bottom-0 flex justify-between gap-4 bg-white px-5 py-7"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">Filtres</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Type de camion" value="camion">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<MalioCheckbox v-model="semiBenne" label="Semi Benne" />
|
||||||
|
<MalioCheckbox v-model="benne" label="Benne" />
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Date à Date" value="date">
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||||
|
<span>Du</span>
|
||||||
|
<MalioDate v-model="dateDebut"/>
|
||||||
|
<span>Au</span>
|
||||||
|
<MalioDate v-model="dateFin"/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
label="Réinitialiser"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="resetFiltres"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
label="Voir les résultats"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref} from 'vue'
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
|
||||||
|
const semiBenne = ref(false)
|
||||||
|
const benne = ref(false)
|
||||||
|
|
||||||
|
const dateDebut = ref<string | null>(null)
|
||||||
|
const dateFin = ref<string | null>(null)
|
||||||
|
|
||||||
|
function resetFiltres() {
|
||||||
|
semiBenne.value = false
|
||||||
|
benne.value = false
|
||||||
|
dateDebut.value = null
|
||||||
|
dateFin.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -54,6 +54,7 @@ export const navSections: SidebarSection[] = [
|
|||||||
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
{label: 'Drawer', to: '/composant/drawer/drawer'},
|
||||||
{label: 'Modal', to: '/composant/modal/modal'},
|
{label: 'Modal', to: '/composant/modal/modal'},
|
||||||
{label: 'Onglets', to: '/composant/tab/tabList'},
|
{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: 'Heure', to: '/composant/time/time'},
|
||||||
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
{label: 'Sélecteur de site', to: '/composant/site/siteSelector'},
|
||||||
{label: 'Formulaire client', to: '/composant/form/client'},
|
{label: 'Formulaire client', to: '/composant/form/client'},
|
||||||
|
{label: 'Filtres', to: '/composant/filtre/filtres'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Liste des évolutions de la librairie Malio layer UI
|
|||||||
* [#MUI-33] Développer le composant Datepicker
|
* [#MUI-33] Développer le composant Datepicker
|
||||||
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
* [#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-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
|
### 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`.
|
* [#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`.
|
||||||
|
|||||||
@@ -694,6 +694,54 @@ const tabs = computed(() => [
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MalioSidebar
|
## MalioSidebar
|
||||||
|
|
||||||
Barre latérale de navigation rétractable.
|
Barre latérale de navigation rétractable.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
defineOptions({ name: 'AccordionStory' })
|
||||||
|
|
||||||
|
const multiple = ref<string[]>(['prix'])
|
||||||
|
const single = ref('q1')
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
|||||||
|
# Spec — Composant Accordéon `<MalioAccordion>`
|
||||||
|
|
||||||
|
**Date :** 2026-05-26
|
||||||
|
**Ticket :** MUI-37
|
||||||
|
**Statut :** Validé (design), prêt pour planification
|
||||||
|
|
||||||
|
## Contexte & objectif
|
||||||
|
|
||||||
|
Ajouter un composant accordéon à `@malio/layer-ui`. Cas d'usage principal :
|
||||||
|
un **système de filtres dans un drawer** d'ERP, où plusieurs sections de
|
||||||
|
critères (prix, catégorie, marque…) doivent pouvoir être dépliées
|
||||||
|
simultanément, chaque section ayant un contenu hétérogène (checkboxes,
|
||||||
|
slider, recherche…).
|
||||||
|
|
||||||
|
## Décision d'API : composants enfants (compositional)
|
||||||
|
|
||||||
|
Plutôt que l'API « tableau `items` + slots » de NuxtUI (qui impose un template
|
||||||
|
`#content` unique avec un switch central sur l'item courant), on adopte une
|
||||||
|
**API compositionnelle** : un parent `<MalioAccordion>` qui enveloppe des
|
||||||
|
enfants `<MalioAccordionItem>`. Chaque section déclare son titre **et** son
|
||||||
|
contenu au même endroit, sans switch central, et s'ajoute/se retire
|
||||||
|
indépendamment.
|
||||||
|
|
||||||
|
Rationale : pour des filtres au contenu hétérogène, c'est nettement plus
|
||||||
|
lisible et évolutif. On reste **100 % natif** (pas de dépendance Reka UI,
|
||||||
|
contrairement à NuxtUI), cohérent avec le `TabList` maison et les conventions
|
||||||
|
du layer (`@iconify/vue`, `twMerge`, props `*Class`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
MalioAccordion (parent : état d'ouverture, mode, coordination)
|
||||||
|
└─ MalioAccordionItem (enfant : en-tête cliquable + panneau animé + slot)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le parent **fournit** (`provide`) un contexte d'accordéon ; chaque enfant
|
||||||
|
**l'injecte** (`inject`) pour connaître son état d'ouverture et déclencher les
|
||||||
|
bascules. Communication via une clé `Symbol` (`InjectionKey`).
|
||||||
|
|
||||||
|
**Contexte fourni** (forme indicative) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AccordionContext {
|
||||||
|
mode: ComputedRef<'single' | 'multiple'>
|
||||||
|
isOpen: (value: string) => boolean
|
||||||
|
toggle: (value: string) => void
|
||||||
|
register: (value: string, defaultOpen: boolean) => void // enfant → parent au montage
|
||||||
|
unregister: (value: string) => void
|
||||||
|
baseId: string // pour générer les ids ARIA
|
||||||
|
registerHeader / focus nav helpers // pour la navigation flèches
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fichiers :**
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/malio/accordion/Accordion.vue
|
||||||
|
app/components/malio/accordion/AccordionItem.vue
|
||||||
|
app/components/malio/accordion/Accordion.test.ts
|
||||||
|
app/components/malio/accordion/AccordionItem.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
(+ page playground et story Histoire, cf. skill `creating-malio-component`.)
|
||||||
|
|
||||||
|
## API publique
|
||||||
|
|
||||||
|
### `<MalioAccordion>`
|
||||||
|
|
||||||
|
`defineOptions({ name: 'MalioAccordion', inheritAttrs: false })`
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Rôle |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts |
|
||||||
|
| `modelValue` | `string \| string[]` | `undefined` | v-model des clés ouvertes (`string` en `single`, `string[]` en `multiple`) |
|
||||||
|
| `id` | `string` | auto (`useId`) | Base d'id pour les attributs ARIA |
|
||||||
|
| `groupClass` | `string` | `''` | Classes du conteneur (fusion `twMerge`) |
|
||||||
|
|
||||||
|
**Events :** `update:modelValue(value: string | string[])`
|
||||||
|
|
||||||
|
**Pattern contrôlé / non-contrôlé** (convention maison) :
|
||||||
|
`isControlled = computed(() => props.modelValue !== undefined)`, avec
|
||||||
|
`localValue` en fallback. En non-contrôlé, l'état initial est dérivé des
|
||||||
|
enfants ayant `defaultOpen`.
|
||||||
|
|
||||||
|
### `<MalioAccordionItem>`
|
||||||
|
|
||||||
|
`defineOptions({ name: 'MalioAccordionItem', inheritAttrs: false })`
|
||||||
|
|
||||||
|
| Prop | Type | Défaut | Rôle |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `title` | `string` | — | Texte de l'en-tête |
|
||||||
|
| `value` | `string` | auto (`useId`) | Clé unique (recommandée pour piloter le v-model) |
|
||||||
|
| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non-contrôlé) |
|
||||||
|
| `disabled` | `boolean` | `false` | En-tête non cliquable |
|
||||||
|
| `headerClass` | `string` | `''` | Override classes de l'en-tête (`twMerge`) |
|
||||||
|
| `panelClass` | `string` | `''` | Override classes du panneau (`twMerge`) |
|
||||||
|
|
||||||
|
**Slot par défaut** = contenu du panneau.
|
||||||
|
|
||||||
|
## Comportement : mode `single` vs `multiple`
|
||||||
|
|
||||||
|
- **`multiple`** (défaut) : `modelValue` est un `string[]`. Basculer une
|
||||||
|
section ajoute/retire sa clé du tableau, sans affecter les autres.
|
||||||
|
- **`single`** : `modelValue` est un `string` (clé ouverte, ou `''`/`undefined`
|
||||||
|
si tout fermé). Ouvrir une section ferme la précédente.
|
||||||
|
|
||||||
|
L'en-tête minimal : **titre + chevron animé** uniquement. Pas de badge, pas
|
||||||
|
d'icône leading, pas de slot d'en-tête custom dans cette version (extensible
|
||||||
|
plus tard si besoin métier).
|
||||||
|
|
||||||
|
## Animation & rendu
|
||||||
|
|
||||||
|
- **Ouverture/fermeture** : transition de hauteur via
|
||||||
|
`grid-template-rows: 0fr → 1fr` sur un wrapper en `overflow: hidden`
|
||||||
|
(gère la hauteur dynamique du contenu sans mesure JS).
|
||||||
|
- **Chevron** : `mdi:chevron-down` via `@iconify/vue`, rotation 180° en
|
||||||
|
transition synchronisée avec l'ouverture.
|
||||||
|
- **Tokens Malio** : séparateurs `border-m-border`, titre `text-m-text`,
|
||||||
|
`rounded-malio` au besoin. Tout surchargeable via `headerClass` / `panelClass`
|
||||||
|
fusionnés avec `twMerge()`.
|
||||||
|
|
||||||
|
## Accessibilité (WAI-ARIA Accordion Pattern)
|
||||||
|
|
||||||
|
- En-tête = vrai `<button type="button">` → focusable nativement,
|
||||||
|
Entrée/Espace pour basculer.
|
||||||
|
- `aria-expanded` sur le bouton, `aria-controls` → id du panneau.
|
||||||
|
- Panneau : `role="region"` + `aria-labelledby` → id du bouton.
|
||||||
|
- Sections désactivées : `disabled` + `aria-disabled` sur le bouton.
|
||||||
|
- **Navigation clavier ↑/↓** entre les en-têtes (déplacement du focus d'un
|
||||||
|
en-tête à l'autre), conformément au pattern WAI-ARIA. `Home`/`End`
|
||||||
|
optionnels (nice-to-have).
|
||||||
|
|
||||||
|
## Tests (Vitest + @vue/test-utils, jsdom)
|
||||||
|
|
||||||
|
Helper `mountComponent(props)` colocalisé. Couverture cible :
|
||||||
|
|
||||||
|
**Accordion.test.ts**
|
||||||
|
- Rendu des enfants (slots).
|
||||||
|
- Mode `multiple` : plusieurs sections ouvertes simultanément.
|
||||||
|
- Mode `single` : ouvrir une section ferme la précédente.
|
||||||
|
- v-model contrôlé : `modelValue` pilote l'état ; émission de `update:modelValue`.
|
||||||
|
- Non-contrôlé : `defaultOpen` sur enfants → état initial correct.
|
||||||
|
|
||||||
|
**AccordionItem.test.ts**
|
||||||
|
- Toggle au clic sur l'en-tête.
|
||||||
|
- `disabled` : clic sans effet, attributs `disabled` / `aria-disabled`.
|
||||||
|
- Attributs ARIA : `aria-expanded`, `aria-controls`, `role="region"`,
|
||||||
|
`aria-labelledby` correctement liés.
|
||||||
|
- Navigation clavier ↑/↓ entre en-têtes.
|
||||||
|
- Override de classes via `headerClass` / `panelClass`.
|
||||||
|
|
||||||
|
## Livrables documentaires (convention maison)
|
||||||
|
|
||||||
|
- Mise à jour de `COMPONENTS.md` (tableau de props + exemples).
|
||||||
|
- Mise à jour de `CHANGELOG.md`.
|
||||||
|
- Page playground (ajout à `playground.nav.ts`).
|
||||||
|
- Story Histoire (`app/story/accordion/`).
|
||||||
|
|
||||||
|
## Hors périmètre (YAGNI, V1)
|
||||||
|
|
||||||
|
- Badge / compteur de filtres actifs dans l'en-tête.
|
||||||
|
- Icône leading.
|
||||||
|
- Slot d'en-tête personnalisé.
|
||||||
|
- Persistance d'état (localStorage, URL).
|
||||||
|
|
||||||
|
Ces éléments pourront être ajoutés ultérieurement si un besoin métier concret
|
||||||
|
émerge, sans casser l'API.
|
||||||
Reference in New Issue
Block a user