376 lines
9.7 KiB
Vue
376 lines
9.7 KiB
Vue
<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>
|