mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 10:42:29 +08:00
206 lines
4.5 KiB
Vue
206 lines
4.5 KiB
Vue
|
|
<template>
|
|||
|
|
<div :class="listClass" ref="listRef">
|
|||
|
|
<!-- 滑动指示器 - 放在按钮前面 -->
|
|||
|
|
<div
|
|||
|
|
class="tabs-indicator"
|
|||
|
|
:style="indicatorStyle"
|
|||
|
|
/>
|
|||
|
|
<slot />
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
|
|||
|
|
import { cn } from '@/lib/utils'
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
class?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = defineProps<Props>()
|
|||
|
|
|
|||
|
|
const listRef = ref<HTMLElement | null>(null)
|
|||
|
|
const indicatorStyle = ref<Record<string, string>>({
|
|||
|
|
transform: 'translateX(0)',
|
|||
|
|
width: '0px',
|
|||
|
|
opacity: '0',
|
|||
|
|
transition: 'none'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 标记是否已完成首次定位(首次无动画)
|
|||
|
|
const hasInitialized = ref(false)
|
|||
|
|
// 记录当前激活的 tab 索引,用于计算相对位置
|
|||
|
|
const activeIndex = ref(-1)
|
|||
|
|
|
|||
|
|
const activeTab = inject<Ref<string>>('activeTab')
|
|||
|
|
|
|||
|
|
// 检查是否有 grid 类(由外部传入)
|
|||
|
|
const hasGridClass = computed(() => {
|
|||
|
|
return props.class?.includes('grid')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const listClass = computed(() => {
|
|||
|
|
return cn(
|
|||
|
|
'tabs-list',
|
|||
|
|
// 如果外部传入了 grid 类,就不使用默认的 inline-flex
|
|||
|
|
!hasGridClass.value && 'inline-flex',
|
|||
|
|
props.class
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 更新指示器位置
|
|||
|
|
const updateIndicator = () => {
|
|||
|
|
if (!listRef.value) return
|
|||
|
|
|
|||
|
|
const buttons = Array.from(
|
|||
|
|
listRef.value.querySelectorAll<HTMLButtonElement>('button[data-value]')
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 确保所有 button 都已渲染且有 data-value
|
|||
|
|
if (buttons.length === 0) return
|
|||
|
|
|
|||
|
|
const newIndex = buttons.findIndex(
|
|||
|
|
(button) => button.dataset.value === activeTab?.value
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if (newIndex === -1) {
|
|||
|
|
indicatorStyle.value = {
|
|||
|
|
transform: 'translateX(0)',
|
|||
|
|
width: '0px',
|
|||
|
|
opacity: '0',
|
|||
|
|
transition: 'none'
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const activeButton = buttons[newIndex]
|
|||
|
|
const buttonRect = activeButton.getBoundingClientRect()
|
|||
|
|
|
|||
|
|
// 确保按钮已渲染
|
|||
|
|
if (buttonRect.width === 0) return
|
|||
|
|
|
|||
|
|
// 计算相对位置:累加前面所有按钮的宽度
|
|||
|
|
let offsetLeft = 0
|
|||
|
|
for (let i = 0; i < newIndex; i++) {
|
|||
|
|
offsetLeft += buttons[i].getBoundingClientRect().width
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 判断是否需要动画:
|
|||
|
|
// 1. 首次初始化不需要动画
|
|||
|
|
// 2. 索引变化(用户切换 tab)需要动画
|
|||
|
|
const isTabChange = hasInitialized.value && activeIndex.value !== newIndex && activeIndex.value !== -1
|
|||
|
|
|
|||
|
|
// 更新状态
|
|||
|
|
activeIndex.value = newIndex
|
|||
|
|
if (!hasInitialized.value) {
|
|||
|
|
hasInitialized.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
indicatorStyle.value = {
|
|||
|
|
transform: `translateX(${offsetLeft}px)`,
|
|||
|
|
width: `${buttonRect.width}px`,
|
|||
|
|
opacity: '1',
|
|||
|
|
transition: isTabChange
|
|||
|
|
? 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
|
|||
|
|
: 'none'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let rafId: number | null = null
|
|||
|
|
|
|||
|
|
const scheduleIndicatorUpdate = () => {
|
|||
|
|
if (rafId !== null) {
|
|||
|
|
cancelAnimationFrame(rafId)
|
|||
|
|
}
|
|||
|
|
rafId = requestAnimationFrame(() => {
|
|||
|
|
updateIndicator()
|
|||
|
|
rafId = null
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听 activeTab 变化
|
|||
|
|
watch(
|
|||
|
|
() => activeTab?.value,
|
|||
|
|
() => {
|
|||
|
|
nextTick(() => {
|
|||
|
|
scheduleIndicatorUpdate()
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
{ immediate: true, flush: 'post' }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// DOM 初始化/重新挂载时重新计算
|
|||
|
|
watch(
|
|||
|
|
() => listRef.value,
|
|||
|
|
(el) => {
|
|||
|
|
if (el) {
|
|||
|
|
nextTick(() => {
|
|||
|
|
scheduleIndicatorUpdate()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 重置状态
|
|||
|
|
hasInitialized.value = false
|
|||
|
|
activeIndex.value = -1
|
|||
|
|
// 立即尝试更新
|
|||
|
|
nextTick(() => {
|
|||
|
|
scheduleIndicatorUpdate()
|
|||
|
|
})
|
|||
|
|
window.addEventListener('resize', scheduleIndicatorUpdate)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
if (rafId !== null) {
|
|||
|
|
cancelAnimationFrame(rafId)
|
|||
|
|
}
|
|||
|
|
window.removeEventListener('resize', scheduleIndicatorUpdate)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.tabs-list {
|
|||
|
|
position: relative;
|
|||
|
|
height: 2.5rem;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
border-radius: 0.5rem;
|
|||
|
|
background-color: hsl(var(--muted) / 0.3);
|
|||
|
|
padding: 0.25rem;
|
|||
|
|
color: hsl(var(--muted-foreground));
|
|||
|
|
border: 1px solid hsl(var(--border) / 0.6);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs-indicator {
|
|||
|
|
position: absolute;
|
|||
|
|
z-index: 0;
|
|||
|
|
top: 0.25rem;
|
|||
|
|
bottom: 0.25rem;
|
|||
|
|
left: 0;
|
|||
|
|
border-radius: 0.375rem;
|
|||
|
|
background: linear-gradient(
|
|||
|
|
180deg,
|
|||
|
|
hsl(var(--background)),
|
|||
|
|
hsl(var(--background) / 0.95)
|
|||
|
|
);
|
|||
|
|
border: 1px solid hsl(var(--border));
|
|||
|
|
box-shadow:
|
|||
|
|
0 1px 3px 0 rgb(0 0 0 / 0.1),
|
|||
|
|
0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
.tabs-indicator {
|
|||
|
|
background: linear-gradient(
|
|||
|
|
180deg,
|
|||
|
|
hsl(var(--accent)),
|
|||
|
|
hsl(var(--accent) / 0.95)
|
|||
|
|
);
|
|||
|
|
border-color: hsl(var(--border) / 0.8);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|