import { afterEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, mount } from '@vue/test-utils' import type { DefineComponent } from 'vue' import { Icon as IconifyIcon } from '@iconify/vue' import Drawer from './Drawer.vue' type DrawerProps = { id?: string modelValue?: boolean side?: 'right' | 'left' showClose?: boolean dismissable?: boolean closeOnEscape?: boolean ariaLabel?: string drawerClass?: string overlayClass?: string headerClass?: string bodyClass?: string footerClass?: string } const DrawerForTest = Drawer as DefineComponent function mountComponent(props: DrawerProps = {}, slots?: Record) { return mount(DrawerForTest, { props, slots, global: { stubs: { Teleport: true } }, }) } describe('MalioDrawer', () => { enableAutoUnmount(afterEach) afterEach(() => { document.body.style.overflow = '' }) it('does not render when modelValue is false', () => { const wrapper = mountComponent({ modelValue: false }) expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) }) it('renders the panel when modelValue is true', () => { const wrapper = mountComponent({ modelValue: true }) expect(wrapper.find('[data-test="panel"]').exists()).toBe(true) }) it('renders default slot in the body', () => { const wrapper = mountComponent( { modelValue: true }, { default: '

Contenu

' }, ) expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu') }) it('works in uncontrolled mode (defaults closed)', () => { const wrapper = mountComponent() expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) }) 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 }) expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-drawer-/) }) it('has role="dialog" and aria-modal on the 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('applies drawerClass to the panel', () => { const wrapper = mountComponent({ modelValue: true, drawerClass: '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: '

Titre

' }, ) 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: '

Titre

' }, ) 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: '

Titre

' }, ) 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') }) it('renders the #footer slot inside the body (scrollable zone)', () => { const wrapper = mountComponent( { modelValue: true }, { footer: '' }, ) expect(wrapper.find('[data-test="body"] [data-test="footer"] [data-test="save"]').exists()).toBe(true) }) it('does not render the footer wrapper when no #footer slot', () => { const wrapper = mountComponent({ modelValue: true }) expect(wrapper.find('[data-test="footer"]').exists()).toBe(false) }) it('applies bodyClass to the body', () => { const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' }) expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10') }) it('applies footerClass to the footer wrapper', () => { const wrapper = mountComponent( { modelValue: true, footerClass: 'sticky bottom-0' }, { footer: 'pied' }, ) const footer = wrapper.find('[data-test="footer"]') expect(footer.classes()).toContain('sticky') expect(footer.classes()).toContain('bottom-0') }) it('aligns to the right by default', () => { const wrapper = mountComponent({ modelValue: true }) expect(wrapper.find('.fixed').classes()).toContain('justify-end') }) it('aligns to the left when side is "left"', () => { const wrapper = mountComponent({ modelValue: true, side: 'left' }) expect(wrapper.find('.fixed').classes()).toContain('justify-start') }) it('emits update:modelValue false and close on backdrop click (dismissable)', async () => { const wrapper = mountComponent({ modelValue: true }) await wrapper.find('[data-test="backdrop"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) expect(wrapper.emitted('close')).toHaveLength(1) }) it('does not close on backdrop click when dismissable is false', async () => { const wrapper = mountComponent({ modelValue: true, dismissable: false }) await wrapper.find('[data-test="backdrop"]').trigger('click') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('applies overlayClass to the backdrop', () => { const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' }) expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70') }) it('closes on Escape key when closeOnEscape is true', async () => { const wrapper = mountComponent({ modelValue: true }) await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' }) expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) expect(wrapper.emitted('close')).toHaveLength(1) }) it('does not close on Escape when closeOnEscape is false', async () => { const wrapper = mountComponent({ modelValue: true, closeOnEscape: false }) await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' }) expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('locks body scroll when opened and restores it when closed', async () => { const wrapper = mountComponent({ modelValue: false }) expect(document.body.style.overflow).toBe('') await wrapper.setProps({ modelValue: true }) expect(document.body.style.overflow).toBe('hidden') await wrapper.setProps({ modelValue: false }) expect(document.body.style.overflow).toBe('') }) it('moves focus into the panel when opened', async () => { const wrapper = mount(DrawerForTest, { props: { modelValue: false, showClose: false }, slots: { default: '' }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapper.setProps({ modelValue: true }) await wrapper.vm.$nextTick() const first = wrapper.find('[data-test="first"]').element expect(document.activeElement).toBe(first) wrapper.unmount() }) it('restores focus to the trigger when closed', async () => { const trigger = document.createElement('button') document.body.appendChild(trigger) trigger.focus() expect(document.activeElement).toBe(trigger) const wrapper = mount(DrawerForTest, { props: { modelValue: false }, slots: { default: '' }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapper.setProps({ modelValue: true }) await wrapper.vm.$nextTick() await wrapper.setProps({ modelValue: false }) await wrapper.vm.$nextTick() expect(document.activeElement).toBe(trigger) wrapper.unmount() trigger.remove() }) it('moves focus to the close button on open (default showClose)', async () => { const wrapper = mount(DrawerForTest, { props: { modelValue: false, showClose: true }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapper.setProps({ modelValue: true }) await wrapper.vm.$nextTick() expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element) wrapper.unmount() }) it('wraps focus to the first element when Tab is pressed on the last element', async () => { const wrapper = mount(DrawerForTest, { props: { modelValue: true, showClose: false }, slots: { default: '' }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapper.vm.$nextTick() const last = wrapper.find('[data-test="btn2"]').element as HTMLElement last.focus() expect(document.activeElement).toBe(last) await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' }) expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element) wrapper.unmount() }) it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => { const wrapper = mount(DrawerForTest, { props: { modelValue: true, showClose: false }, slots: { default: '' }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapper.vm.$nextTick() const first = wrapper.find('[data-test="btn1"]').element as HTMLElement first.focus() expect(document.activeElement).toBe(first) await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true }) expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element) wrapper.unmount() }) it('does not release body scroll-lock when one stacked drawer closes while another is still open', async () => { const wrapperA = mount(DrawerForTest, { props: { modelValue: false }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) const wrapperB = mount(DrawerForTest, { props: { modelValue: false }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) // Open drawer A → scroll locked await wrapperA.setProps({ modelValue: true }) expect(document.body.style.overflow).toBe('hidden') // Open drawer B → still locked await wrapperB.setProps({ modelValue: true }) expect(document.body.style.overflow).toBe('hidden') // Close drawer B → A is still open, scroll must remain locked await wrapperB.setProps({ modelValue: false }) expect(document.body.style.overflow).toBe('hidden') // Close drawer A → both closed, scroll-lock released await wrapperA.setProps({ modelValue: false }) expect(document.body.style.overflow).toBe('') }) })