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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user