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 Modal from './Modal.vue' type ModalProps = { id?: string modelValue?: boolean showClose?: boolean dismissable?: boolean closeOnEscape?: boolean ariaLabel?: string modalClass?: string overlayClass?: string headerClass?: string bodyClass?: string footerClass?: string } const ModalForTest = Modal as DefineComponent function mountComponent(props: ModalProps = {}, slots?: Record) { return mount(ModalForTest, { props, slots, global: { stubs: { Teleport: true } }, }) } describe('MalioModal', () => { 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('centers the modal (items-center justify-center)', () => { const wrapper = mountComponent({ modelValue: true }) const root = wrapper.find('.fixed') expect(root.classes()).toContain('items-center') expect(root.classes()).toContain('justify-center') }) 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-modal' }) expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal') }) it('generates an id when not provided', () => { const wrapper = mountComponent({ modelValue: true }) expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/) }) 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 modalClass to the panel', () => { const wrapper = mountComponent({ modelValue: true, modalClass: '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-modal' }, { header: '

Titre

' }, ) const panel = wrapper.find('[data-test="panel"]') expect(panel.attributes('aria-labelledby')).toBe('test-modal-header') expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header') }) it('sets aria-label from ariaLabel when no #header is provided', () => { const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' }) const panel = wrapper.find('[data-test="panel"]') expect(panel.attributes('aria-label')).toBe('Boîte de dialogue') 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 in a footer pinned below the body', () => { const wrapper = mountComponent( { modelValue: true }, { footer: '' }, ) expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false) expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true) }) it('does not render the footer 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', () => { const wrapper = mountComponent( { modelValue: true, footerClass: 'justify-end' }, { footer: 'pied' }, ) expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end') }) 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(ModalForTest, { 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(ModalForTest, { 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('wraps focus to the first element when Tab is pressed on the last element', async () => { const wrapper = mount(ModalForTest, { 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(ModalForTest, { 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 modal closes while another is still open', async () => { const wrapperA = mount(ModalForTest, { props: { modelValue: false }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) const wrapperB = mount(ModalForTest, { props: { modelValue: false }, attachTo: document.body, global: { stubs: { Teleport: true } }, }) await wrapperA.setProps({ modelValue: true }) expect(document.body.style.overflow).toBe('hidden') await wrapperB.setProps({ modelValue: true }) expect(document.body.style.overflow).toBe('hidden') await wrapperB.setProps({ modelValue: false }) expect(document.body.style.overflow).toBe('hidden') await wrapperA.setProps({ modelValue: false }) expect(document.body.style.overflow).toBe('') }) })