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:
2026-06-03 17:30:13 +02:00
parent 41a3df7481
commit df2b8badc8
2 changed files with 220 additions and 27 deletions
+95 -27
View File
@@ -1,36 +1,68 @@
<template>
<div v-bind="$attrs">
<div
role="tablist"
class="flex justify-center gap-[60px] border-b border-m-primary"
>
<div class="flex items-center justify-center gap-4">
<button
v-for="tab in tabs"
:id="`${componentId}-tab-${tab.key}`"
:key="tab.key"
role="tab"
v-if="isWindowed"
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"
aria-label="Onglets précédents"
data-test="tab-prev"
:disabled="!canPrev"
: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',
'text-m-primary',
canPrev ? 'cursor-pointer hover:text-m-primary/70' : 'cursor-not-allowed text-m-primary/30',
]"
@click="selectTab(tab.key)"
@click="prev"
>
<IconifyIcon
v-if="tab.icon"
:icon="tab.icon"
:width="tab.iconSize ?? 24"
/>
{{ tab.label }}
<IconifyIcon icon="mdi:chevron-left" :width="28" />
</button>
<div
role="tablist"
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>
</div>
@@ -48,7 +80,7 @@
</template>
<script setup lang="ts">
import {computed, ref, useId} from 'vue'
import {computed, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue'
defineOptions({name: 'MalioTabList', inheritAttrs: false})
@@ -65,9 +97,11 @@ const props = withDefaults(defineProps<{
tabs: Tab[]
modelValue?: string
id?: string
maxVisibleTabs?: number
}>(), {
modelValue: undefined,
id: '',
maxVisibleTabs: undefined,
})
const emit = defineEmits<{
@@ -84,6 +118,40 @@ const activeTab = computed(() =>
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) {
const tab = props.tabs.find(t => t.key === key)
if (tab?.disabled) return