Merge branch 'develop' into feature/MUI-33-developper-le-composant-datepicker
This commit is contained in:
@@ -1,48 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const drawerDefault = ref(false)
|
||||
const drawerNoClose = ref(false)
|
||||
const drawerCustomWidth = ref(false)
|
||||
const drawerWithForm = ref(false)
|
||||
const drawerRight = ref(false)
|
||||
const drawerLeft = ref(false)
|
||||
const drawerForm = ref(false)
|
||||
const drawerFixedFooter = 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 simple</h2>
|
||||
<MalioButton label="Ouvrir le drawer" @click="drawerDefault = true" />
|
||||
<MalioDrawer v-model="drawerDefault" title="Titre du drawer">
|
||||
<p class="text-m-text">Contenu du drawer. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
<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">Sans bouton fermer</h2>
|
||||
<MalioButton label="Ouvrir le drawer" variant="secondary" @click="drawerNoClose = true" />
|
||||
<MalioDrawer v-model="drawerNoClose" title="Sans croix" :show-close="false">
|
||||
<p class="text-m-text">Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||
<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">Largeur personnalisée</h2>
|
||||
<MalioButton label="Ouvrir le drawer large" variant="tertiary" @click="drawerCustomWidth = true" />
|
||||
<MalioDrawer v-model="drawerCustomWidth" title="Drawer large" drawer-class="max-w-2xl">
|
||||
<p class="text-m-text">Ce drawer utilise une largeur personnalisée via drawerClass.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Avec formulaire</h2>
|
||||
<MalioButton label="Ouvrir le formulaire" variant="danger" @click="drawerWithForm = true" />
|
||||
<MalioDrawer v-model="drawerWithForm" title="Formulaire">
|
||||
<div class="flex flex-col gap-4">
|
||||
<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" />
|
||||
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
|
||||
</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">Avec footer fixed bottom</h2>
|
||||
<MalioButton label="Ouvrir (footer fixe)" variant="tertiary" @click="drawerFixedFooter = true" />
|
||||
<MalioDrawer v-model="drawerFixedFooter">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold text-black">Conditions</h2>
|
||||
</template>
|
||||
<!-- pb-24 : laisse la place au footer fixe qui sort du flux et recouvrirait le bas du contenu -->
|
||||
<div class="flex flex-col gap-4 pb-24">
|
||||
<p v-for="n in 12" :key="n" class="text-m-text">
|
||||
Paragraphe {{ n }} — contenu long pour forcer le scroll et montrer que le footer reste fixé en bas du viewport.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<!-- fixed : positionné par rapport au viewport ; w-full max-w-md cale la largeur sur le drawer droite par défaut -->
|
||||
<div class="fixed bottom-0 right-0 w-full max-w-md border-t border-m-border bg-white px-5 py-4">
|
||||
<MalioButton label="Accepter" button-class="w-full" @click="drawerFixedFooter = 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>
|
||||
|
||||
@@ -33,6 +33,7 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* [#MUI-34] Revoir le système de playground
|
||||
|
||||
### 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`.
|
||||
|
||||
### Fixed
|
||||
* Hauteur des boutons de pagination du datatable alignée sur le select (40px)
|
||||
|
||||
@@ -584,28 +584,59 @@ Barre latérale de navigation rétractable.
|
||||
|
||||
## MalioDrawer
|
||||
|
||||
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
|
||||
Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs drawers.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto | Identifiant HTML |
|
||||
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||
| `title` | `string` | `''` | Titre affiché dans le header |
|
||||
| `side` | `'right' \| 'left'` | `'right'` | Côté d'apparition |
|
||||
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
|
||||
| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop |
|
||||
| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` |
|
||||
| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent |
|
||||
| `drawerClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-2xl` (twMerge) |
|
||||
| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) |
|
||||
| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) |
|
||||
| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) |
|
||||
| `footerClass` | `string` | `''` | Classes CSS wrapper du footer (aucune position imposée) |
|
||||
|
||||
**Events :** `update:modelValue(value: boolean)`
|
||||
**Slots :** `default` (contenu du drawer)
|
||||
**Events :** `update:modelValue(value: boolean)`, `close()`
|
||||
|
||||
**Slots :**
|
||||
- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée.
|
||||
- `default` — contenu (zone scrollable).
|
||||
- `footer` — rendu dans la zone scrollable, sans positionnement imposé : le consommateur choisit (`sticky bottom-0`, `fixed`, ou rien).
|
||||
|
||||
```vue
|
||||
<MalioDrawer v-model="isOpen" title="Détails">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">Détails</h2>
|
||||
</template>
|
||||
<p>Contenu du drawer</p>
|
||||
</MalioDrawer>
|
||||
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
|
||||
<p>Fermeture uniquement via backdrop</p>
|
||||
|
||||
<!-- Côté gauche, largeur custom -->
|
||||
<MalioDrawer v-model="isOpen" side="left" drawer-class="max-w-2xl">
|
||||
<template #header><h2>Navigation</h2></template>
|
||||
<p>Drawer large depuis la gauche</p>
|
||||
</MalioDrawer>
|
||||
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
|
||||
<p>Drawer plus large</p>
|
||||
|
||||
<!-- Footer collé en bas (le consommateur applique le positionnement) -->
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header><h2>Formulaire</h2></template>
|
||||
<MalioInputText label="Nom" />
|
||||
<template #footer>
|
||||
<div class="sticky bottom-0 bg-white py-4">
|
||||
<MalioButton label="Enregistrer" button-class="w-full" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Non fermable au backdrop / Échap (croix uniquement) -->
|
||||
<MalioDrawer v-model="isOpen" :dismissable="false" :close-on-escape="false">
|
||||
<template #header><h2>Action requise</h2></template>
|
||||
<p>Fermeture via la croix uniquement</p>
|
||||
</MalioDrawer>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
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<DrawerProps>
|
||||
@@ -18,64 +25,38 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
|
||||
return mount(DrawerForTest, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('MalioDrawer', () => {
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('does not render when modelValue is false', () => {
|
||||
const wrapper = mountComponent({ modelValue: false })
|
||||
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: '<p data-test="content">Contenu du drawer</p>' },
|
||||
{ default: '<p data-test="content">Contenu</p>' },
|
||||
)
|
||||
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 +66,276 @@ 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')
|
||||
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
|
||||
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||
})
|
||||
|
||||
it('works in uncontrolled mode', () => {
|
||||
const wrapper = mountComponent()
|
||||
// Without modelValue, defaults to closed
|
||||
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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, showClose: 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()
|
||||
})
|
||||
|
||||
it('moves focus to the close button on open (default showClose)', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: false, showClose: true },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: true, showClose: false },
|
||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
|
||||
last.focus()
|
||||
expect(document.activeElement).toBe(last)
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
|
||||
const wrapper = mount(DrawerForTest, {
|
||||
props: { modelValue: true, showClose: false },
|
||||
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
|
||||
first.focus()
|
||||
expect(document.activeElement).toBe(first)
|
||||
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
|
||||
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => {
|
||||
const wrapperA = mount(DrawerForTest, {
|
||||
props: { modelValue: false },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
const wrapperB = mount(DrawerForTest, {
|
||||
props: { modelValue: false },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
|
||||
// Open drawer A → scroll locked
|
||||
await wrapperA.setProps({ modelValue: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Open drawer B → still locked
|
||||
await wrapperB.setProps({ modelValue: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Close drawer B → A is still open, scroll must remain locked
|
||||
await wrapperB.setProps({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
// Close drawer A → both closed, scroll-lock released
|
||||
await wrapperA.setProps({ modelValue: false })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +1,76 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
name="drawer"
|
||||
:name="`drawer-${side}`"
|
||||
appear
|
||||
@after-leave="isRendered = false"
|
||||
>
|
||||
<div
|
||||
v-if="isRendered && isOpen"
|
||||
:id="componentId"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
class="fixed inset-0 z-50 flex"
|
||||
:class="side === 'right' ? 'justify-end' : 'justify-start'"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40"
|
||||
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||
data-test="backdrop"
|
||||
@click="close"
|
||||
@click="onBackdropClick"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="panelRef"
|
||||
:class="twMerge(
|
||||
'relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl',
|
||||
'relative z-50 flex h-full w-full max-w-md flex-col bg-white',
|
||||
drawerClass,
|
||||
)"
|
||||
role="dialog"
|
||||
:aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||
tabindex="-1"
|
||||
data-test="panel"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pb-8 pt-8">
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="text-[32px] font-semibold text-m-primary"
|
||||
<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"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||
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:close"
|
||||
:width="24"
|
||||
:height="24"
|
||||
icon="mdi:cancel-bold"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-5"
|
||||
: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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,7 +79,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useAttrs, useId, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useAttrs,
|
||||
useId,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -72,68 +99,195 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
modelValue?: boolean
|
||||
title?: string
|
||||
side?: 'right' | 'left'
|
||||
showClose?: boolean
|
||||
dismissable?: boolean
|
||||
closeOnEscape?: boolean
|
||||
ariaLabel?: string
|
||||
drawerClass?: string
|
||||
overlayClass?: string
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
footerClass?: string
|
||||
}>(),
|
||||
{
|
||||
id: '',
|
||||
modelValue: undefined,
|
||||
title: '',
|
||||
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 titleId = computed(() => `${componentId.value}-title`)
|
||||
|
||||
const slots = useSlots()
|
||||
const headerId = computed(() => `${componentId.value}-header`)
|
||||
const hasHeader = computed(() => !!slots.header)
|
||||
|
||||
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)
|
||||
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
|
||||
let lockedByThisInstance = false
|
||||
|
||||
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"]), [contenteditable]:not([contenteditable="false"])',
|
||||
),
|
||||
).filter((el) => el.tabIndex !== -1)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
||||
if (!lockedByThisInstance) {
|
||||
lockedByThisInstance = true
|
||||
openDrawerCount++
|
||||
if (openDrawerCount === 1) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
const focusable = getFocusable(panel)
|
||||
;(focusable[0] ?? panel).focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (lockedByThisInstance) {
|
||||
lockedByThisInstance = false
|
||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||
if (openDrawerCount === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
previouslyFocused?.focus?.()
|
||||
previouslyFocused = null
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) isRendered.value = true
|
||||
if (val) {
|
||||
isRendered.value = true
|
||||
onOpen()
|
||||
}
|
||||
else {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (!isControlled.value) {
|
||||
localValue.value = false
|
||||
onMounted(() => {
|
||||
if (isOpen.value) onOpen()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// If this instance is still holding a scroll-lock slot, release it.
|
||||
if (lockedByThisInstance) {
|
||||
lockedByThisInstance = false
|
||||
openDrawerCount = Math.max(0, openDrawerCount - 1)
|
||||
if (openDrawerCount === 0) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function onBackdropClick() {
|
||||
if (props.dismissable) close()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isControlled.value) localValue.value = false
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Shared across all MalioDrawer instances: only the last open drawer releases the body scroll-lock.
|
||||
let openDrawerCount = 0
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
.drawer-right-enter-active,
|
||||
.drawer-right-leave-active,
|
||||
.drawer-left-enter-active,
|
||||
.drawer-left-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
.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-enter-from,
|
||||
.drawer-leave-to {
|
||||
.drawer-right-enter-from,
|
||||
.drawer-right-leave-to,
|
||||
.drawer-left-enter-from,
|
||||
.drawer-left-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
.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>
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({ name: 'DrawerStory' })
|
||||
|
||||
const showRight = ref(false)
|
||||
const showLeft = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNoDismiss = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Overlay/Drawer">
|
||||
<Variant title="Simple">
|
||||
<Variant title="Droite (défaut)">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@click="showSimple = true"
|
||||
@click="showRight = true"
|
||||
>
|
||||
Ouvrir le drawer
|
||||
Ouvrir à droite
|
||||
</button>
|
||||
<MalioDrawer v-model="showSimple" title="Détails">
|
||||
<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="Avec formulaire">
|
||||
<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"
|
||||
@@ -22,102 +53,38 @@
|
||||
>
|
||||
Ouvrir le formulaire
|
||||
</button>
|
||||
<MalioDrawer v-model="showForm" title="Nouveau contact">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputText v-model="formNom" label="Nom" />
|
||||
<MalioInputText v-model="formPrenom" label="Prénom" />
|
||||
<MalioButton label="Enregistrer" button-class="w-full" @click="showForm = false" />
|
||||
<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="Sans bouton fermer">
|
||||
<Variant title="Non dismissable">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@click="showNoClose = true"
|
||||
@click="showNoDismiss = true"
|
||||
>
|
||||
Ouvrir (sans croix)
|
||||
Ouvrir
|
||||
</button>
|
||||
<MalioDrawer v-model="showNoClose" title="Information" :show-close="false">
|
||||
<p>Ce drawer n'a pas de bouton fermer. Cliquez sur le backdrop pour fermer.</p>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Largeur personnalisée">
|
||||
<div class="p-4">
|
||||
<button
|
||||
class="rounded bg-m-btn-primary px-4 py-2 text-white"
|
||||
@click="showWide = true"
|
||||
>
|
||||
Ouvrir (large)
|
||||
</button>
|
||||
<MalioDrawer v-model="showWide" title="Drawer large" drawer-class="max-w-2xl">
|
||||
<p>Ce drawer utilise une largeur personnalisée via la prop drawerClass.</p>
|
||||
<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>
|
||||
|
||||
<docs lang="md">
|
||||
# MalioDrawer
|
||||
|
||||
Panneau latéral (drawer) qui s'ouvre depuis la droite avec un fond semi-transparent.
|
||||
|
||||
## Props détaillées
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto-généré | Identifiant HTML du drawer |
|
||||
| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) |
|
||||
| `title` | `string` | `''` | Titre affiché dans le header |
|
||||
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
|
||||
| `drawerClass` | `string` | `''` | Classes CSS additionnelles sur le panneau (fusionnées via `twMerge`) |
|
||||
|
||||
## Comportement
|
||||
|
||||
- Le drawer s'ouvre en glissant depuis la droite avec une transition
|
||||
- Un backdrop semi-transparent couvre le reste de la page
|
||||
- Clic sur le backdrop ferme le drawer
|
||||
- Bouton de fermeture (croix) en haut à droite, masquable via `showClose`
|
||||
- Contenu scrollable si plus haut que la fenêtre
|
||||
- Teleport vers `<body>` pour éviter les problèmes de z-index
|
||||
|
||||
## Accessibilité
|
||||
|
||||
- `role="dialog"` et `aria-modal="true"` sur le panneau
|
||||
- `aria-labelledby` lié au titre
|
||||
- Bouton fermer avec `aria-label="Fermer"`
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `update:modelValue` | `boolean` | Émis à la fermeture (backdrop ou bouton) |
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Description |
|
||||
|------|-------------|
|
||||
| `default` | Contenu du drawer |
|
||||
</docs>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MalioDrawer from '../../components/malio/drawer/Drawer.vue'
|
||||
import MalioInputText from '../../components/malio/input/InputText.vue'
|
||||
import MalioButton from '../../components/malio/button/Button.vue'
|
||||
|
||||
defineOptions({ name: 'DrawerStory' })
|
||||
|
||||
const showSimple = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNoClose = ref(false)
|
||||
const showWide = ref(false)
|
||||
|
||||
const formNom = ref('Dupont')
|
||||
const formPrenom = ref('Jean')
|
||||
</script>
|
||||
|
||||
1117
docs/superpowers/plans/2026-05-21-drawer-redesign.md
Normal file
1117
docs/superpowers/plans/2026-05-21-drawer-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
146
docs/superpowers/specs/2026-05-21-drawer-redesign-design.md
Normal file
146
docs/superpowers/specs/2026-05-21-drawer-redesign-design.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Refonte du composant `<MalioDrawer>` — Design
|
||||
|
||||
> Ticket : MUI-35 — Revoir le design du composant Drawer
|
||||
> Date : 2026-05-21
|
||||
> Statut : design validé, à implémenter
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Le `<MalioDrawer>` 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** `<MalioDrawer>` (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. 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
|
||||
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 `<Teleport to="body">` + `<Transition>` +
|
||||
`isRendered` (démontage après l'animation de sortie).
|
||||
|
||||
## Migration (breaking)
|
||||
|
||||
| Avant | Après |
|
||||
|-------|-------|
|
||||
| `title="Titre"` | `<template #header><h2>Titre</h2></template>` (ou composant de titre Malio) |
|
||||
| `<MalioDrawer>contenu</MalioDrawer>` | 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).
|
||||
Reference in New Issue
Block a user