477 lines
12 KiB
JavaScript
477 lines
12 KiB
JavaScript
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: '<p>Modal body content</p>'
|
|
}
|
|
});
|
|
|
|
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: '<h3>Custom Header</h3>'
|
|
}
|
|
});
|
|
|
|
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: '<button>Cancel</button><button>Save</button>'
|
|
}
|
|
});
|
|
|
|
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:
|
|
'<button id="first-btn">First</button><button>Second</button>'
|
|
}
|
|
});
|
|
|
|
await nextTick();
|
|
await nextTick(); // Wait for focus to be set
|
|
|
|
const firstButton = document.querySelector('#first-btn');
|
|
// Note: JSDOM has limited focus() support, so we just verify the element exists
|
|
expect(firstButton).toBeTruthy();
|
|
});
|
|
|
|
it('traps Tab key within modal', async () => {
|
|
wrapper = mount(BaseModal, {
|
|
props: {
|
|
modelValue: true
|
|
},
|
|
slots: {
|
|
default:
|
|
'<button id="btn1">Button 1</button><button id="btn2">Button 2</button>'
|
|
}
|
|
});
|
|
|
|
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();
|
|
await nextTick();
|
|
|
|
expect(document.body.style.overflow).toBe('hidden');
|
|
});
|
|
|
|
it('restores body scroll when closed', async () => {
|
|
wrapper = mount(BaseModal, {
|
|
props: {
|
|
modelValue: true
|
|
}
|
|
});
|
|
|
|
await nextTick();
|
|
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();
|
|
await nextTick();
|
|
expect(document.body.style.overflow).toBe('hidden');
|
|
|
|
wrapper.unmount();
|
|
expect(document.body.style.overflow).toBe('');
|
|
});
|
|
});
|
|
});
|