| 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>
This commit was merged in pull request #80.
This commit is contained in:
@@ -16,7 +16,6 @@ type TabListProps = {
|
||||
modelValue?: string
|
||||
id?: string
|
||||
maxVisibleTabs?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
const TabListForTest = TabList as DefineComponent<TabListProps>
|
||||
@@ -157,7 +156,16 @@ describe('MalioTabList', () => {
|
||||
const wrapper = mountComponent({tabs: disabledTabs})
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
expect(buttons[1].classes()).toContain('cursor-not-allowed')
|
||||
expect(buttons[1].classes()).not.toContain('hover:text-m-primary/70')
|
||||
expect(buttons[1].classes()).not.toContain('hover:text-m-primary')
|
||||
})
|
||||
|
||||
it('hover sur un onglet inactif applique le même style que l\'actif (texte plein + barre)', () => {
|
||||
const wrapper = mountComponent({tabs})
|
||||
const inactive = wrapper.findAll('[role="tab"]')[1]
|
||||
expect(inactive.attributes('aria-selected')).toBe('false')
|
||||
expect(inactive.classes()).toContain('hover:text-m-primary')
|
||||
expect(inactive.classes()).toContain('hover:after:bg-m-primary')
|
||||
expect(inactive.classes()).toContain('hover:after:h-[3px]')
|
||||
})
|
||||
|
||||
it('does not emit update:modelValue when clicking a disabled tab', async () => {
|
||||
@@ -199,16 +207,6 @@ describe('MalioTabList — fenêtrage maxVisibleTabs', () => {
|
||||
{key: 't7', label: 'Tab 7'},
|
||||
]
|
||||
|
||||
it('applies the default maxWidth (1100px) on the tabs container when windowed', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1100px')
|
||||
})
|
||||
|
||||
it('applies a custom maxWidth on the tabs container', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5, maxWidth: 1200})
|
||||
expect(wrapper.find('[role="tablist"]').attributes('style')).toContain('max-width: 1200px')
|
||||
})
|
||||
|
||||
it('renders only maxVisibleTabs buttons and disables prev at start', () => {
|
||||
const wrapper = mountComponent({tabs: sevenTabs, maxVisibleTabs: 5})
|
||||
const buttons = wrapper.findAll('[role="tab"]')
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<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"
|
||||
@@ -20,7 +45,6 @@
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-1 justify-center gap-[60px]"
|
||||
:style="{ maxWidth: `${maxWidth}px` }"
|
||||
>
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
@@ -39,7 +63,7 @@
|
||||
? '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',
|
||||
: '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)"
|
||||
>
|
||||
@@ -91,7 +115,7 @@
|
||||
? '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',
|
||||
: '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)"
|
||||
>
|
||||
@@ -119,8 +143,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useId, watch} from 'vue'
|
||||
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})
|
||||
|
||||
@@ -137,12 +162,10 @@ const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
id?: string
|
||||
maxVisibleTabs?: number
|
||||
maxWidth?: number
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
id: '',
|
||||
maxVisibleTabs: undefined,
|
||||
maxWidth: 1100,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -159,22 +182,69 @@ const activeTab = computed(() =>
|
||||
isControlled.value ? props.modelValue! : localValue.value,
|
||||
)
|
||||
|
||||
const isWindowed = computed(() =>
|
||||
props.maxVisibleTabs != null && props.tabs.length > props.maxVisibleTabs,
|
||||
)
|
||||
const TAB_GAP = 60
|
||||
const CHEVRON_RESERVE = 110
|
||||
|
||||
const maxStartIndex = computed(() =>
|
||||
isWindowed.value ? Math.max(0, props.tabs.length - props.maxVisibleTabs!) : 0,
|
||||
)
|
||||
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 + props.maxVisibleTabs!)
|
||||
? 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)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {computeVisibleCount} from './tabFit'
|
||||
|
||||
const base = {gap: 60, chevronReserve: 110}
|
||||
const widths = (n: number, w = 180) => Array.from({length: n}, () => w)
|
||||
|
||||
describe('computeVisibleCount', () => {
|
||||
it('sans layout : respecte maxVisibleTabs', () => {
|
||||
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: [], maxVisibleTabs: 3})).toBe(3)
|
||||
})
|
||||
|
||||
it('sans layout ni maxVisibleTabs : tous les onglets', () => {
|
||||
expect(computeVisibleCount({...base, count: 5, containerWidth: 0, tabWidths: []})).toBe(5)
|
||||
})
|
||||
|
||||
it('tout tient : retourne le total (pas de chevrons)', () => {
|
||||
// 4×180 + 3×60 = 900 <= 1000
|
||||
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: widths(4)})).toBe(4)
|
||||
})
|
||||
|
||||
it('trop large : additionne les vraies largeurs (pas la pire) — pas d\'effondrement à 1', () => {
|
||||
// total 7×180+6×60=1620 > 1400 ; avail=1400-110=1290 ; 180,420,660,900,1140,(1380>1290) → 5
|
||||
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7)})).toBe(5)
|
||||
})
|
||||
|
||||
it('largeur étroite : montre ce qui tient (≥ 2 ici, pas 1)', () => {
|
||||
// avail=570-110=460 ; 180,(420),(660>460) → 2
|
||||
expect(computeVisibleCount({...base, count: 7, containerWidth: 570, tabWidths: widths(7)})).toBe(2)
|
||||
})
|
||||
|
||||
it('maxVisibleTabs plafonne le résultat', () => {
|
||||
expect(computeVisibleCount({...base, count: 7, containerWidth: 1400, tabWidths: widths(7), maxVisibleTabs: 3})).toBe(3)
|
||||
})
|
||||
|
||||
it('au moins 1 onglet si rien ne tient', () => {
|
||||
expect(computeVisibleCount({...base, count: 5, containerWidth: 150, tabWidths: widths(5, 300)})).toBe(1)
|
||||
})
|
||||
|
||||
it('gère des largeurs hétérogènes', () => {
|
||||
// total 1080 > 1000 → fenêtré ; avail=1000-110=890 ; 300,560,820,(1080>890) → 3
|
||||
expect(computeVisibleCount({...base, count: 4, containerWidth: 1000, tabWidths: [300, 200, 200, 200]})).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// Calcule combien d'onglets afficher pour qu'ils tiennent dans la largeur dispo,
|
||||
// en gardant la structure « flèches fixes aux bords » : le nombre est choisi pour
|
||||
// que les onglets visibles tiennent → pas de débordement sur les flèches, pas de
|
||||
// rognage, barre d'onglet actif intacte.
|
||||
//
|
||||
// On additionne les VRAIES largeurs d'onglets (pas la pire), donc le résultat
|
||||
// n'est pas sur-conservateur (évite de tomber à 1 onglet inutilement).
|
||||
//
|
||||
// Fonction pure → testable sans DOM. Sans layout (SSR / jsdom : largeurs à 0),
|
||||
// on retombe sur le plafond `maxVisibleTabs` (ou tous les onglets).
|
||||
export interface TabFitInput {
|
||||
count: number // nombre total d'onglets
|
||||
containerWidth: number // largeur dispo mesurée (0 si inconnue)
|
||||
tabWidths: number[] // largeur mesurée de chaque onglet (vide si inconnu)
|
||||
gap: number // espace entre onglets (px)
|
||||
chevronReserve: number // place des chevrons + marges quand fenêtré (px)
|
||||
maxVisibleTabs?: number // plafond optionnel imposé par le consommateur
|
||||
}
|
||||
|
||||
export function computeVisibleCount(input: TabFitInput): number {
|
||||
const {count, containerWidth, tabWidths, gap, chevronReserve, maxVisibleTabs} = input
|
||||
|
||||
// Pas d'info de layout : on respecte le plafond explicite, sinon tout afficher.
|
||||
if (containerWidth <= 0 || tabWidths.length === 0) {
|
||||
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, count) : count
|
||||
}
|
||||
|
||||
const fullAvail = containerWidth
|
||||
const total = tabWidths.reduce((s, w) => s + w, 0) + gap * Math.max(0, count - 1)
|
||||
|
||||
let fit: number
|
||||
if (total <= fullAvail) {
|
||||
fit = count // tout tient, pas de chevrons
|
||||
}
|
||||
else {
|
||||
const avail = fullAvail - chevronReserve
|
||||
let used = 0
|
||||
let n = 0
|
||||
for (const w of tabWidths) {
|
||||
const add = w + (n > 0 ? gap : 0)
|
||||
if (used + add > avail) break
|
||||
used += add
|
||||
n++
|
||||
}
|
||||
fit = Math.max(1, n)
|
||||
}
|
||||
|
||||
return maxVisibleTabs != null ? Math.min(maxVisibleTabs, fit) : fit
|
||||
}
|
||||
Reference in New Issue
Block a user