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>
|