feat: 为 Tabs 组件添加平滑的滑动指示条效果
continuous-integration/drone Build was killed
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-27 21:52:28 +08:00
1 parent 1c475a7e7a
commit ee67a5206d
1 file changed
+97 -3
+97 -3
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
type Tab = { id: string; label: string }; type Tab = { id: string; label: string };
let { let {
@@ -15,27 +17,97 @@
onchange?: (id: string) => void; onchange?: (id: string) => void;
}>(); }>();
let headerEl: HTMLDivElement | undefined = $state();
let enableAnimation = $state(false);
let indicatorStyle = $state({ left: 0, width: 0, visible: false });
function updateIndicator(withAnimation = true) {
if (!headerEl) return;
const buttons = headerEl.querySelectorAll<HTMLButtonElement>('.tab-btn');
let activeEl: HTMLButtonElement | undefined;
for (const btn of buttons) {
if (btn.dataset.tabId === activeTab) {
activeEl = btn;
break;
}
}
if (!activeEl) {
indicatorStyle = { left: 0, width: 0, visible: false };
return;
}
if (!withAnimation) {
enableAnimation = false;
}
indicatorStyle = {
left: activeEl.offsetLeft,
width: activeEl.offsetWidth,
visible: true
};
if (!withAnimation) {
// 在下一帧恢复动画能力,避免后续切换时丢失动画
requestAnimationFrame(() => {
enableAnimation = true;
});
} else {
enableAnimation = true;
}
}
function select(id: string) { function select(id: string) {
activeTab = id; activeTab = id;
if (onchange) onchange(id); if (onchange) onchange(id);
} }
$effect(() => {
// 监听 activeTab 与 tabs 列表变化
void activeTab;
void tabs;
tick().then(() => {
// 初次渲染不启用动画,直接定位;后续切换启用动画
updateIndicator(enableAnimation);
});
});
$effect(() => {
if (!headerEl) return;
// 监听容器尺寸变化(如窗口缩放或折叠侧边栏),静默重校准指示条位置
const ro = new ResizeObserver(() => {
updateIndicator(false);
});
ro.observe(headerEl);
return () => ro.disconnect();
});
</script> </script>
<div class="tabs-header" class:full-width={fullWidth} class:small> <div bind:this={headerEl} class="tabs-header" class:full-width={fullWidth} class:small>
{#each tabs as tab (tab.id || tab)} {#each tabs as tab (tab.id || tab)}
<button <button
type="button" type="button"
class="tab-btn" class="tab-btn"
data-tab-id={tab.id}
class:active={activeTab === tab.id} class:active={activeTab === tab.id}
onclick={() => select(tab.id)} onclick={() => select(tab.id)}
> >
{tab.label} {tab.label}
</button> </button>
{/each} {/each}
<div
class="tab-indicator"
class:visible={indicatorStyle.visible}
class:animate={enableAnimation}
style:transform="translateX({indicatorStyle.left}px)"
style:width="{indicatorStyle.width}px"
></div>
</div> </div>
<style> <style>
.tabs-header { .tabs-header {
position: relative;
display: flex; display: flex;
gap: 20px; gap: 20px;
border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.1)); border-bottom: 2px solid var(--border-color, rgba(0, 0, 0, 0.1));
@@ -71,7 +143,7 @@
cursor: pointer; cursor: pointer;
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
margin-bottom: -2px; margin-bottom: -2px;
transition: all 0.2s; transition: color 0.2s ease;
} }
.tab-btn:hover { .tab-btn:hover {
@@ -80,6 +152,28 @@
.tab-btn.active { .tab-btn.active {
color: var(--text-color); color: var(--text-color);
border-bottom-color: var(--accent-color, #419b38); }
.tab-indicator {
position: absolute;
bottom: -2px;
left: 0;
height: 3px;
background-color: var(--accent-color, #419b38);
border-radius: 3px 3px 0 0;
pointer-events: none;
opacity: 0;
will-change: transform, width;
}
.tab-indicator.visible {
opacity: 1;
}
.tab-indicator.animate {
transition:
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.15s ease;
} }
</style> </style>