feat(ui) : TabList — fenêtrage maxVisibleTabs + flèches navigation
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) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ type TabListProps = {
|
|||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
maxVisibleTabs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabListForTest = TabList as DefineComponent<TabListProps>
|
const TabListForTest = TabList as DefineComponent<TabListProps>
|
||||||
@@ -185,3 +186,127 @@ describe('MalioTabList', () => {
|
|||||||
expect(buttons[1].attributes('aria-selected')).toBe('false')
|
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: '<p>Panel 1</p>'},
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,36 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-bind="$attrs">
|
<div v-bind="$attrs">
|
||||||
<div
|
<div class="flex items-center justify-center gap-4">
|
||||||
role="tablist"
|
|
||||||
class="flex justify-center gap-[60px] border-b border-m-primary"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-if="isWindowed"
|
||||||
:id="`${componentId}-tab-${tab.key}`"
|
|
||||||
:key="tab.key"
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
type="button"
|
||||||
:aria-selected="activeTab === tab.key"
|
aria-label="Onglets précédents"
|
||||||
:aria-controls="`${componentId}-panel-${tab.key}`"
|
data-test="tab-prev"
|
||||||
:aria-disabled="!!tab.disabled"
|
:disabled="!canPrev"
|
||||||
:tabindex="activeTab === tab.key ? 0 : -1"
|
|
||||||
:disabled="tab.disabled"
|
|
||||||
:class="[
|
:class="[
|
||||||
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
'text-m-primary',
|
||||||
activeTab === tab.key
|
canPrev ? 'cursor-pointer hover:text-m-primary/70' : 'cursor-not-allowed text-m-primary/30',
|
||||||
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
|
||||||
: tab.disabled
|
|
||||||
? 'cursor-not-allowed text-m-primary/50'
|
|
||||||
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
|
||||||
]"
|
]"
|
||||||
@click="selectTab(tab.key)"
|
@click="prev"
|
||||||
>
|
>
|
||||||
<IconifyIcon
|
<IconifyIcon icon="mdi:chevron-left" :width="28" />
|
||||||
v-if="tab.icon"
|
</button>
|
||||||
:icon="tab.icon"
|
|
||||||
:width="tab.iconSize ?? 24"
|
<div
|
||||||
/>
|
role="tablist"
|
||||||
{{ tab.label }}
|
class="flex justify-center gap-[60px] border-b border-m-primary"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="tab in visibleTabs"
|
||||||
|
:id="`${componentId}-tab-${tab.key}`"
|
||||||
|
:key="tab.key"
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
:aria-selected="activeTab === tab.key"
|
||||||
|
:aria-controls="`${componentId}-panel-${tab.key}`"
|
||||||
|
:aria-disabled="!!tab.disabled"
|
||||||
|
:tabindex="activeTab === tab.key ? 0 : -1"
|
||||||
|
:disabled="tab.disabled"
|
||||||
|
:class="[
|
||||||
|
'relative flex items-center gap-[18px] text-[24px] font-[600] transition-colors',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'cursor-pointer text-m-primary after:content-[\'\'] after:absolute after:-bottom-[3px] after:left-0 after:right-0 after:h-[3px] after:bg-m-primary'
|
||||||
|
: tab.disabled
|
||||||
|
? 'cursor-not-allowed text-m-primary/50'
|
||||||
|
: 'cursor-pointer text-m-primary/50 hover:text-m-primary/70',
|
||||||
|
]"
|
||||||
|
@click="selectTab(tab.key)"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="tab.icon"
|
||||||
|
:icon="tab.icon"
|
||||||
|
:width="tab.iconSize ?? 24"
|
||||||
|
/>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="isWindowed"
|
||||||
|
type="button"
|
||||||
|
aria-label="Onglets suivants"
|
||||||
|
data-test="tab-next"
|
||||||
|
:disabled="!canNext"
|
||||||
|
:class="[
|
||||||
|
'text-m-primary',
|
||||||
|
canNext ? 'cursor-pointer hover:text-m-primary/70' : 'cursor-not-allowed text-m-primary/30',
|
||||||
|
]"
|
||||||
|
@click="next"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="mdi:chevron-right" :width="28" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +80,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, useId} from 'vue'
|
import {computed, ref, useId, watch} from 'vue'
|
||||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||||
|
|
||||||
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
||||||
@@ -65,9 +97,11 @@ const props = withDefaults(defineProps<{
|
|||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
maxVisibleTabs?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: undefined,
|
modelValue: undefined,
|
||||||
id: '',
|
id: '',
|
||||||
|
maxVisibleTabs: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -84,6 +118,40 @@ const activeTab = computed(() =>
|
|||||||
isControlled.value ? props.modelValue! : localValue.value,
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isWindowed = computed(() =>
|
||||||
|
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxStartIndex = computed(() =>
|
||||||
|
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const startIndex = ref(0)
|
||||||
|
|
||||||
|
const visibleTabs = computed(() =>
|
||||||
|
isWindowed.value
|
||||||
|
? props.tabs.slice(startIndex.value, startIndex.value + props.maxVisibleTabs!)
|
||||||
|
: props.tabs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canPrev = computed(() => isWindowed.value && startIndex.value > 0)
|
||||||
|
const canNext = computed(() => isWindowed.value && startIndex.value < maxStartIndex.value)
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (!canPrev.value) return
|
||||||
|
startIndex.value -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!canNext.value) return
|
||||||
|
startIndex.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp startIndex back in range if tabs or maxVisibleTabs change.
|
||||||
|
watch(maxStartIndex, (max) => {
|
||||||
|
if (startIndex.value > max) startIndex.value = max
|
||||||
|
})
|
||||||
|
|
||||||
function selectTab(key: string) {
|
function selectTab(key: string) {
|
||||||
const tab = props.tabs.find(t => t.key === key)
|
const tab = props.tabs.find(t => t.key === key)
|
||||||
if (tab?.disabled) return
|
if (tab?.disabled) return
|
||||||
|
|||||||
Reference in New Issue
Block a user