feat: 为 Tabs 组件添加平滑的滑动指示条效果
This commit is contained in:
1 parent
1c475a7e7a
commit
ee67a5206d
1 file changed
+97
-3
@@ -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>
|
||||||
Reference in new issue
Block a user