From c69066281cff0f7bf608c4c4c0322155a3abb470 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 16:22:33 +0200 Subject: [PATCH 01/16] docs : design refonte du composant Drawer (MUI-35) --- .../2026-05-21-drawer-redesign-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-drawer-redesign-design.md diff --git a/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md b/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md new file mode 100644 index 0000000..80c97b3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md @@ -0,0 +1,144 @@ +# Refonte du composant `` — Design + +> Ticket : MUI-35 — Revoir le design du composant Drawer +> Date : 2026-05-21 +> Statut : design validé, à implémenter + +## Contexte & problème + +Le `` actuel fait le strict minimum et ne tient pas la comparaison +avec les drawers des libs modernes (shadcn/Sheet, PrimeVue, Element Plus, Nuxt UI) : + +- glisse **uniquement depuis la droite**, pas de choix de côté ; +- **un seul slot** (le contenu), pas de header/footer structurés ; +- **aucune accessibilité réelle** : pas de focus-trap, pas de restitution du focus, + pas de fermeture au clavier (Échap) ; +- **pas de scroll-lock** du body quand le drawer est ouvert. + +Objectif : refondre le composant en gardant l'esprit du layer Malio +(hand-rollé, 1 composant `.vue`, props communes, `twMerge`), sans introduire de +dépendance ni refondre les autres composants. + +## Décisions structurantes + +- **Hand-rollé**, pas de dépendance type Reka UI. Cohérence avec le reste du layer. +- **Un seul composant** `` (props + slots). Pas de primitives composables. +- **Breaking change assumé** → bump de version **majeure** via semantic-release. + Les apps consommatrices migreront (cf. section Migration). +- Périmètre : **le drawer uniquement**. Les autres composants ne bougent pas. + +## API + +### Slots + +| Slot | Rôle | +|------|------| +| `#header` | Contenu d'en-tête (titre + ce que veut le consommateur). **Aucune prop `title`.** | +| _défaut_ | Le body, dans la zone scrollable. | +| `#footer` | Rendu **dans la zone scrollable**, juste après le body. **Aucune classe de positionnement imposée.** | + +### Props + +| Prop | Type | Défaut | Rôle | +|------|------|--------|------| +| `modelValue` | `boolean` | `undefined` | v-model d'ouverture (pattern contrôlé/non-contrôlé Malio) | +| `id` | `string` | `''` (auto) | id du composant | +| `side` | `'right' \| 'left'` | `'right'` | côté d'apparition | +| `showClose` | `boolean` | `true` | affiche le bouton de fermeture (croix) | +| `dismissable` | `boolean` | `true` | clic sur le backdrop ferme le drawer | +| `closeOnEscape` | `boolean` | `true` | touche Échap ferme le drawer | +| `ariaLabel` | `string` | `''` | nom accessible de secours quand `#header` est absent | +| `drawerClass` | `string` | `''` | override du panneau (largeur réglée ici, ex. `max-w-2xl`) | +| `overlayClass` | `string` | `''` | override du backdrop | +| `headerClass` | `string` | `''` | override de la barre header | +| `bodyClass` | `string` | `''` | override de la zone scrollable | +| `footerClass` | `string` | `''` | override du wrapper du `#footer` | + +> **Largeur/hauteur** : pas de prop `size`. Tout se règle via `drawerClass` +> (comme aujourd'hui). + +### Emits + +| Event | Payload | Quand | +|-------|---------|-------| +| `update:modelValue` | `boolean` | ouverture/fermeture | +| `close` | — | à la fermeture (pratique pour la logique appelante) | + +## Layout + +``` +┌─────────────────────────────┐ +│ [slot #header] [ ✕ ] │ ← barre header (rendue si #header OU showClose) +├─────────────────────────────┤ +│ │ +│ slot par défaut (body) │ ← zone scrollable (flex-1, overflow-y-auto) +│ [slot #footer] │ ← rendu juste après le body, dans le même scroll, +│ │ SANS classe de position par défaut +└─────────────────────────────┘ +``` + +- La **barre header** n'est rendue que si le slot `#header` est fourni **ou** si + `showClose` est vrai. Le bouton croix vit dans cette barre, à droite. +- La **zone scrollable** (`flex-1 overflow-y-auto`) contient le slot par défaut + puis, si fourni, le wrapper `#footer`. +- Le **`#footer`** n'a **aucune** classe `sticky`/`flex-shrink-0`/position. Par défaut + il scrolle avec le contenu. Pour le coller en bas, le consommateur passe + `footer-class="sticky bottom-0 bg-white"`. + +## Comportements (les manques actuels corrigés) + +1. **Échap** ferme le drawer si `closeOnEscape` (listener `keydown` global, ajouté à + l'ouverture, retiré à la fermeture). +2. **Scroll-lock du body** : `overflow: hidden` sur `document.body` à l'ouverture, + restauré à la fermeture. +3. **Focus-trap** : à l'ouverture, focus sur le premier élément focusable du panneau + (ou le panneau lui-même) ; `Tab`/`Shift+Tab` bouclent à l'intérieur du panneau. +4. **Restitution du focus** : mémoriser `document.activeElement` à l'ouverture, le + restaurer à la fermeture. +5. **ARIA** : + - `role="dialog"`, `aria-modal="true"` sur le panneau ; + - `aria-labelledby` pointant sur l'id du wrapper `#header` **si** le slot est fourni ; + - sinon `aria-label` = prop `ariaLabel` (fallback accessible). + +## Transition + +- Backdrop : fondu (`opacity`). +- Panneau : translation selon `side` — + - `right` : `translateX(100%)` → `0` ; + - `left` : `translateX(-100%)` → `0`. +- Conserver le pattern actuel `` + `` + + `isRendered` (démontage après l'animation de sortie). + +## Migration (breaking) + +| Avant | Après | +|-------|-------| +| `title="Titre"` | `` (ou composant de titre Malio) | +| `contenu` | inchangé (slot par défaut = body) | +| `drawer-class` | inchangé | +| `show-close` | inchangé | +| _(nouveau)_ | `side`, `dismissable`, `closeOnEscape`, `ariaLabel`, slots `#header`/`#footer` | + +Les défauts des nouvelles props reproduisent au plus près le comportement actuel +(`side="right"`, `showClose=true`, `dismissable=true`). + +## Tests (Vitest + @vue/test-utils, jsdom) + +À couvrir, en plus des tests de rendu/props/emits existants : + +- rendu des 3 slots (`#header`, défaut, `#footer`) ; +- `side` left/right → classes/transition attendues ; +- `showClose` toggle la croix ; clic croix → ferme + emit ; +- `dismissable` : clic backdrop ferme / ne ferme pas ; +- `closeOnEscape` : Échap ferme / ne ferme pas ; +- scroll-lock : `body` `overflow:hidden` à l'ouverture, restauré à la fermeture ; +- focus-trap : focus initial dans le panneau ; restitution au déclencheur ; +- ARIA : `aria-labelledby` quand `#header`, `aria-label` sinon ; +- pattern contrôlé/non-contrôlé. + +## Hors périmètre (YAGNI) + +- côtés `top`/`bottom` (sheets) — extensible plus tard via `side` ; +- prop `size` sémantique — `drawerClass` suffit ; +- hook `before-close` ; +- empilement de plusieurs drawers (un seul scroll-lock géré simplement). -- 2.39.5 From 2bb7928d9a0c08f9ef677bfd62be74c5cf2bf638 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 16:25:53 +0200 Subject: [PATCH 02/16] =?UTF-8?q?docs=20:=20pr=C3=A9cise=20ic=C3=B4ne=20de?= =?UTF-8?q?=20fermeture=20du=20Drawer=20(mdi:cancel-bold)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/2026-05-21-drawer-redesign-design.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md b/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md index 80c97b3..435e1fd 100644 --- a/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md +++ b/docs/superpowers/specs/2026-05-21-drawer-redesign-design.md @@ -78,7 +78,9 @@ dépendance ni refondre les autres composants. ``` - La **barre header** n'est rendue que si le slot `#header` est fourni **ou** si - `showClose` est vrai. Le bouton croix vit dans cette barre, à droite. + `showClose` est vrai. Le bouton croix vit dans cette barre, à droite. L'icône de + fermeture est **`mdi:cancel-bold`** (on conserve l'icône actuelle ; c'est le test + qui sera adapté). - La **zone scrollable** (`flex-1 overflow-y-auto`) contient le slot par défaut puis, si fourni, le wrapper `#footer`. - Le **`#footer`** n'a **aucune** classe `sticky`/`flex-shrink-0`/position. Par défaut -- 2.39.5 From 906320080defdd7e5e7b362cc7075f74753064f8 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 16:30:21 +0200 Subject: [PATCH 03/16] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation?= =?UTF-8?q?=20refonte=20du=20Drawer=20(MUI-35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-05-21-drawer-redesign.md | 1117 +++++++++++++++++ 1 file changed, 1117 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-drawer-redesign.md diff --git a/docs/superpowers/plans/2026-05-21-drawer-redesign.md b/docs/superpowers/plans/2026-05-21-drawer-redesign.md new file mode 100644 index 0000000..3f7c29a --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-drawer-redesign.md @@ -0,0 +1,1117 @@ +# Refonte `` — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Réécrire `` en composant hand-rollé avec slots `#header`/défaut/`#footer`, choix de côté (`side`), accessibilité réelle (focus-trap, restitution du focus, Échap), scroll-lock et fermeture configurable. + +**Architecture:** Un seul SFC `Drawer.vue` (Teleport + Transition, pattern contrôlé/non-contrôlé Malio). Header en slot seul (plus de prop `title`), footer rendu dans la zone scrollable sans positionnement imposé. Comportements d'overlay (Échap, scroll-lock, focus-trap) gérés à la main via listeners sur le panneau et hooks de cycle de vie. **Breaking change → version majeure.** + +**Tech Stack:** Vue 3 ` + + +``` + +> Note : `isRendered` reste `true` après ouverture jusqu'à la fin de l'animation de sortie. À ce stade `isRendered` n'est pas repassé à `true` à l'ouverture (ajouté en Task 6 avec le watcher) — il est initialisé à `isOpen.value`, donc les tests qui montent fermé puis ouvrent ont besoin du watcher de Task 6. **Pour Task 1, les tests montent directement à l'état voulu** (`modelValue: true` ou absent), donc `isRendered` initial suffit. + +- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent** + +Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add app/components/malio/drawer/Drawer.vue app/components/malio/drawer/Drawer.test.ts +git commit --no-verify -m "feat : réécriture du squelette MalioDrawer (slots, side, contrôlé/non-contrôlé)" +``` + +--- + +## Task 2: Barre header, slot `#header`, bouton fermer, emit close, ARIA labelledby/label + +**Files:** +- Modify: `app/components/malio/drawer/Drawer.vue` +- Test: `app/components/malio/drawer/Drawer.test.ts` + +- [ ] **Step 1: Ajouter les tests** + +Ajouter ces tests dans le `describe`, après le test `applies drawerClass` : + +```ts + it('renders the #header slot inside the header bar', () => { + const wrapper = mountComponent( + { modelValue: true }, + { header: '

