diff --git a/app/components/malio/modal/Modal.test.ts b/app/components/malio/modal/Modal.test.ts new file mode 100644 index 0000000..60f9a1d --- /dev/null +++ b/app/components/malio/modal/Modal.test.ts @@ -0,0 +1,320 @@ +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('') + }) +}) diff --git a/app/components/malio/modal/Modal.vue b/app/components/malio/modal/Modal.vue new file mode 100644 index 0000000..cb28c48 --- /dev/null +++ b/app/components/malio/modal/Modal.vue @@ -0,0 +1,280 @@ + + + + + + +