From df2b8badc8aa2d9a8e613df0bbdb2a8714c3f0a6 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 17:30:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui)=20:=20TabList=20=E2=80=94=20fen=C3=AAt?= =?UTF-8?q?rage=20maxVisibleTabs=20+=20fl=C3=A8ches=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'une prop maxVisibleTabs activant un fenêtrage carousel (sans animation) avec flèches gauche/droite. Les flèches sont désactivées aux bornes, les panneaux hors fenêtre restent montés, et startIndex est clampé si tabs/maxVisibleTabs changent. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/malio/tab/TabList.test.ts | 125 +++++++++++++++++++++++ app/components/malio/tab/TabList.vue | 122 +++++++++++++++++----- 2 files changed, 220 insertions(+), 27 deletions(-) diff --git a/app/components/malio/tab/TabList.test.ts b/app/components/malio/tab/TabList.test.ts index 48874ac..7e91ad2 100644 --- a/app/components/malio/tab/TabList.test.ts +++ b/app/components/malio/tab/TabList.test.ts @@ -15,6 +15,7 @@ type TabListProps = { tabs: Tab[] modelValue?: string id?: string + maxVisibleTabs?: number } const TabListForTest = TabList as DefineComponent @@ -185,3 +186,127 @@ describe('MalioTabList', () => { 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('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('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') + }) +}) diff --git a/app/components/malio/tab/TabList.vue b/app/components/malio/tab/TabList.vue index efa4f3d..969e26a 100644 --- a/app/components/malio/tab/TabList.vue +++ b/app/components/malio/tab/TabList.vue @@ -1,36 +1,68 @@