0558d8c58a
- Le nombre d'onglets affichés en mode fenêtré s'adapte automatiquement à la largeur réelle (ResizeObserver + ligne de mesure cachée). Les chevrons restent fixés aux bords ; le nombre est choisi pour que les onglets tiennent (pas de chevauchement ni de rognage). Calcul isolé en fonction pure testable (tabFit.ts, basée sur les vraies largeurs). maxVisibleTabs devient un plafond optionnel. - BREAKING : suppression de la prop maxWidth (la barre prend toute la largeur). - Survol d'un onglet inactif : même style que l'actif (texte m-primary + barre). - Playground : bac à sable interactif (nb onglets, plafond, icônes, labels longs, cadre redimensionnable) pour tester tous les cas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288 lines
8.9 KiB
Vue
288 lines
8.9 KiB
Vue
<template>
|
|
<div
|
|
ref="rootRef"
|
|
v-bind="$attrs"
|
|
>
|
|
<!-- Ligne de mesure cachée : largeur réelle de chaque onglet (mêmes classes de
|
|
layout, placeholder à la place de l'icône pour ne pas fausser les tests),
|
|
afin de calculer combien d'onglets tiennent. Invisible et hors flux. -->
|
|
<div
|
|
ref="measureRef"
|
|
aria-hidden="true"
|
|
class="pointer-events-none invisible absolute left-0 top-0 flex"
|
|
>
|
|
<span
|
|
v-for="tab in tabs"
|
|
:key="tab.key"
|
|
class="flex items-center gap-[18px] text-[24px] font-[600]"
|
|
>
|
|
<span
|
|
v-if="tab.icon"
|
|
class="inline-block shrink-0"
|
|
:style="{ width: `${tab.iconSize ?? 24}px`, height: `${tab.iconSize ?? 24}px` }"
|
|
/>
|
|
{{ tab.label }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="isWindowed" class="flex items-center justify-center gap-[36px] border-b border-m-primary">
|
|
<button
|
|
type="button"
|
|
aria-label="Onglets précédents"
|
|
data-test="tab-prev"
|
|
:disabled="!canPrev"
|
|
:class="[
|
|
'transition-colors',
|
|
canPrev
|
|
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
|
: 'cursor-not-allowed text-m-disabled',
|
|
]"
|
|
@click="prev"
|
|
>
|
|
<IconifyIcon icon="mdi:chevron-left" :width="28" />
|
|
</button>
|
|
|
|
<div
|
|
role="tablist"
|
|
class="flex flex-1 justify-center gap-[60px]"
|
|
>
|
|
<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="focusedKey === 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 hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
|
|
]"
|
|
@click="selectTab(tab.key)"
|
|
>
|
|
<IconifyIcon
|
|
v-if="tab.icon"
|
|
:icon="tab.icon"
|
|
:width="tab.iconSize ?? 24"
|
|
/>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label="Onglets suivants"
|
|
data-test="tab-next"
|
|
:disabled="!canNext"
|
|
:class="[
|
|
'transition-colors',
|
|
canNext
|
|
? 'cursor-pointer text-m-btn-primary hover:text-m-btn-primary-hover active:text-m-btn-primary-active'
|
|
: 'cursor-not-allowed text-m-disabled',
|
|
]"
|
|
@click="next"
|
|
>
|
|
<IconifyIcon icon="mdi:chevron-right" :width="28" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
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="focusedKey === 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 hover:after:content-[\'\'] hover:after:absolute hover:after:-bottom-[3px] hover:after:left-0 hover:after:right-0 hover:after:h-[3px] hover:after:bg-m-primary',
|
|
]"
|
|
@click="selectTab(tab.key)"
|
|
>
|
|
<IconifyIcon
|
|
v-if="tab.icon"
|
|
:icon="tab.icon"
|
|
:width="tab.iconSize ?? 24"
|
|
/>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-for="tab in tabs"
|
|
v-show="activeTab === tab.key"
|
|
:id="`${componentId}-panel-${tab.key}`"
|
|
:key="tab.key"
|
|
role="tabpanel"
|
|
:aria-labelledby="isTabRendered(tab.key) ? `${componentId}-tab-${tab.key}` : undefined"
|
|
:aria-label="isTabRendered(tab.key) ? undefined : tab.label"
|
|
>
|
|
<slot :name="tab.key" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
|
|
import {Icon as IconifyIcon} from '@iconify/vue'
|
|
import {computeVisibleCount} from './tabFit'
|
|
|
|
defineOptions({name: 'MalioTabList', inheritAttrs: false})
|
|
|
|
type Tab = {
|
|
key: string
|
|
label: string
|
|
icon?: string
|
|
iconSize?: string
|
|
disabled?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
tabs: Tab[]
|
|
modelValue?: string
|
|
id?: string
|
|
maxVisibleTabs?: number
|
|
}>(), {
|
|
modelValue: undefined,
|
|
id: '',
|
|
maxVisibleTabs: undefined,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: string): void
|
|
}>()
|
|
|
|
const generatedId = useId()
|
|
const componentId = computed(() => props.id || `malio-tab-list-${generatedId}`)
|
|
|
|
const isControlled = computed(() => props.modelValue !== undefined)
|
|
const localValue = ref(props.tabs.length > 0 ? props.tabs[0].key : '')
|
|
|
|
const activeTab = computed(() =>
|
|
isControlled.value ? props.modelValue! : localValue.value,
|
|
)
|
|
|
|
const TAB_GAP = 60
|
|
const CHEVRON_RESERVE = 110
|
|
|
|
const rootRef = ref<HTMLElement | null>(null)
|
|
const measureRef = ref<HTMLElement | null>(null)
|
|
const containerWidth = ref(0)
|
|
const tabWidths = ref<number[]>([])
|
|
|
|
// Nombre d'onglets affichés, calculé pour qu'ils tiennent dans la largeur réelle
|
|
// (cf. tabFit.ts) — la structure « flèches fixes » est conservée, et comme le
|
|
// nombre est choisi pour tenir, pas de débordement sur les flèches ni de rognage.
|
|
// `maxVisibleTabs` reste un plafond optionnel. Sans mesure (SSR/jsdom), repli sur
|
|
// ce plafond / tous les onglets.
|
|
const visibleCount = computed(() => computeVisibleCount({
|
|
count: props.tabs.length,
|
|
containerWidth: containerWidth.value,
|
|
tabWidths: tabWidths.value,
|
|
gap: TAB_GAP,
|
|
chevronReserve: CHEVRON_RESERVE,
|
|
maxVisibleTabs: props.maxVisibleTabs,
|
|
}))
|
|
|
|
const isWindowed = computed(() => props.tabs.length > visibleCount.value)
|
|
|
|
const maxStartIndex = computed(() => Math.max(0, props.tabs.length - visibleCount.value))
|
|
|
|
const startIndex = ref(0)
|
|
|
|
const visibleTabs = computed(() =>
|
|
isWindowed.value
|
|
? props.tabs.slice(startIndex.value, startIndex.value + visibleCount.value)
|
|
: props.tabs,
|
|
)
|
|
|
|
function measureTabWidths() {
|
|
const el = measureRef.value
|
|
if (!el) return
|
|
tabWidths.value = Array.from(el.children).map(c => (c as HTMLElement).offsetWidth)
|
|
}
|
|
|
|
function measureContainer() {
|
|
if (rootRef.value) containerWidth.value = rootRef.value.clientWidth
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
onMounted(() => {
|
|
measureTabWidths()
|
|
measureContainer()
|
|
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
|
|
resizeObserver = new ResizeObserver(() => measureContainer())
|
|
resizeObserver.observe(rootRef.value)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => resizeObserver?.disconnect())
|
|
|
|
// Re-mesure quand la liste change (labels/icônes → largeurs différentes).
|
|
watch(() => props.tabs, () => nextTick(() => {
|
|
measureTabWidths()
|
|
measureContainer()
|
|
}), {deep: true})
|
|
|
|
const focusedKey = computed(() => {
|
|
if (!isWindowed.value) return activeTab.value
|
|
const inView = visibleTabs.value.some(t => t.key === activeTab.value)
|
|
return inView ? activeTab.value : (visibleTabs.value[0]?.key ?? '')
|
|
})
|
|
|
|
const isTabRendered = (key: string) => !isWindowed.value || visibleTabs.value.some(t => t.key === key)
|
|
|
|
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
|
|
})
|
|
|
|
// Reset the window to the start when the tab list is replaced.
|
|
watch(() => props.tabs, () => {
|
|
startIndex.value = 0
|
|
})
|
|
|
|
function selectTab(key: string) {
|
|
const tab = props.tabs.find(t => t.key === key)
|
|
if (tab?.disabled) return
|
|
if (!isControlled.value) {
|
|
localValue.value = key
|
|
}
|
|
emit('update:modelValue', key)
|
|
}
|
|
</script>
|