[#MUI-35] Refonte du composant drawer #49

Merged
tristan merged 16 commits from feature/MUI-35-revoir-le-design-du-composant-drawer into develop 2026-05-21 15:17:58 +00:00
2 changed files with 72 additions and 119 deletions
Showing only changes of commit 31f0cb38cd - Show all commits

View File

@@ -5,11 +5,18 @@ import { Icon as IconifyIcon } from '@iconify/vue'
import Drawer from './Drawer.vue' import Drawer from './Drawer.vue'
type DrawerProps = { type DrawerProps = {
modelValue?: boolean
title?: string
showClose?: boolean
id?: string id?: string
modelValue?: boolean
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
} }
const DrawerForTest = Drawer as DefineComponent<DrawerProps> const DrawerForTest = Drawer as DefineComponent<DrawerProps>
@@ -18,11 +25,7 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
return mount(DrawerForTest, { return mount(DrawerForTest, {
props, props,
slots, slots,
global: { global: { stubs: { Teleport: true } },
stubs: {
Teleport: true,
},
},
}) })
} }
@@ -32,50 +35,22 @@ describe('MalioDrawer', () => {
expect(wrapper.find('[data-test="panel"]').exists()).toBe(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 }) const wrapper = mountComponent({ modelValue: true })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true) expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
}) })
it('renders the title', () => { it('renders default slot in the body', () => {
const wrapper = mountComponent({ modelValue: true, title: 'Mon tiroir' })
expect(wrapper.find('h2').text()).toBe('Mon tiroir')
})
it('renders slot content', () => {
const wrapper = mountComponent( const wrapper = mountComponent(
{ modelValue: true }, { 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 () => { it('works in uncontrolled mode (defaults closed)', () => {
const wrapper = mountComponent({ modelValue: true }) const wrapper = mountComponent()
await wrapper.find('[data-test="backdrop"]').trigger('click') expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
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', () => { it('uses custom id when provided', () => {
@@ -85,38 +60,18 @@ describe('MalioDrawer', () => {
it('generates an id when not provided', () => { it('generates an id when not provided', () => {
const wrapper = mountComponent({ modelValue: true }) const wrapper = mountComponent({ modelValue: true })
const id = wrapper.find('.fixed').attributes('id') expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/)
expect(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 wrapper = mountComponent({ modelValue: true })
const panel = wrapper.find('[data-test="panel"]') const panel = wrapper.find('[data-test="panel"]')
expect(panel.attributes('role')).toBe('dialog') expect(panel.attributes('role')).toBe('dialog')
expect(panel.attributes('aria-modal')).toBe('true') 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', () => { it('applies drawerClass to the panel', () => {
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-lg' }) const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
const panel = wrapper.find('[data-test="panel"]') expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
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

@@ -1,57 +1,36 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition <Transition
name="drawer" :name="`drawer-${side}`"
appear appear
@after-leave="isRendered = false" @after-leave="isRendered = false"
> >
<div <div
v-if="isRendered && isOpen" v-if="isRendered && isOpen"
:id="componentId" :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" v-bind="attrs"
> >
<div <div
class="absolute inset-0 bg-black/40" :class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
data-test="backdrop" data-test="backdrop"
@click="close"
/> />
<div <div
ref="panelRef"
:class="twMerge( :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, drawerClass,
)" )"
role="dialog" role="dialog"
:aria-modal="true" aria-modal="true"
:aria-labelledby="titleId" tabindex="-1"
data-test="panel" 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 <div
class="flex-1 overflow-y-auto px-5" :class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body"
> >
<slot /> <slot />
</div> </div>
@@ -62,8 +41,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useAttrs, useId, watch } from 'vue' import { computed, ref, useAttrs, useId } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioDrawer', inheritAttrs: false }) defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
@@ -72,68 +50,88 @@ const props = withDefaults(
defineProps<{ defineProps<{
id?: string id?: string
modelValue?: boolean modelValue?: boolean
title?: string side?: 'right' | 'left'
showClose?: boolean showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(), }>(),
{ {
id: '', id: '',
modelValue: undefined, modelValue: undefined,
title: '', side: 'right',
showClose: true, showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
drawerClass: '', drawerClass: '',
overlayClass: '',
headerClass: '',
bodyClass: '',
footerClass: '',
}, },
) )
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'close'): void
}>() }>()
const attrs = useAttrs() const attrs = useAttrs()
const generatedId = useId() const generatedId = useId()
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`) const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
const titleId = computed(() => `${componentId.value}-title`)
const isControlled = computed(() => props.modelValue !== undefined) const isControlled = computed(() => props.modelValue !== undefined)
const localValue = ref(false) const localValue = ref(false)
const isOpen = computed(() => const isOpen = computed(() =>
isControlled.value ? props.modelValue! : localValue.value, isControlled.value ? props.modelValue! : localValue.value,
) )
const isRendered = ref(isOpen.value) const isRendered = ref(isOpen.value)
watch(isOpen, (val) => { const panelRef = ref<HTMLElement | null>(null)
if (val) isRendered.value = true
})
function close() { function close() {
if (!isControlled.value) { if (!isControlled.value) localValue.value = false
localValue.value = false
}
emit('update:modelValue', false) emit('update:modelValue', false)
emit('close')
} }
</script> </script>
<style scoped> <style scoped>
.drawer-enter-active, .drawer-right-enter-active,
.drawer-leave-active { .drawer-right-leave-active,
.drawer-left-enter-active,
.drawer-left-leave-active {
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
.drawer-enter-active > div:last-child, .drawer-right-enter-active > div:last-child,
.drawer-leave-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; transition: transform 0.3s ease;
} }
.drawer-enter-from, .drawer-right-enter-from,
.drawer-leave-to { .drawer-right-leave-to,
.drawer-left-enter-from,
.drawer-left-leave-to {
opacity: 0; opacity: 0;
} }
.drawer-enter-from > div:last-child, .drawer-right-enter-from > div:last-child,
.drawer-leave-to > div:last-child { .drawer-right-leave-to > div:last-child {
transform: translateX(100%); transform: translateX(100%);
} }
.drawer-left-enter-from > div:last-child,
.drawer-left-leave-to > div:last-child {
transform: translateX(-100%);
}
</style> </style>