diff --git a/.playground/pages/composant/drawer/drawer.vue b/.playground/pages/composant/drawer/drawer.vue new file mode 100644 index 0000000..0c87902 --- /dev/null +++ b/.playground/pages/composant/drawer/drawer.vue @@ -0,0 +1,49 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b539c..223c25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/COMPONENTS.md b/COMPONENTS.md index c103142..770d20a 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -355,3 +355,32 @@ Barre latérale de navigation rétractable. ``` + +--- + +## 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 + +

Contenu du drawer

+
+ +

Fermeture uniquement via backdrop

+
+ +

Drawer plus large

+
+``` diff --git a/app/components/malio/drawer/Drawer.test.ts b/app/components/malio/drawer/Drawer.test.ts new file mode 100644 index 0000000..8fd6ff3 --- /dev/null +++ b/app/components/malio/drawer/Drawer.test.ts @@ -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 + +function mountComponent(props: DrawerProps = {}, slots?: Record) { + 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: '

Contenu du drawer

' }, + ) + 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') + }) +}) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue new file mode 100644 index 0000000..3ad8eb7 --- /dev/null +++ b/app/components/malio/drawer/Drawer.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/app/story/drawer/drawer.story.vue b/app/story/drawer/drawer.story.vue new file mode 100644 index 0000000..b7061ed --- /dev/null +++ b/app/story/drawer/drawer.story.vue @@ -0,0 +1,123 @@ + + + +# 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 `` 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 | + + +