import {describe, expect, it} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import {Icon as IconifyIcon} from '@iconify/vue' import TabList from './TabList.vue' type Tab = { key: string label: string icon?: string disabled?: boolean } type TabListProps = { tabs: Tab[] modelValue?: string id?: string maxVisibleTabs?: number maxWidth?: number } const TabListForTest = TabList as DefineComponent const tabs: Tab[] = [ {key: 'home', label: 'Accueil', icon: 'mdi:home'}, {key: 'settings', label: 'Paramètres'}, {key: 'profile', label: 'Profil', icon: 'mdi:account'}, ] function mountComponent(props: TabListProps, slots?: Record) { return mount(TabListForTest, { props, slots, }) } describe('MalioTabList', () => { it('renders all tab buttons with correct labels', () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons).toHaveLength(3) expect(buttons[0].text()).toContain('Accueil') expect(buttons[1].text()).toContain('Paramètres') expect(buttons[2].text()).toContain('Profil') }) it('renders icons for tabs that have one', () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[0].find('svg').exists()).toBe(true) expect(buttons[2].find('svg').exists()).toBe(true) }) it('does not render icon when tab has no icon', () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[1].find('svg').exists()).toBe(false) }) it('first tab is active by default in uncontrolled mode', () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[0].attributes('aria-selected')).toBe('true') expect(buttons[1].attributes('aria-selected')).toBe('false') expect(buttons[2].attributes('aria-selected')).toBe('false') }) it('shows the panel content for the active tab (v-show)', () => { const wrapper = mountComponent({tabs}, { home: '

Home content

', settings: '

Settings content

', profile: '

Profile content

', }) const panels = wrapper.findAll('[role="tabpanel"]') expect(panels[0].attributes('style')).toBeUndefined() expect(panels[1].attributes('style')).toContain('display: none') expect(panels[2].attributes('style')).toContain('display: none') }) it('switches tab on click in uncontrolled mode', async () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') await buttons[1].trigger('click') expect(buttons[1].attributes('aria-selected')).toBe('true') expect(buttons[0].attributes('aria-selected')).toBe('false') }) it('emits update:modelValue on click in controlled mode', async () => { const wrapper = mountComponent({tabs, modelValue: 'home'}) const buttons = wrapper.findAll('[role="tab"]') await buttons[2].trigger('click') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['profile']) }) it('respects modelValue for active tab in controlled mode', () => { const wrapper = mountComponent({tabs, modelValue: 'settings'}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[0].attributes('aria-selected')).toBe('false') expect(buttons[1].attributes('aria-selected')).toBe('true') expect(buttons[2].attributes('aria-selected')).toBe('false') }) it('sets correct aria-controls and aria-labelledby', () => { const wrapper = mountComponent({tabs, id: 'test'}) const buttons = wrapper.findAll('[role="tab"]') const panels = wrapper.findAll('[role="tabpanel"]') expect(buttons[0].attributes('aria-controls')).toBe('test-panel-home') expect(buttons[1].attributes('aria-controls')).toBe('test-panel-settings') expect(panels[0].attributes('aria-labelledby')).toBe('test-tab-home') expect(panels[1].attributes('aria-labelledby')).toBe('test-tab-settings') }) it('has role="tablist" on the tab container', () => { const wrapper = mountComponent({tabs}) expect(wrapper.find('[role="tablist"]').exists()).toBe(true) }) it('active tab has tabindex 0, others have -1', () => { const wrapper = mountComponent({tabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[0].attributes('tabindex')).toBe('0') expect(buttons[1].attributes('tabindex')).toBe('-1') expect(buttons[2].attributes('tabindex')).toBe('-1') }) it('renders icon props correctly via findComponent', () => { const wrapper = mount(TabListForTest, { props: {tabs}, }) const icons = wrapper.findAllComponents(IconifyIcon) expect(icons).toHaveLength(2) expect(icons[0].props('icon')).toBe('mdi:home') expect(icons[1].props('icon')).toBe('mdi:account') }) it('sets disabled attribute and aria-disabled on disabled tabs', () => { const disabledTabs: Tab[] = [ {key: 'a', label: 'A'}, {key: 'b', label: 'B', disabled: true}, ] const wrapper = mountComponent({tabs: disabledTabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[1].attributes('disabled')).toBeDefined() expect(buttons[1].attributes('aria-disabled')).toBe('true') }) it('applies cursor-not-allowed on disabled tabs', () => { const disabledTabs: Tab[] = [ {key: 'a', label: 'A'}, {key: 'b', label: 'B', disabled: true}, ] const wrapper = mountComponent({tabs: disabledTabs}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons[1].classes()).toContain('cursor-not-allowed') expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70') }) it('does not emit update:modelValue when clicking a disabled tab', async () => { const disabledTabs: Tab[] = [ {key: 'a', label: 'A'}, {key: 'b', label: 'B', disabled: true}, ] const wrapper = mountComponent({tabs: disabledTabs, modelValue: 'a'}) const buttons = wrapper.findAll('[role="tab"]') await buttons[1].trigger('click') expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) it('does not change active tab in uncontrolled mode when clicking disabled tab', async () => { const disabledTabs: Tab[] = [ {key: 'a', label: 'A'}, {key: 'b', label: 'B', disabled: true}, ] const wrapper = mountComponent({tabs: disabledTabs}) const buttons = wrapper.findAll('[role="tab"]') await buttons[1].trigger('click') expect(buttons[0].attributes('aria-selected')).toBe('true') expect(buttons[1].attributes('aria-selected')).toBe('false') }) }) describe('MalioTabList — fenêtrage maxVisibleTabs', () => { const sevenTabs: Tab[] = [ {key: 't1', label: 'Tab 1'}, {key: 't2', label: 'Tab 2'}, {key: 't3', label: 'Tab 3'}, {key: 't4', label: 'Tab 4'}, {key: 't5', label: 'Tab 5'}, {key: 't6', label: 'Tab 6'}, {key: 't7', label: 'Tab 7'}, ] it('applies the default maxWidth (1100px) on the tabs container when windowed', () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px') }) it('applies a custom maxWidth on the tabs container', () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200}) expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px') }) it('renders only maxVisibleTabs buttons and disables prev at start', () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) const buttons = wrapper.findAll('[role="tab"]') expect(buttons).toHaveLength(5) expect(buttons[0].text()).toContain('Tab 1') expect(buttons[4].text()).toContain('Tab 5') const prev = wrapper.find('[data-test="tab-prev"]') const next = wrapper.find('[data-test="tab-next"]') expect(prev.exists()).toBe(true) expect(next.exists()).toBe(true) expect(prev.attributes('disabled')).toBeDefined() expect(next.attributes('disabled')).toBeUndefined() }) it('shifts the window by 1 on next click', async () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) await wrapper.find('[data-test="tab-next"]').trigger('click') const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) expect(labels.some(l => l.includes('Tab 1'))).toBe(false) expect(labels.some(l => l.includes('Tab 6'))).toBe(true) expect(labels).toHaveLength(5) expect(wrapper.find('[data-test="tab-prev"]').attributes('disabled')).toBeUndefined() }) it('disables next at the end and shows the last window', async () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) // 7 - 5 = 2 clicks to reach the end await wrapper.find('[data-test="tab-next"]').trigger('click') await wrapper.find('[data-test="tab-next"]').trigger('click') const next = wrapper.find('[data-test="tab-next"]') expect(next.attributes('disabled')).toBeDefined() const buttons = wrapper.findAll('[role="tab"]') expect(buttons).toHaveLength(5) // last window starts at tabs[length-5] = tabs[2] = Tab 3 expect(buttons[0].text()).toContain('Tab 3') expect(buttons[4].text()).toContain('Tab 7') }) it('clicking next past the end does not overshoot', async () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) const next = wrapper.find('[data-test="tab-next"]') await next.trigger('click') await next.trigger('click') await next.trigger('click') // guarded, no-op await next.trigger('click') // guarded, no-op const buttons = wrapper.findAll('[role="tab"]') expect(buttons).toHaveLength(5) expect(buttons[0].text()).toContain('Tab 3') expect(buttons[4].text()).toContain('Tab 7') }) it('renders no arrows and all tabs when maxVisibleTabs is undefined', () => { const wrapper = mountComponent({tabs: sevenTabs}) expect(wrapper.findAll('[role="tab"]')).toHaveLength(7) expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false) expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false) }) it('renders no arrows when maxVisibleTabs >= tabs.length', () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 7}) expect(wrapper.findAll('[role="tab"]')).toHaveLength(7) expect(wrapper.find('[data-test="tab-prev"]').exists()).toBe(false) expect(wrapper.find('[data-test="tab-next"]').exists()).toBe(false) }) it('selecting a visible tab activates it without moving the window', async () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) const buttons = wrapper.findAll('[role="tab"]') await buttons[2].trigger('click') const after = wrapper.findAll('[role="tab"]') expect(after[2].attributes('aria-selected')).toBe('true') // window unchanged expect(after[0].text()).toContain('Tab 1') expect(after).toHaveLength(5) }) it('keeps the active panel rendered even when its tab is outside the window', async () => { const wrapper = mountComponent( {tabs: sevenTabs, maxVisibleTabs: 5, modelValue: 't1'}, {t1: '

