Files
malio-layer-ui/app/components/malio/tab/TabList.vue
T
tristan b6fcd3c186
Release / release (push) Successful in 1m56s
fix: component style (#80)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-19 13:22:12 +00:00

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>