[#MUI-15] Création d'un composant drawer #21

Merged
tristan merged 1 commits from feature/MUI-15-developper-un-composant-drawer into develop 2026-03-24 10:49:28 +00:00
6 changed files with 459 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue'
const drawerDefault = ref(false)
const drawerNoClose = ref(false)
const drawerCustomWidth = ref(false)
const drawerWithForm = 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>
</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>
</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">
<MalioInputText label="Nom" />
<MalioInputText label="Prénom" />
<MalioInputText label="Email" />
<MalioButton label="Enregistrer" button-class="w-full" @click="drawerWithForm = false" />
</div>
</MalioDrawer>
</div>
</div>
</template>

View File

@@ -23,6 +23,7 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-23] Revoir la config couleur tailwind
* [#MUI-10] Création d'un composant bouton
* [#MUI-2] Faire un MCP pour la librairie de composant
* [#MUI-15] Création d'un composant drawer
### Changed

View File

@@ -355,3 +355,32 @@ Barre latérale de navigation rétractable.
<template #logo-collapsed><img src="/logo-small.png" /></template>
</MalioSidebar>
```
---
## MalioDrawer
Panneau latéral (drawer) qui s'ouvre depuis la droite avec backdrop semi-transparent.
| 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 |
| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) |
| `drawerClass` | `string` | `''` | Classes CSS panneau (twMerge) |
**Events :** `update:modelValue(value: boolean)`
**Slots :** `default` (contenu du drawer)
```vue
<MalioDrawer v-model="isOpen" title="Détails">
<p>Contenu du drawer</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Sans croix" :show-close="false">
<p>Fermeture uniquement via backdrop</p>
</MalioDrawer>
<MalioDrawer v-model="isOpen" title="Large" drawer-class="max-w-2xl">
<p>Drawer plus large</p>
</MalioDrawer>
```

View File

@@ -0,0 +1,122 @@
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 = {
modelValue?: boolean
title?: string
showClose?: boolean
id?: string
drawerClass?: 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 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', () => {
const wrapper = mountComponent(
{ modelValue: true },
{ default: '<p data-test="content">Contenu du drawer</p>' },
)
expect(wrapper.find('[data-test="content"]').text()).toBe('Contenu du drawer')
})
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('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 })
const id = wrapper.find('.fixed').attributes('id')
expect(id).toMatch(/^malio-drawer-/)
})
it('has role="dialog" and aria-modal on 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')
})
})

View File

@@ -0,0 +1,135 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
:id="componentId"
class="fixed inset-0 z-50"
v-bind="attrs"
>
<Transition name="drawer-backdrop">
<div
v-if="isOpen"
class="absolute inset-0 bg-black/40"
data-test="backdrop"
@click="close"
/>
</Transition>
<Transition name="drawer-panel">
<div
v-if="isOpen"
:class="twMerge(
'absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl',
drawerClass,
)"
role="dialog"
:aria-modal="true"
:aria-labelledby="titleId"
data-test="panel"
>
<div class="flex items-center justify-between px-5 pb-8 pt-8">
<h2
:id="titleId"
class="text-[32px] font-semibold text-m-primary"
>
{{ title }}
</h2>
<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"
data-test="close-button"
@click="close"
>
<IconifyIcon
icon="mdi:close"
:width="24"
:height="24"
/>
</button>
</div>
<div
class="overflow-y-auto px-5"
style="max-height: calc(100% - 96px)"
>
<slot />
</div>
</div>
</Transition>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, useAttrs, useId } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
title?: string
showClose?: boolean
drawerClass?: string
}>(),
{
id: '',
modelValue: undefined,
title: '',
showClose: true,
drawerClass: '',
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const attrs = useAttrs()
const generatedId = useId()
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
const titleId = computed(() => `${componentId.value}-title`)
const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false)
const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value,
)
function close() {
if (!isControlled.value) {
localValue.value = false
}
emit('update:modelValue', false)
}
</script>
<style scoped>
.drawer-backdrop-enter-active,
.drawer-backdrop-leave-active {
transition: opacity 0.2s ease;
}
.drawer-backdrop-enter-from,
.drawer-backdrop-leave-to {
opacity: 0;
}
.drawer-panel-enter-active,
.drawer-panel-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.drawer-panel-enter-from,
.drawer-panel-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<Story title="Overlay/Drawer">
<Variant title="Simple">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showSimple = true"
>
Ouvrir le drawer
</button>
<MalioDrawer v-model="showSimple" title="Détails">
<p>Contenu simple du drawer.</p>
</MalioDrawer>
</div>
</Variant>
<Variant title="Avec formulaire">
<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" 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" />
</div>
</MalioDrawer>
</div>
</Variant>
<Variant title="Sans bouton fermer">
<div class="p-4">
<button
class="rounded bg-m-btn-primary px-4 py-2 text-white"
@click="showNoClose = true"
>
Ouvrir (sans croix)
</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>
</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>