import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import { describe, expect, it } from 'vitest' import BaseModal from '../../components/BaseModal.vue' import type { ModalTab } from '../../types' const stripTabs: ModalTab[] = [ { id: 'processing', label: 'Processing', meta: '6' }, { id: 'peaks', label: 'Peak detection' }, { id: 'correction', label: 'Correction', disabled: true }, { id: 'appearance', label: 'Appearance' }, ] const railTabs: ModalTab[] = [ { id: 'profile', label: 'Profile', icon: 'M20 21a8 8 0 0 0-16 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8' }, { id: 'security', label: 'Security', meta: '2', description: 'Passkeys and institutional sign-in.' }, { id: 'appearance', label: 'Appearance' }, ] function mountModal(props: Record = {}, slots: Record = {}) { return mount(BaseModal, { props: { modelValue: true, title: 'Settings', ...props, }, slots: { default: '

Body

', ...slots, }, global: { stubs: { teleport: true, }, }, }) } describe('BaseModal', () => { describe('plain layout', () => { it('should not render a tab strip when layout is omitted', () => { const wrapper = mountModal() expect(wrapper.find('.mint-modal__tabs').exists()).toBe(false) }) }) describe('tabs layout', () => { it('should render a shell-level tablist when layout is tabs', () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' }) expect(wrapper.find('.mint-modal__tabs[role="tablist"]').exists()).toBe(true) }) it('should mark the active tab with aria-selected when a tab is active', () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'peaks' }) const active = wrapper.findAll('.mint-modal__tab')[1] expect(active.attributes('aria-selected')).toBe('true') }) it('should render the tab meta when a tab defines one', () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' }) expect(wrapper.find('.mint-modal__tab-meta').text()).toBe('6') }) it('should emit update:activeTab when another tab is clicked', async () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' }) await wrapper.findAll('.mint-modal__tab')[1].trigger('click') expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['peaks']) }) it('should not emit update:activeTab when a disabled tab is clicked', async () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' }) await wrapper.findAll('.mint-modal__tab')[2].trigger('click') expect(wrapper.emitted('update:activeTab')).toBeUndefined() }) it('should skip disabled tabs when moving with ArrowRight', async () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'peaks' }) await wrapper.findAll('.mint-modal__tab')[1].trigger('keydown.right') expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['appearance']) }) it('should apply the fixed-height body modifier when layout is tabs', () => { const wrapper = mountModal({ layout: 'tabs', tabs: stripTabs, activeTab: 'processing' }) expect(wrapper.find('.mint-modal__body').classes()).toContain('mint-modal__body--tabbed') }) }) describe('rail layout', () => { it('should render a vertical tablist when layout is rail', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) expect(wrapper.find('.mint-modal__rail[aria-orientation="vertical"]').exists()).toBe(true) }) it('should show the active tab label as the pane title', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'security' }) expect(wrapper.find('.mint-modal__pane-title').text()).toBe('Security') }) it('should show the active tab description as the pane subtitle', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'security' }) expect(wrapper.find('.mint-modal__pane-subtitle').text()).toBe('Passkeys and institutional sign-in.') }) it('should emit update:activeTab when a rail item is clicked', async () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) await wrapper.findAll('.mint-modal__rail-item')[1].trigger('click') expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['security']) }) it('should move to the next tab when ArrowDown is pressed on a rail item', async () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) await wrapper.findAll('.mint-modal__rail-item')[0].trigger('keydown.down') expect(wrapper.emitted('update:activeTab')?.at(-1)).toEqual(['security']) }) it('should render the rail-footer slot inside the rail', () => { const wrapper = mountModal( { layout: 'rail', tabs: railTabs, activeTab: 'profile' }, { 'rail-footer': '' }, ) expect(wrapper.find('.mint-modal__rail .rail-logout').exists()).toBe(true) }) it('should not render the plain body when layout is rail', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) expect(wrapper.find('.mint-modal__body').exists()).toBe(false) }) it('should render the default slot inside the pane body when layout is rail', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) expect(wrapper.find('.mint-modal__pane-body .body-content').exists()).toBe(true) }) it('should render a heading for each rail section when tabs carry a section', () => { const sectionTabs: ModalTab[] = [ { id: 'general', label: 'General', section: 'Plugin' }, { id: 'advanced', label: 'Advanced', section: 'Plugin' }, { id: 'appearance', label: 'Appearance', section: 'Personal' }, ] const wrapper = mountModal({ layout: 'rail', tabs: sectionTabs, activeTab: 'general' }) expect(wrapper.findAll('.mint-modal__rail-section').map(heading => heading.text())).toEqual(['Plugin', 'Personal']) }) it('should render a divider before a rail section that follows another', () => { const sectionTabs: ModalTab[] = [ { id: 'general', label: 'General', section: 'Plugin' }, { id: 'appearance', label: 'Appearance', section: 'Personal' }, ] const wrapper = mountModal({ layout: 'rail', tabs: sectionTabs, activeTab: 'general' }) expect(wrapper.findAll('.mint-modal__rail-divider')).toHaveLength(1) }) it('should not render rail section headings when no tab carries a section', () => { const wrapper = mountModal({ layout: 'rail', tabs: railTabs, activeTab: 'profile' }) expect(wrapper.find('.mint-modal__rail-section').exists()).toBe(false) }) it('should render the pane-header slot in place of the default pane title', () => { const wrapper = mountModal( { layout: 'rail', tabs: railTabs, activeTab: 'security' }, { 'pane-header': '
Custom
' }, ) expect(wrapper.find('.mint-modal__pane-header .custom-head').exists()).toBe(true) }) it('should pass the active tab to the pane-header slot', () => { const wrapper = mount(BaseModal, { props: { modelValue: true, layout: 'rail', tabs: railTabs, activeTab: 'security' }, slots: { 'pane-header': '' }, global: { stubs: { teleport: true } }, }) expect(wrapper.find('.slot-tab').text()).toBe('Security') }) }) describe('keyboard', () => { function pressEscape() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) } it('should close the topmost modal when Escape is pressed with two modals open', () => { const outer = mountModal() const inner = mountModal({ title: 'Nested' }) pressEscape() expect(inner.emitted('update:modelValue')).toEqual([[false]]) outer.unmount() inner.unmount() }) it('should keep the outer modal open when Escape closes a nested modal', () => { const outer = mountModal() const inner = mountModal({ title: 'Nested' }) pressEscape() expect(outer.emitted('update:modelValue')).toBeUndefined() outer.unmount() inner.unmount() }) it('should focus the first focusable element when mounted already open', async () => { const attached = mount(BaseModal, { props: { modelValue: true, title: 'Settings' }, slots: { default: '' }, attachTo: document.body, }) await nextTick() await nextTick() expect(document.activeElement?.getAttribute('aria-label')).toBe('Close modal') attached.unmount() }) it('should focus the autofocus element when the modal content marks one', async () => { const attached = mount(BaseModal, { props: { modelValue: true, title: 'Settings' }, slots: { default: '' }, attachTo: document.body, }) await nextTick() await nextTick() expect(document.activeElement?.className).toBe('first-field') attached.unmount() }) }) })