2025-12-04 10:04:21 +08:00

376 lines
9.7 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="u-tabspro"
:class="[
`u-variant-${variant}`,
`u-align-${align}`,
compact ? 'u-compact' : '',
wrap ? 'u-wrap' : 'u-nowrap'
]"
>
<div
class="u-track"
ref="trackRef"
role="tablist"
:aria-label="ariaLabel"
@wheel.passive="onWheelScroll"
>
<button
v-for="(t, i) in tabsArr"
:key="(t.value ?? t.label ?? i) as any"
class="u-tab"
:class="{
active: i === currentIndex,
disabled: !!t.disabled,
'u-tab--rec': isRecTab(t) // 仅标记“推荐类”
}"
role="tab"
:aria-selected="i === currentIndex"
:tabindex="t.disabled ? -1 : (i === currentIndex ? 0 : -1)"
:disabled="!!t.disabled"
@click="!t.disabled && setActive(i)"
@keydown.left.prevent="focusPrev"
@keydown.right.prevent="focusNext"
:ref="(el) => assignTabRef(el, i)"
>
<slot name="tab" :tab="t" :index="i">
{{ t.label }}
</slot>
</button>
<!-- 下划线风格活动指示线仅当活动且为推荐类时炫彩 -->
<span
v-if="variant === 'underline'"
class="u-underline"
:class="{ 'u-underline--rec': isActiveRec }"
:style="{ width: underline.w + 'px', transform: `translateX(${underline.x}px)` }"
aria-hidden="true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref, computed, watch, onMounted, nextTick, onBeforeUnmount,
type ComponentPublicInstance
} from 'vue'
type Align = 'left' | 'center' | 'between'
type Variant = 'pills' | 'underline'
type TabItem = Readonly<{
label: string
value?: string | number
disabled?: boolean
}>
interface Props {
tabs: ReadonlyArray<TabItem>
modelValue?: string | number | null
defaultIndex?: number
variant?: Variant
align?: Align
compact?: boolean
wrap?: boolean
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: null,
defaultIndex: 0,
variant: 'pills',
align: 'left',
compact: false,
wrap: true,
ariaLabel: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string | number): void
(e: 'change', payload: { index: number; tab: TabItem }): void
}>()
const tabsArr = computed(() => props.tabs ?? [])
const tabRefs = ref<Array<HTMLButtonElement | null>>([])
function assignTabRef(
el: Element | ComponentPublicInstance | null,
i: number
) {
tabRefs.value[i] = (el as HTMLButtonElement) ?? null
}
const trackRef = ref<HTMLElement | null>(null)
const currentIndex = ref(0)
function valueToIndex(v: string | number | null) {
return tabsArr.value.findIndex(t => (t?.value ?? t?.label) === v)
}
/* -------- 推荐类判定label/value 命中以下任一关键词即可) -------- */
function isRecTab(tab?: TabItem | null) {
if (!tab) return false
const raw = String(tab.value ?? tab.label ?? '').trim().toLowerCase()
const keys = ['推荐', '精选', '热门', '置顶', 'rec']
return keys.some(k => raw === k.toLowerCase() || raw.includes(k.toLowerCase()))
}
const isActiveRec = computed(() => isRecTab(tabsArr.value[currentIndex.value]))
/* -------- underline 位置 -------- */
const underline = ref({ x: 0, w: 0 })
function updateUnderline() {
if (props.variant !== 'underline') return
const el = tabRefs.value[currentIndex.value]
const track = trackRef.value
if (!el || !track) return
const trackRect = track.getBoundingClientRect()
const rect = el.getBoundingClientRect()
underline.value = { x: rect.left - trackRect.left, w: rect.width }
}
function scrollActiveIntoView() {
if (props.wrap) return
const el = tabRefs.value[currentIndex.value]
const track = trackRef.value
if (!el || !track) return
const padding = 24
const left = el.offsetLeft - padding
const right = left + el.offsetWidth + padding * 2
if (left < track.scrollLeft) {
track.scrollTo({ left, behavior: 'smooth' })
} else if (right > track.scrollLeft + track.clientWidth) {
track.scrollTo({ left: right - track.clientWidth, behavior: 'smooth' })
}
}
function onWheelScroll(e: WheelEvent) {
if (props.wrap) return
const track = trackRef.value
if (!track) return
track.scrollLeft += e.deltaY
}
/* -------- 交互 -------- */
function setActive(i: number) {
if (i < 0 || i >= tabsArr.value.length) return
const tab = tabsArr.value[i]
if (!tab || tab.disabled) return
currentIndex.value = i
nextTick(() => {
updateUnderline()
scrollActiveIntoView()
})
const v = (tab.value ?? tab.label) as string | number
emit('update:modelValue', v)
emit('change', { index: i, tab })
}
function focusPrev() {
const len = tabsArr.value.length
if (!len) return
let i = (currentIndex.value - 1 + len) % len
while (tabsArr.value[i]?.disabled && i !== currentIndex.value) {
i = (i - 1 + len) % len
}
setActive(i)
tabRefs.value[i]?.focus?.()
}
function focusNext() {
const len = tabsArr.value.length
if (!len) return
let i = (currentIndex.value + 1) % len
while (tabsArr.value[i]?.disabled && i !== currentIndex.value) {
i = (i + 1) % len
}
setActive(i)
tabRefs.value[i]?.focus?.()
}
/* -------- 同步/初始化 -------- */
watch(
() => props.modelValue,
(v) => {
if (v == null) return
const idx = valueToIndex(v)
if (idx >= 0) {
currentIndex.value = idx
nextTick(() => {
updateUnderline()
scrollActiveIntoView()
})
}
},
{ immediate: true }
)
watch(
() => tabsArr.value.length,
() => nextTick(updateUnderline)
)
onMounted(() => {
if (props.modelValue == null) {
const idx = Math.min(
props.defaultIndex ?? 0,
Math.max(tabsArr.value.length - 1, 0)
)
currentIndex.value = idx
// 触发 update:modelValue 事件,同步父组件的状态
const tab = tabsArr.value[idx]
if (tab) {
const v = (tab.value ?? tab.label) as string | number
emit('update:modelValue', v)
}
}
nextTick(() => {
updateUnderline()
scrollActiveIntoView()
window.addEventListener('resize', updateUnderline)
})
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateUnderline)
})
</script>
<style scoped>
/* ---------- 主题变量 ---------- */
.u-tabspro {
--gap: 12px;
--radius: 8px;
--font: 14px;
--height: 30px;
--color: #374151; /* gray-700 */
--muted: #6b7280; /* gray-500 */
--active: #111827; /* gray-900 */
--border: #e5e7eb; /* gray-200 */
--bg: #ffffff;
--bg-muted: #f9fafb;
/* 首页三原色系:紫 -> 靛蓝 -> 蓝 -> 青 */
--rainbow: linear-gradient(90deg,
#7c3aed 0%,
#6366f1 33%,
#60a5fa 66%,
#22d3ee 100%);
}
/* 紧凑尺寸 */
.u-compact {
--gap: 8px;
--radius: 10px;
--font: 13px;
--height: 30px;
}
/* ---------- 布局 ---------- */
.u-track {
display: flex;
gap: var(--gap);
align-items: center;
position: relative;
min-height: var(--height);
width: 100%;
}
/* 排列 */
.u-align-left .u-track { justify-content: flex-start; }
.u-align-center .u-track { justify-content: center; }
.u-align-between .u-track{ justify-content: space-between; }
/* 换行/滚动 */
.u-wrap .u-track { flex-wrap: wrap; }
.u-nowrap .u-track { overflow-x: auto; scrollbar-width: thin; }
.u-nowrap .u-track::-webkit-scrollbar { height: 8px; }
.u-nowrap .u-track::-webkit-scrollbar-thumb {
background: rgba(0,0,0,.12);
border-radius: 4px;
}
/* ---------- Tab 基础 ---------- */
.u-tab {
appearance: none;
border: 0;
background: transparent;
color: var(--color);
font-size: var(--font);
line-height: 1;
height: var(--height);
padding: 0 14px;
border-radius: var(--radius);
cursor: pointer;
white-space: nowrap;
transition: all .18s ease;
outline: none;
}
.u-tab:focus-visible {
outline: 2px solid rgba(99,102,241,.35);
outline-offset: 2px;
}
.u-tab.disabled {
opacity: .45;
cursor: not-allowed;
}
/* ---------- 变体pills ---------- */
.u-variant-pills .u-tab { background: var(--bg-muted); }
.u-variant-pills .u-tab:hover {
background: #eef2ff; color: var(--active);
}
.u-variant-pills .u-tab.active {
background: var(--active); color: #fff;
box-shadow: 0 4px 10px rgba(17,24,39,.12);
}
/* ★ 推荐:仅当选中时炫彩(整块胶囊流动) */
.u-variant-pills .u-tab.u-tab--rec.active {
color: #fff;
background-image: var(--rainbow);
background-size: 220% 220%;
animation: tabsRainbow 4s ease infinite;
box-shadow: 0 8px 18px rgba(99,102,241,.22);
}
/* ---------- 变体underline ---------- */
.u-variant-underline .u-track { gap: 28px; padding: 6px 2px; }
.u-variant-underline .u-tab {
padding: 0 4px; background: transparent; border-radius: 6px; color: var(--muted);
}
.u-variant-underline .u-tab:hover { color: var(--active); }
.u-variant-underline .u-tab.active { color: var(--active); font-weight: 700; }
/* ★ 推荐:仅当选中时文字炫彩(背景裁剪到文字) */
.u-variant-underline .u-tab.u-tab--rec.active {
background-image: var(--rainbow);
background-size: 220% 220%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: tabsRainbow 4s ease infinite;
}
/* 指示线 */
.u-underline {
position: absolute; bottom: 0; height: 3px; border-radius: 3px;
background: var(--active);
transition: transform .22s cubic-bezier(.22,1,.36,1),
width .22s cubic-bezier(.22,1,.36,1);
pointer-events: none;
}
/* ★ 推荐:活动且为推荐类时,指示线炫彩流动 */
.u-underline--rec {
background-image: var(--rainbow);
background-size: 220% 220%;
animation: tabsRainbow 4s ease infinite;
}
/* 炫彩动画 */
@keyframes tabsRainbow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>