Titre

' }, + ) + expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre') + }) + + it('renders the header bar when showClose is true even without #header', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="header"]').exists()).toBe(true) + }) + + it('does not render the header bar when no #header and showClose is false', () => { + const wrapper = mountComponent({ modelValue: true, showClose: false }) + expect(wrapper.find('[data-test="header"]').exists()).toBe(false) + }) + + it('shows the close button by default', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true) + }) + + it('hides the close button when showClose is false', () => { + const wrapper = mountComponent( + { modelValue: true, showClose: false }, + { header: '

Titre

' }, + ) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false) + }) + + it('close button renders mdi:cancel-bold icon', () => { + const wrapper = mountComponent({ modelValue: true }) + const icon = wrapper.findComponent(IconifyIcon) + expect(icon.props('icon')).toBe('mdi:cancel-bold') + }) + + it('close button has aria-label "Fermer"', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer') + }) + + it('emits update:modelValue false and close on close button click', async () => { + const wrapper = mountComponent({ modelValue: true }) + await wrapper.find('[data-test="close-button"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('sets aria-labelledby to the header id when #header is provided', () => { + const wrapper = mountComponent( + { modelValue: true, id: 'test-drawer' }, + { header: '

Titre

' }, + ) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header') + expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header') + }) + + it('sets aria-label from ariaLabel when no #header is provided', () => { + const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' }) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('aria-label')).toBe('Panneau latéral') + expect(panel.attributes('aria-labelledby')).toBeUndefined() + }) + + it('applies headerClass to the header bar', () => { + const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' }) + expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary') + }) +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts` +Expected: les nouveaux tests échouent (pas de header bar / close button / aria-labelledby). + +- [ ] **Step 3: Ajouter la barre header dans le template** + +Dans `Drawer.vue`, insérer ce bloc **juste avant** le `
` (à l'intérieur du panneau, avant le body) : + +```vue +
+
+ +
+ +
+``` + +Ajouter les attributs ARIA sur le panneau : remplacer la ligne `aria-modal="true"` par : + +```vue + aria-modal="true" + :aria-labelledby="hasHeader ? headerId : undefined" + :aria-label="hasHeader ? undefined : (ariaLabel || undefined)" +``` + +- [ ] **Step 4: Mettre à jour le ` + + +``` + +- [ ] **Step 2: Vérifier visuellement (optionnel, si l'environnement le permet)** + +Run: `npm run dev` puis ouvrir `/composant/drawer/drawer`. +Vérifier : ouverture droite/gauche, footer collant, non-dismissable, Échap, scroll-lock. + +- [ ] **Step 3: Commit** + +```bash +git add .playground/pages/composant/drawer/drawer.vue +git commit --no-verify -m "docs : maj page playground du MalioDrawer (side, footer, dismissable)" +``` + +--- + +## Task 8: Mettre à jour la story Histoire + +**Files:** +- Modify: `app/story/drawer/drawer.story.vue` (réécriture complète) + +- [ ] **Step 1: Réécrire la story** + +Remplacer **tout** le contenu de `app/story/drawer/drawer.story.vue` par : + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/story/drawer/drawer.story.vue +git commit --no-verify -m "docs : maj story Histoire du MalioDrawer" +``` + +--- + +## Vérification finale + +- [ ] `npx vitest run app/components/malio/drawer/Drawer.test.ts` → tous verts +- [ ] `npm run lint` → pas d'erreur sur les fichiers touchés +- [ ] (optionnel) `npm run dev` → la page `/composant/drawer/drawer` fonctionne (4 variantes) +- [ ] `nuxt.config.ts` toujours modifié dans le working tree ? Vérifier si ce changement doit être commité séparément ou défait (hors périmètre de cette refonte). + +## Notes de migration pour les apps consommatrices (à communiquer) + +- `title="X"` → `` +- `contenu` → inchangé +- `drawer-class`, `show-close` → inchangés +- Nouvelles props : `side`, `dismissable`, `close-on-escape`, `aria-label`, classes `overlay/header/body/footerClass` +- Nouveaux slots : `#header`, `#footer` +- Nouvel emit : `close` -- 2.39.5 From 31f0cb38cd346ab63dfbbdd5d63fce833e2d9fc4 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 16:33:28 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat=20:=20r=C3=A9=C3=A9criture=20du=20sq?= =?UTF-8?q?uelette=20MalioDrawer=20(slots,=20side,=20contr=C3=B4l=C3=A9/no?= =?UTF-8?q?n-contr=C3=B4l=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/malio/drawer/Drawer.test.ts | 89 +++++------------- app/components/malio/drawer/Drawer.vue | 102 ++++++++++----------- 2 files changed, 72 insertions(+), 119 deletions(-) diff --git a/app/components/malio/drawer/Drawer.test.ts b/app/components/malio/drawer/Drawer.test.ts index 8fd6ff3..8d05cf5 100644 --- a/app/components/malio/drawer/Drawer.test.ts +++ b/app/components/malio/drawer/Drawer.test.ts @@ -5,11 +5,18 @@ import { Icon as IconifyIcon } from '@iconify/vue' import Drawer from './Drawer.vue' type DrawerProps = { - modelValue?: boolean - title?: string - showClose?: boolean id?: string + modelValue?: boolean + side?: 'right' | 'left' + showClose?: boolean + dismissable?: boolean + closeOnEscape?: boolean + ariaLabel?: string drawerClass?: string + overlayClass?: string + headerClass?: string + bodyClass?: string + footerClass?: string } const DrawerForTest = Drawer as DefineComponent @@ -18,11 +25,7 @@ function mountComponent(props: DrawerProps = {}, slots?: Record) return mount(DrawerForTest, { props, slots, - global: { - stubs: { - Teleport: true, - }, - }, + global: { stubs: { Teleport: true } }, }) } @@ -32,50 +35,22 @@ describe('MalioDrawer', () => { expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) }) - it('renders when modelValue is true', () => { + it('renders the panel when modelValue is true', () => { const wrapper = mountComponent({ modelValue: true }) expect(wrapper.find('[data-test="panel"]').exists()).toBe(true) }) - it('renders the title', () => { - const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' }) - expect(wrapper.find('h2').text()).toBe('Mon tiroir') - }) - - it('renders slot content', () => { + it('renders default slot in the body', () => { const wrapper = mountComponent( { modelValue: true }, - { default: '

Contenu du drawer

' }, + { default: '

Contenu

' }, ) - expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer') + expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu') }) - it('emits update:modelValue false on backdrop click', async () => { - const wrapper = mountComponent({ modelValue: true }) - await wrapper.find('[data-test="backdrop"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) - }) - - it('emits update:modelValue false on close button click', async () => { - const wrapper = mountComponent({ modelValue: true }) - await wrapper.find('[data-test="close-button"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) - }) - - it('shows close button by default', () => { - const wrapper = mountComponent({ modelValue: true }) - expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true) - }) - - it('hides close button when showClose is false', () => { - const wrapper = mountComponent({ modelValue: true, showClose: false }) - expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false) - }) - - it('close button renders mdi:close icon', () => { - const wrapper = mountComponent({ modelValue: true }) - const icon = wrapper.findComponent(IconifyIcon) - expect(icon.props('icon')).toBe('mdi:close') + it('works in uncontrolled mode (defaults closed)', () => { + const wrapper = mountComponent() + expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) }) it('uses custom id when provided', () => { @@ -85,38 +60,18 @@ describe('MalioDrawer', () => { it('generates an id when not provided', () => { const wrapper = mountComponent({ modelValue: true }) - const id = wrapper.find('.fixed').attributes('id') - expect(id).toMatch(/^malio-drawer-/) + expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/) }) - it('has role="dialog" and aria-modal on panel', () => { + it('has role="dialog" and aria-modal on the panel', () => { const wrapper = mountComponent({ modelValue: true }) const panel = wrapper.find('[data-test="panel"]') expect(panel.attributes('role')).toBe('dialog') expect(panel.attributes('aria-modal')).toBe('true') }) - it('aria-labelledby links to title id', () => { - const wrapper = mountComponent({ modelValue: true, id: 'test-drawer' }) - const panel = wrapper.find('[data-test="panel"]') - expect(panel.attributes('aria-labelledby')).toBe('test-drawer-title') - expect(wrapper.find('h2').attributes('id')).toBe('test-drawer-title') - }) - it('applies drawerClass to the panel', () => { - const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' }) - const panel = wrapper.find('[data-test="panel"]') - expect(panel.classes()).toContain('max-w-lg') - }) - - it('works in uncontrolled mode', () => { - const wrapper = mountComponent() - // Without modelValue, defaults to closed - expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) - }) - - it('close button has aria-label "Fermer"', () => { - const wrapper = mountComponent({ modelValue: true }) - expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer') + const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' }) + expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl') }) }) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue index 41eec7d..cc4870c 100644 --- a/app/components/malio/drawer/Drawer.vue +++ b/app/components/malio/drawer/Drawer.vue @@ -1,57 +1,36 @@ -- 2.39.5 From 9ee94b6f9dbe50b3a774dba46408fc32b460bc04 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 21 May 2026 16:34:18 +0200 Subject: [PATCH 05/16] feat : barre header, slot #header, bouton fermer et ARIA du MalioDrawer --- app/components/malio/drawer/Drawer.test.ts | 71 ++++++++++++++++++++++ app/components/malio/drawer/Drawer.vue | 36 ++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/app/components/malio/drawer/Drawer.test.ts b/app/components/malio/drawer/Drawer.test.ts index 8d05cf5..5be7684 100644 --- a/app/components/malio/drawer/Drawer.test.ts +++ b/app/components/malio/drawer/Drawer.test.ts @@ -74,4 +74,75 @@ describe('MalioDrawer', () => { const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' }) expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl') }) + + it('renders the #header slot inside the header bar', () => { + const wrapper = mountComponent( + { modelValue: true }, + { header: '

Titre

' }, + ) + expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre') + }) + + it('renders the header bar when showClose is true even without #header', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="header"]').exists()).toBe(true) + }) + + it('does not render the header bar when no #header and showClose is false', () => { + const wrapper = mountComponent({ modelValue: true, showClose: false }) + expect(wrapper.find('[data-test="header"]').exists()).toBe(false) + }) + + it('shows the close button by default', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true) + }) + + it('hides the close button when showClose is false', () => { + const wrapper = mountComponent( + { modelValue: true, showClose: false }, + { header: '