Panel 1

'}, ) await wrapper.find('[data-test="tab-next"]').trigger('click') await wrapper.find('[data-test="tab-next"]').trigger('click') // Tab 1 is no longer in the window const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) expect(labels.some(l => l.includes('Tab 1'))).toBe(false) // but its panel is still rendered and visible const panels = wrapper.findAll('[role="tabpanel"]') expect(panels).toHaveLength(7) expect(wrapper.text()).toContain('Panel 1') }) it('keeps exactly one rendered tab with tabindex=0 when the active tab scrolls out of the window', async () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) // active tab is the first one (t1) by default; scroll it out of the window await wrapper.find('[data-test="tab-next"]').trigger('click') await wrapper.find('[data-test="tab-next"]').trigger('click') // t1 is no longer rendered const labels = wrapper.findAll('[role="tab"]').map(b => b.text()) expect(labels.some(l => l.includes('Tab 1'))).toBe(false) const focusable = wrapper.findAll('[role="tab"]').filter(b => b.attributes('tabindex') === '0') expect(focusable).toHaveLength(1) // falls back to the first visible tab (Tab 3) expect(focusable[0].text()).toContain('Tab 3') }) it('arrows expose aria-labels', () => { const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5}) expect(wrapper.find('[data-test="tab-prev"]').attributes('aria-label')).toBe('Onglets précédents') expect(wrapper.find('[data-test="tab-next"]').attributes('aria-label')).toBe('Onglets suivants') }) })