feat : barre header, slot #header, bouton fermer et ARIA du MalioDrawer

This commit is contained in:
2026-05-21 16:34:18 +02:00
parent 31f0cb38cd
commit 9ee94b6f9d
2 changed files with 106 additions and 1 deletions

View File

@@ -74,4 +74,75 @@ describe('MalioDrawer', () => {
const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' }) const wrapper = mountComponent({ modelValue: true, drawerClass: 'max-w-2xl' })
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl') expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
}) })
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')
})
}) })

View File

@@ -25,9 +25,38 @@
)" )"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
:aria-labelledby="hasHeader ? headerId : undefined"
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
tabindex="-1" tabindex="-1"
data-test="panel" data-test="panel"
> >
<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"
>
<slot name="header" />
</div>
<button
v-if="showClose"
type="button"
aria-label="Fermer"
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:cancel-bold"
:width="16"
:height="16"
/>
</button>
</div>
<div <div
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)" :class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
data-test="body" data-test="body"
@@ -41,7 +70,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useAttrs, useId } from 'vue' import { computed, ref, useAttrs, useId, useSlots } 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 })
@@ -87,6 +117,10 @@ const generatedId = useId()
const componentId = computed(() => props.id || `malio-drawer-${generatedId}`) const componentId = computed(() => props.id || `malio-drawer-${generatedId}`)
const slots = useSlots()
const headerId = computed(() => `${componentId.value}-header`)
const hasHeader = computed(() => !!slots.header)
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(() =>