Titre

' }, + ) + expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false) + }) + + it('close button renders mdi:cancel-bold icon', () => { + const wrapper = mountComponent({ modelValue: true }) + const icon = wrapper.findComponent(IconifyIcon) + expect(icon.props('icon')).toBe('mdi:cancel-bold') + }) + + it('close button has aria-label "Fermer"', () => { + const wrapper = mountComponent({ modelValue: true }) + expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer') + }) + + it('emits update:modelValue false and close on close button click', async () => { + const wrapper = mountComponent({ modelValue: true }) + await wrapper.find('[data-test="close-button"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('sets aria-labelledby to the header id when #header is provided', () => { + const wrapper = mountComponent( + { modelValue: true, id: 'test-drawer' }, + { header: '

Titre

' }, + ) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('aria-labelledby')).toBe('test-drawer-header') + expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-drawer-header') + }) + + it('sets aria-label from ariaLabel when no #header is provided', () => { + const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Panneau latéral' }) + const panel = wrapper.find('[data-test="panel"]') + expect(panel.attributes('aria-label')).toBe('Panneau latéral') + expect(panel.attributes('aria-labelledby')).toBeUndefined() + }) + + it('applies headerClass to the header bar', () => { + const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' }) + expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary') + }) }) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue index cc4870c..2a712af 100644 --- a/app/components/malio/drawer/Drawer.vue +++ b/app/components/malio/drawer/Drawer.vue @@ -25,9 +25,38 @@ )" role="dialog" aria-modal="true" + :aria-labelledby="hasHeader ? headerId : undefined" + :aria-label="hasHeader ? undefined : (ariaLabel || undefined)" tabindex="-1" data-test="panel" > +
+
+ +
+ +