376 lines
9.7 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<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>