All checks were successful
Release / release (push) Successful in 1m25s
| 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é Co-authored-by: matthieu <matthieu@yuno.malio.fr> Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Reviewed-on: #51 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
1118 lines
35 KiB
Markdown
1118 lines
35 KiB
Markdown
# Refonte `<MalioDrawer>` — 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 `<MalioDrawer>` 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 `<script setup lang="ts">`, Tailwind + `tailwind-merge`, `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
|
|
|
|
**Spec de référence:** `docs/superpowers/specs/2026-05-21-drawer-redesign-design.md`
|
|
|
|
---
|
|
|
|
## Structure des fichiers
|
|
|
|
| Fichier | Action | Responsabilité |
|
|
|---------|--------|----------------|
|
|
| `app/components/malio/drawer/Drawer.vue` | Réécrire | Le composant |
|
|
| `app/components/malio/drawer/Drawer.test.ts` | Réécrire | Tests unitaires |
|
|
| `.playground/pages/composant/drawer/drawer.vue` | Réécrire | Page de démo playground |
|
|
| `app/story/drawer/drawer.story.vue` | Réécrire | Story Histoire |
|
|
|
|
`.playground/playground.nav.ts` contient déjà l'entrée Drawer — **ne pas y toucher**.
|
|
|
|
## Conventions de test importantes
|
|
|
|
- `mount(..., { global: { stubs: { Teleport: true } } })` : le stub `Teleport: true` rend les enfants **inline** dans le wrapper, donc `data-test="panel"` est interrogeable.
|
|
- Le watcher `isOpen` ne se déclenche pas au montage. Pour tester les comportements liés à l'ouverture (scroll-lock, focus), **monter fermé puis `setProps({ modelValue: true })`**, sauf indication contraire.
|
|
- Pour les tests de focus, monter avec `attachTo: document.body` (sinon `document.activeElement` n'est pas fiable en jsdom).
|
|
- Lancer un test ciblé : `npx vitest run app/components/malio/drawer/Drawer.test.ts -t "<nom>"`.
|
|
- Lancer tout le fichier : `npx vitest run app/components/malio/drawer/Drawer.test.ts`.
|
|
|
|
> Note : `npm run test` via le hook pre-commit est connu pour être capricieux. Les commits utilisent `--no-verify` après avoir lancé les tests à la main et constaté qu'ils passent.
|
|
|
|
---
|
|
|
|
## Task 1: Squelette du composant (render, slots par défaut, contrôlé/non-contrôlé, id, role)
|
|
|
|
**Files:**
|
|
- Modify: `app/components/malio/drawer/Drawer.vue` (réécriture complète, étape 1)
|
|
- Test: `app/components/malio/drawer/Drawer.test.ts` (réécriture complète, étape 1)
|
|
|
|
- [ ] **Step 1: Écrire le fichier de test (squelette)**
|
|
|
|
Remplacer **tout** le contenu de `app/components/malio/drawer/Drawer.test.ts` par :
|
|
|
|
```ts
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import type { DefineComponent } from 'vue'
|
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
|
import Drawer from './Drawer.vue'
|
|
|
|
type DrawerProps = {
|
|
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<DrawerProps>
|
|
|
|
function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>) {
|
|
return mount(DrawerForTest, {
|
|
props,
|
|
slots,
|
|
global: { stubs: { Teleport: true } },
|
|
})
|
|
}
|
|
|
|
describe('MalioDrawer', () => {
|
|
it('does not render when modelValue is false', () => {
|
|
const wrapper = mountComponent({ modelValue: false })
|
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders the panel when modelValue is true', () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders default slot in the body', () => {
|
|
const wrapper = mountComponent(
|
|
{ modelValue: true },
|
|
{ default: '<p data-test="content">Contenu</p>' },
|
|
)
|
|
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
|
})
|
|
|
|
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', () => {
|
|
const wrapper = mountComponent({ modelValue: true, id: 'my-drawer' })
|
|
expect(wrapper.find('.fixed').attributes('id')).toBe('my-drawer')
|
|
})
|
|
|
|
it('generates an id when not provided', () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
|
|
})
|
|
|
|
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('applies drawerClass to the panel', () => {
|
|
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
|
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: échecs (l'ancien composant utilise encore `title`, `data-test="body"` absent, etc.)
|
|
|
|
- [ ] **Step 3: Réécrire `Drawer.vue` (squelette)**
|
|
|
|
Remplacer **tout** le contenu de `app/components/malio/drawer/Drawer.vue` par :
|
|
|
|
```vue
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition
|
|
:name="`drawer-${side}`"
|
|
appear
|
|
@after-leave="isRendered = false"
|
|
>
|
|
<div
|
|
v-if="isRendered && isOpen"
|
|
:id="componentId"
|
|
class="fixed inset-0 z-50 flex"
|
|
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
|
v-bind="attrs"
|
|
>
|
|
<div
|
|
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
|
data-test="backdrop"
|
|
/>
|
|
|
|
<div
|
|
ref="panelRef"
|
|
:class="twMerge(
|
|
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
|
drawerClass,
|
|
)"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
data-test="panel"
|
|
>
|
|
<div
|
|
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
|
data-test="body"
|
|
>
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, useAttrs, useId } from 'vue'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
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
|
|
}>(),
|
|
{
|
|
id: '',
|
|
modelValue: undefined,
|
|
side: 'right',
|
|
showClose: true,
|
|
dismissable: true,
|
|
closeOnEscape: true,
|
|
ariaLabel: '',
|
|
drawerClass: '',
|
|
overlayClass: '',
|
|
headerClass: '',
|
|
bodyClass: '',
|
|
footerClass: '',
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: boolean): void
|
|
(e: 'close'): void
|
|
}>()
|
|
|
|
const attrs = useAttrs()
|
|
const generatedId = useId()
|
|
|
|
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
|
|
|
|
const isControlled = computed(() => props.modelValue !== undefined)
|
|
const localValue = ref(false)
|
|
const isOpen = computed(() =>
|
|
isControlled.value ? props.modelValue! : localValue.value,
|
|
)
|
|
const isRendered = ref(isOpen.value)
|
|
|
|
const panelRef = ref<HTMLElement | null>(null)
|
|
|
|
function close() {
|
|
if (!isControlled.value) localValue.value = false
|
|
emit('update:modelValue', false)
|
|
emit('close')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.drawer-right-enter-active,
|
|
.drawer-right-leave-active,
|
|
.drawer-left-enter-active,
|
|
.drawer-left-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.drawer-right-enter-active > div:last-child,
|
|
.drawer-right-leave-active > div:last-child,
|
|
.drawer-left-enter-active > div:last-child,
|
|
.drawer-left-leave-active > div:last-child {
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.drawer-right-enter-from,
|
|
.drawer-right-leave-to,
|
|
.drawer-left-enter-from,
|
|
.drawer-left-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.drawer-right-enter-from > div:last-child,
|
|
.drawer-right-leave-to > div:last-child {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.drawer-left-enter-from > div:last-child,
|
|
.drawer-left-leave-to > div:last-child {
|
|
transform: translateX(-100%);
|
|
}
|
|
</style>
|
|
```
|
|
|
|
> 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: '<h2 data-test="title">Titre</h2>' },
|
|
)
|
|
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: '<h2>Titre</h2>' },
|
|
)
|
|
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: '<h2>Titre</h2>' },
|
|
)
|
|
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 `<div ... data-test="body">` (à l'intérieur du panneau, avant le body) :
|
|
|
|
```vue
|
|
<div
|
|
v-if="hasHeader || showClose"
|
|
:class="twMerge('flex items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
|
data-test="header"
|
|
>
|
|
<div
|
|
:id="headerId"
|
|
class="min-w-0 flex-1"
|
|
data-test="header-content"
|
|
>
|
|
<slot name="header" />
|
|
</div>
|
|
<button
|
|
v-if="showClose"
|
|
type="button"
|
|
aria-label="Fermer"
|
|
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
|
data-test="close-button"
|
|
@click="close"
|
|
>
|
|
<IconifyIcon
|
|
icon="mdi:cancel-bold"
|
|
:width="16"
|
|
:height="16"
|
|
/>
|
|
</button>
|
|
</div>
|
|
```
|
|
|
|
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 `<script setup>`**
|
|
|
|
Ajouter l'import de l'icône en haut du script :
|
|
|
|
```ts
|
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
|
```
|
|
|
|
Ajouter `useSlots` à l'import depuis `vue` :
|
|
|
|
```ts
|
|
import { computed, ref, useAttrs, useId, useSlots } from 'vue'
|
|
```
|
|
|
|
Ajouter après `const generatedId = useId()` :
|
|
|
|
```ts
|
|
const slots = useSlots()
|
|
const headerId = computed(() => `${componentId.value}-header`)
|
|
const hasHeader = computed(() => !!slots.header)
|
|
```
|
|
|
|
- [ ] **Step 5: Lancer les tests pour vérifier qu'ils passent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: PASS (tous les tests jusqu'ici)
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add app/components/malio/drawer/Drawer.vue app/components/malio/drawer/Drawer.test.ts
|
|
git commit --no-verify -m "feat : barre header, slot #header, bouton fermer et ARIA du MalioDrawer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Slot `#footer` (dans la zone scrollable, sans positionnement) + bodyClass + footerClass
|
|
|
|
**Files:**
|
|
- Modify: `app/components/malio/drawer/Drawer.vue`
|
|
- Test: `app/components/malio/drawer/Drawer.test.ts`
|
|
|
|
- [ ] **Step 1: Ajouter les tests**
|
|
|
|
Ajouter dans le `describe` :
|
|
|
|
```ts
|
|
it('renders the #footer slot inside the body (scrollable zone)', () => {
|
|
const wrapper = mountComponent(
|
|
{ modelValue: true },
|
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
|
)
|
|
expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render the footer wrapper when no #footer slot', () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('applies bodyClass to the body', () => {
|
|
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
|
})
|
|
|
|
it('applies footerClass to the footer wrapper', () => {
|
|
const wrapper = mountComponent(
|
|
{ modelValue: true, footerClass: 'sticky bottom-0' },
|
|
{ footer: '<span>pied</span>' },
|
|
)
|
|
const footer = wrapper.find('[data-test="footer"]')
|
|
expect(footer.classes()).toContain('sticky')
|
|
expect(footer.classes()).toContain('bottom-0')
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: échec sur les 4 nouveaux tests.
|
|
|
|
- [ ] **Step 3: Ajouter le footer dans le body**
|
|
|
|
Dans `Drawer.vue`, à l'intérieur du `<div ... data-test="body">`, **après** `<slot />`, ajouter :
|
|
|
|
```vue
|
|
<div
|
|
v-if="$slots.footer"
|
|
:class="footerClass"
|
|
data-test="footer"
|
|
>
|
|
<slot name="footer" />
|
|
</div>
|
|
```
|
|
|
|
Le body devient :
|
|
|
|
```vue
|
|
<div
|
|
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
|
data-test="body"
|
|
>
|
|
<slot />
|
|
<div
|
|
v-if="$slots.footer"
|
|
:class="footerClass"
|
|
data-test="footer"
|
|
>
|
|
<slot name="footer" />
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: PASS
|
|
|
|
- [ ] **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 : slot #footer dans la zone scrollable du MalioDrawer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `side` (right/left) + backdrop dismissable + overlayClass
|
|
|
|
**Files:**
|
|
- Modify: `app/components/malio/drawer/Drawer.vue`
|
|
- Test: `app/components/malio/drawer/Drawer.test.ts`
|
|
|
|
- [ ] **Step 1: Ajouter les tests**
|
|
|
|
Ajouter dans le `describe` :
|
|
|
|
```ts
|
|
it('aligns to the right by default', () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
expect(wrapper.find('.fixed').classes()).toContain('justify-end')
|
|
})
|
|
|
|
it('aligns to the left when side is "left"', () => {
|
|
const wrapper = mountComponent({ modelValue: true, side: 'left' })
|
|
expect(wrapper.find('.fixed').classes()).toContain('justify-start')
|
|
})
|
|
|
|
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
})
|
|
|
|
it('does not close on backdrop click when dismissable is false', async () => {
|
|
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
})
|
|
|
|
it('applies overlayClass to the backdrop', () => {
|
|
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
|
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
|
})
|
|
```
|
|
|
|
> `side`/`overlayClass` sont déjà câblés depuis Task 1 (les tests d'alignement et overlayClass devraient déjà passer). Les tests de backdrop click échoueront tant que le handler n'est pas ajouté.
|
|
|
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: échec sur les tests de clic backdrop.
|
|
|
|
- [ ] **Step 3: Brancher le clic backdrop**
|
|
|
|
Dans `Drawer.vue`, ajouter `@click="onBackdropClick"` sur le div backdrop :
|
|
|
|
```vue
|
|
<div
|
|
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
|
data-test="backdrop"
|
|
@click="onBackdropClick"
|
|
/>
|
|
```
|
|
|
|
Ajouter la fonction dans le `<script setup>`, avant `close()` :
|
|
|
|
```ts
|
|
function onBackdropClick() {
|
|
if (props.dismissable) close()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: PASS
|
|
|
|
- [ ] **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 : fermeture backdrop dismissable du MalioDrawer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Fermeture au clavier (Échap) via closeOnEscape
|
|
|
|
**Files:**
|
|
- Modify: `app/components/malio/drawer/Drawer.vue`
|
|
- Test: `app/components/malio/drawer/Drawer.test.ts`
|
|
|
|
- [ ] **Step 1: Ajouter les tests**
|
|
|
|
Ajouter dans le `describe` :
|
|
|
|
```ts
|
|
it('closes on Escape key when closeOnEscape is true', async () => {
|
|
const wrapper = mountComponent({ modelValue: true })
|
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
|
})
|
|
|
|
it('does not close on Escape when closeOnEscape is false', async () => {
|
|
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: échec sur les 2 nouveaux tests.
|
|
|
|
- [ ] **Step 3: Brancher le keydown sur le panneau**
|
|
|
|
Dans `Drawer.vue`, ajouter `@keydown="onKeydown"` sur le div panneau (`data-test="panel"`) :
|
|
|
|
```vue
|
|
<div
|
|
ref="panelRef"
|
|
:class="twMerge(
|
|
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
|
drawerClass,
|
|
)"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-labelledby="hasHeader ? headerId : undefined"
|
|
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
|
tabindex="-1"
|
|
data-test="panel"
|
|
@keydown="onKeydown"
|
|
>
|
|
```
|
|
|
|
Ajouter la fonction dans le `<script setup>`, avant `close()` :
|
|
|
|
```ts
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && props.closeOnEscape) {
|
|
e.stopPropagation()
|
|
close()
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: PASS
|
|
|
|
- [ ] **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 : fermeture du MalioDrawer avec la touche Échap"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Scroll-lock du body + focus-trap + restitution du focus
|
|
|
|
**Files:**
|
|
- Modify: `app/components/malio/drawer/Drawer.vue`
|
|
- Test: `app/components/malio/drawer/Drawer.test.ts`
|
|
|
|
- [ ] **Step 1: Ajouter le nettoyage + les tests**
|
|
|
|
Comme `onMounted` (ajouté à l'étape 3) verrouille le scroll dès le montage des tests déjà ouverts et que `@vue/test-utils` ne démonte pas automatiquement, ajouter un nettoyage pour éviter la pollution inter-tests.
|
|
|
|
Mettre à jour l'import vitest en haut du fichier de test :
|
|
|
|
```ts
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
```
|
|
|
|
Ajouter, **juste après** la ligne `describe('MalioDrawer', () => {` :
|
|
|
|
```ts
|
|
afterEach(() => {
|
|
document.body.style.overflow = ''
|
|
})
|
|
```
|
|
|
|
Puis ajouter ces tests dans le `describe`. Ils utilisent un montage dédié `attachTo: document.body` pour le focus :
|
|
|
|
```ts
|
|
it('locks body scroll when opened and restores it when closed', async () => {
|
|
const wrapper = mountComponent({ modelValue: false })
|
|
expect(document.body.style.overflow).toBe('')
|
|
await wrapper.setProps({ modelValue: true })
|
|
expect(document.body.style.overflow).toBe('hidden')
|
|
await wrapper.setProps({ modelValue: false })
|
|
expect(document.body.style.overflow).toBe('')
|
|
})
|
|
|
|
it('moves focus into the panel when opened', async () => {
|
|
const wrapper = mount(DrawerForTest, {
|
|
props: { modelValue: false },
|
|
slots: { default: '<button data-test="first">OK</button>' },
|
|
attachTo: document.body,
|
|
global: { stubs: { Teleport: true } },
|
|
})
|
|
await wrapper.setProps({ modelValue: true })
|
|
await wrapper.vm.$nextTick()
|
|
const first = wrapper.find('[data-test="first"]').element
|
|
expect(document.activeElement).toBe(first)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('restores focus to the trigger when closed', async () => {
|
|
const trigger = document.createElement('button')
|
|
document.body.appendChild(trigger)
|
|
trigger.focus()
|
|
expect(document.activeElement).toBe(trigger)
|
|
|
|
const wrapper = mount(DrawerForTest, {
|
|
props: { modelValue: false },
|
|
slots: { default: '<button>OK</button>' },
|
|
attachTo: document.body,
|
|
global: { stubs: { Teleport: true } },
|
|
})
|
|
await wrapper.setProps({ modelValue: true })
|
|
await wrapper.vm.$nextTick()
|
|
await wrapper.setProps({ modelValue: false })
|
|
await wrapper.vm.$nextTick()
|
|
expect(document.activeElement).toBe(trigger)
|
|
|
|
wrapper.unmount()
|
|
trigger.remove()
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: échec sur les 3 nouveaux tests (pas de scroll-lock ni gestion du focus).
|
|
|
|
- [ ] **Step 3: Ajouter scroll-lock + gestion du focus dans le script**
|
|
|
|
Mettre à jour l'import depuis `vue` pour inclure `nextTick`, `watch`, `onMounted`, `onBeforeUnmount` :
|
|
|
|
```ts
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
useAttrs,
|
|
useId,
|
|
useSlots,
|
|
watch,
|
|
} from 'vue'
|
|
```
|
|
|
|
Ajouter, après la déclaration `const panelRef = ref<HTMLElement | null>(null)` :
|
|
|
|
```ts
|
|
let previouslyFocused: HTMLElement | null = null
|
|
|
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
|
return Array.from(
|
|
container.querySelectorAll<HTMLElement>(
|
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
),
|
|
).filter((el) => el.tabIndex !== -1)
|
|
}
|
|
|
|
function onOpen() {
|
|
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
|
document.body.style.overflow = 'hidden'
|
|
nextTick(() => {
|
|
const panel = panelRef.value
|
|
if (!panel) return
|
|
const focusable = getFocusable(panel)
|
|
;(focusable[0] ?? panel).focus()
|
|
})
|
|
}
|
|
|
|
function onClose() {
|
|
document.body.style.overflow = ''
|
|
previouslyFocused?.focus?.()
|
|
previouslyFocused = null
|
|
}
|
|
|
|
watch(isOpen, (val) => {
|
|
if (val) {
|
|
isRendered.value = true
|
|
onOpen()
|
|
} else {
|
|
onClose()
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (isOpen.value) onOpen()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.body.style.overflow = ''
|
|
})
|
|
```
|
|
|
|
Remplacer la fonction `onKeydown` (de Task 5) par la version avec focus-trap :
|
|
|
|
```ts
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && props.closeOnEscape) {
|
|
e.stopPropagation()
|
|
close()
|
|
return
|
|
}
|
|
if (e.key !== 'Tab') return
|
|
|
|
const panel = panelRef.value
|
|
if (!panel) return
|
|
const focusable = getFocusable(panel)
|
|
if (focusable.length === 0) {
|
|
e.preventDefault()
|
|
panel.focus()
|
|
return
|
|
}
|
|
const first = focusable[0]!
|
|
const last = focusable[focusable.length - 1]!
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault()
|
|
last.focus()
|
|
}
|
|
else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault()
|
|
first.focus()
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
|
|
|
Run: `npx vitest run app/components/malio/drawer/Drawer.test.ts`
|
|
Expected: PASS (tout le fichier)
|
|
|
|
- [ ] **Step 5: Lancer le lint**
|
|
|
|
Run: `npm run lint`
|
|
Expected: aucune erreur sur `Drawer.vue` / `Drawer.test.ts` (corriger le cas échéant).
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add app/components/malio/drawer/Drawer.vue app/components/malio/drawer/Drawer.test.ts
|
|
git commit --no-verify -m "feat : scroll-lock, focus-trap et restitution du focus du MalioDrawer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Mettre à jour la page playground
|
|
|
|
**Files:**
|
|
- Modify: `.playground/pages/composant/drawer/drawer.vue` (réécriture complète)
|
|
|
|
- [ ] **Step 1: Réécrire la page playground**
|
|
|
|
Remplacer **tout** le contenu de `.playground/pages/composant/drawer/drawer.vue` par :
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
|
|
const drawerRight = ref(false)
|
|
const drawerLeft = ref(false)
|
|
const drawerForm = ref(false)
|
|
const drawerNoDismiss = ref(false)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
|
<div class="rounded-lg border p-6">
|
|
<h2 class="mb-6 text-xl font-bold">Drawer droite (défaut)</h2>
|
|
<MalioButton label="Ouvrir à droite" @click="drawerRight = true" />
|
|
<MalioDrawer v-model="drawerRight">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold text-black">Détails</h2>
|
|
</template>
|
|
<p class="text-m-text">Contenu du drawer. Échap, clic backdrop et croix le ferment.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-6">
|
|
<h2 class="mb-6 text-xl font-bold">Drawer gauche</h2>
|
|
<MalioButton label="Ouvrir à gauche" variant="secondary" @click="drawerLeft = true" />
|
|
<MalioDrawer v-model="drawerLeft" side="left">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold text-black">Navigation</h2>
|
|
</template>
|
|
<p class="text-m-text">Ce drawer glisse depuis la gauche.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-6">
|
|
<h2 class="mb-6 text-xl font-bold">Avec footer collant</h2>
|
|
<MalioButton label="Ouvrir le formulaire" variant="tertiary" @click="drawerForm = true" />
|
|
<MalioDrawer v-model="drawerForm" drawer-class="max-w-lg">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold text-black">Nouveau contact</h2>
|
|
</template>
|
|
<div class="flex flex-col gap-4 py-2">
|
|
<MalioInputText label="Nom" />
|
|
<MalioInputText label="Prénom" />
|
|
<MalioInputText label="Email" />
|
|
</div>
|
|
<template #footer>
|
|
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
|
<MalioButton label="Annuler" variant="secondary" button-class="flex-1" @click="drawerForm = false" />
|
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="drawerForm = false" />
|
|
</div>
|
|
</template>
|
|
</MalioDrawer>
|
|
</div>
|
|
|
|
<div class="rounded-lg border p-6">
|
|
<h2 class="mb-6 text-xl font-bold">Non dismissable (croix uniquement)</h2>
|
|
<MalioButton label="Ouvrir" variant="danger" @click="drawerNoDismiss = true" />
|
|
<MalioDrawer v-model="drawerNoDismiss" :dismissable="false" :close-on-escape="false">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold text-black">Action requise</h2>
|
|
</template>
|
|
<p class="text-m-text">Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **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
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
|
|
const showRight = ref(false)
|
|
const showLeft = ref(false)
|
|
const showForm = ref(false)
|
|
const showNoDismiss = ref(false)
|
|
</script>
|
|
|
|
<template>
|
|
<Story title="Overlay/Drawer">
|
|
<Variant title="Droite (défaut)">
|
|
<div class="p-4">
|
|
<button
|
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
@click="showRight = true"
|
|
>
|
|
Ouvrir à droite
|
|
</button>
|
|
<MalioDrawer v-model="showRight">
|
|
<template #header>
|
|
<h2 class="text-xl font-bold">Détails</h2>
|
|
</template>
|
|
<p>Contenu simple du drawer.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
</Variant>
|
|
|
|
<Variant title="Gauche">
|
|
<div class="p-4">
|
|
<button
|
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
@click="showLeft = true"
|
|
>
|
|
Ouvrir à gauche
|
|
</button>
|
|
<MalioDrawer v-model="showLeft" side="left">
|
|
<template #header>
|
|
<h2 class="text-xl font-bold">Navigation</h2>
|
|
</template>
|
|
<p>Ce drawer glisse depuis la gauche.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
</Variant>
|
|
|
|
<Variant title="Avec footer collant">
|
|
<div class="p-4">
|
|
<button
|
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
@click="showForm = true"
|
|
>
|
|
Ouvrir le formulaire
|
|
</button>
|
|
<MalioDrawer v-model="showForm">
|
|
<template #header>
|
|
<h2 class="text-xl font-bold">Nouveau contact</h2>
|
|
</template>
|
|
<div class="flex flex-col gap-4 py-2">
|
|
<MalioInputText label="Nom" />
|
|
<MalioInputText label="Prénom" />
|
|
</div>
|
|
<template #footer>
|
|
<div class="sticky bottom-0 flex gap-3 bg-white py-4">
|
|
<MalioButton label="Enregistrer" button-class="flex-1" @click="showForm = false" />
|
|
</div>
|
|
</template>
|
|
</MalioDrawer>
|
|
</div>
|
|
</Variant>
|
|
|
|
<Variant title="Non dismissable">
|
|
<div class="p-4">
|
|
<button
|
|
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
|
@click="showNoDismiss = true"
|
|
>
|
|
Ouvrir
|
|
</button>
|
|
<MalioDrawer v-model="showNoDismiss" :dismissable="false" :close-on-escape="false">
|
|
<template #header>
|
|
<h2 class="text-xl font-bold">Action requise</h2>
|
|
</template>
|
|
<p>Ni le backdrop ni Échap ne ferment ce drawer. Utilisez la croix.</p>
|
|
</MalioDrawer>
|
|
</div>
|
|
</Variant>
|
|
</Story>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **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"` → `<template #header><h2 class="text-[24px] font-bold">X</h2></template>`
|
|
- `<MalioDrawer>contenu</MalioDrawer>` → 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`
|