feat : réécriture du squelette MalioDrawer (slots, side, contrôlé/non-contrôlé)
This commit is contained in:
@@ -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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user