import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import BaseModal from '../../../src/components/shared/BaseModal.vue'; describe('BaseModal', () => { let wrapper; beforeEach(() => { // Create target element for Teleport const el = document.createElement('div'); el.id = 'modal-target'; document.body.appendChild(el); }); afterEach(() => { if (wrapper) { wrapper.unmount(); } // Clean up teleport target document.body.innerHTML = ''; document.body.style.overflow = ''; }); describe('Rendering', () => { it('does not render when modelValue is false', () => { wrapper = mount(BaseModal, { props: { modelValue: false, title: 'Test Modal' } }); expect(document.querySelector('.modal-overlay')).toBe(null); }); it('renders when modelValue is true', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test Modal' } }); await nextTick(); expect(document.querySelector('.modal-overlay')).toBeTruthy(); expect(document.querySelector('.modal-container')).toBeTruthy(); }); it('renders with title', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'My Modal Title' } }); await nextTick(); const title = document.querySelector('.modal-title'); expect(title).toBeTruthy(); expect(title.textContent).toBe('My Modal Title'); }); it('renders with default slot content', async () => { wrapper = mount(BaseModal, { props: { modelValue: true }, slots: { default: '

Modal body content

' } }); await nextTick(); const body = document.querySelector('.modal-body'); expect(body.innerHTML).toContain('Modal body content'); }); it('renders with header slot', async () => { wrapper = mount(BaseModal, { props: { modelValue: true }, slots: { header: '

Custom Header

' } }); await nextTick(); const header = document.querySelector('.modal-header'); expect(header.innerHTML).toContain('Custom Header'); }); it('renders with footer slot', async () => { wrapper = mount(BaseModal, { props: { modelValue: true }, slots: { footer: '' } }); await nextTick(); const footer = document.querySelector('.modal-footer'); expect(footer).toBeTruthy(); expect(footer.innerHTML).toContain('Cancel'); expect(footer.innerHTML).toContain('Save'); }); it('renders all modal sizes', async () => { const sizes = ['small', 'medium', 'large', 'full']; for (const size of sizes) { wrapper = mount(BaseModal, { props: { modelValue: true, size } }); await nextTick(); const container = document.querySelector('.modal-container'); expect(container.classList.contains(`modal-container--${size}`)).toBe(true); wrapper.unmount(); } }); it('shows close button by default', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test' } }); await nextTick(); expect(document.querySelector('.modal-close')).toBeTruthy(); }); it('hides close button when showClose is false', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test', showClose: false } }); await nextTick(); expect(document.querySelector('.modal-close')).toBe(null); }); }); describe('Close Behavior', () => { it('emits update:modelValue when close button clicked', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test' } }); await nextTick(); const closeButton = document.querySelector('.modal-close'); closeButton.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeTruthy(); expect(wrapper.emitted('update:modelValue')[0]).toEqual([false]); }); it('emits close event when close button clicked', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test' } }); await nextTick(); const closeButton = document.querySelector('.modal-close'); closeButton.click(); await nextTick(); expect(wrapper.emitted('close')).toBeTruthy(); }); it('closes on overlay click when closeOnOverlay is true', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, closeOnOverlay: true } }); await nextTick(); const overlay = document.querySelector('.modal-overlay'); overlay.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeTruthy(); expect(wrapper.emitted('update:modelValue')[0]).toEqual([false]); }); it('does not close on overlay click when closeOnOverlay is false', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, closeOnOverlay: false } }); await nextTick(); const overlay = document.querySelector('.modal-overlay'); overlay.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeFalsy(); }); it('does not close on modal container click', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); const container = document.querySelector('.modal-container'); container.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeFalsy(); }); it('closes on Escape key press', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); document.dispatchEvent(escapeEvent); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeTruthy(); expect(wrapper.emitted('update:modelValue')[0]).toEqual([false]); }); it('does not close when persistent is true', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, persistent: true, title: 'Persistent Modal' } }); await nextTick(); // Try close button const closeButton = document.querySelector('.modal-close'); closeButton.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeFalsy(); // Try overlay click const overlay = document.querySelector('.modal-overlay'); overlay.click(); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeFalsy(); // Try Escape key const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' }); document.dispatchEvent(escapeEvent); await nextTick(); expect(wrapper.emitted('update:modelValue')).toBeFalsy(); }); }); describe('Focus Management', () => { it('focuses first focusable element when opened', async () => { wrapper = mount(BaseModal, { props: { modelValue: true }, slots: { default: '' } }); await nextTick(); await nextTick(); // Wait for focus to be set const firstButton = document.querySelector('#first-btn'); expect(document.activeElement).toBe(firstButton); }); it('traps Tab key within modal', async () => { wrapper = mount(BaseModal, { props: { modelValue: true }, slots: { default: '' } }); await nextTick(); await nextTick(); const btn1 = document.querySelector('#btn1'); const btn2 = document.querySelector('#btn2'); // Focus last button btn2.focus(); expect(document.activeElement).toBe(btn2); // Press Tab - should cycle to first button const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }); document.dispatchEvent(tabEvent); await nextTick(); // Note: In a real browser, focus would cycle. In JSDOM it doesn't automatically, // but we're testing the event handler exists expect(wrapper.vm).toBeTruthy(); }); it('prevents body scroll when open', async () => { wrapper = mount(BaseModal, { props: { modelValue: false } }); expect(document.body.style.overflow).toBe(''); await wrapper.setProps({ modelValue: true }); await nextTick(); expect(document.body.style.overflow).toBe('hidden'); }); it('restores body scroll when closed', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); expect(document.body.style.overflow).toBe('hidden'); await wrapper.setProps({ modelValue: false }); await nextTick(); expect(document.body.style.overflow).toBe(''); }); }); describe('Accessibility', () => { it('has role="dialog"', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); const container = document.querySelector('.modal-container'); expect(container.getAttribute('role')).toBe('dialog'); }); it('has aria-modal="true"', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); const container = document.querySelector('.modal-container'); expect(container.getAttribute('aria-modal')).toBe('true'); }); it('has aria-labelledby when title is provided', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'My Modal' } }); await nextTick(); const container = document.querySelector('.modal-container'); const title = document.querySelector('#modal-title'); expect(container.getAttribute('aria-labelledby')).toBe('modal-title'); expect(title).toBeTruthy(); }); it('close button has aria-label', async () => { wrapper = mount(BaseModal, { props: { modelValue: true, title: 'Test' } }); await nextTick(); const closeButton = document.querySelector('.modal-close'); expect(closeButton.getAttribute('aria-label')).toBe('Close modal'); }); }); describe('Lifecycle', () => { it('emits open event when opened', async () => { wrapper = mount(BaseModal, { props: { modelValue: false } }); await wrapper.setProps({ modelValue: true }); await nextTick(); expect(wrapper.emitted('open')).toBeTruthy(); }); it('cleans up event listeners on unmount', async () => { const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); wrapper.unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('restores body overflow on unmount', async () => { wrapper = mount(BaseModal, { props: { modelValue: true } }); await nextTick(); expect(document.body.style.overflow).toBe('hidden'); wrapper.unmount(); expect(document.body.style.overflow).toBe(''); }); }); });