add tabs component

This commit is contained in:
Elijah Duffy
2025-07-11 13:50:06 -07:00
parent f7e46077e3
commit 56c5b90629
3 changed files with 205 additions and 0 deletions

133
src/lib/Tabs.svelte Normal file
View File

@@ -0,0 +1,133 @@
<script lang="ts" module>
export type TabPage = {
title: string;
onMount?: () => void;
onUnmount?: () => void;
content: Snippet<[tab: TabPage]>;
};
</script>
<script lang="ts">
import { onMount, tick, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fly, type FlyParams } from 'svelte/transition';
interface Props {
pages: TabPage[];
activeIndex?: number;
onchange?: (event: { index: number; tab: TabPage }) => void;
class?: ClassValue | null;
}
let { pages, activeIndex = 0, onchange, class: classValue }: Props = $props();
let primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement;
let indicatorEl: HTMLDivElement;
let prevIndex = $state(activeIndex);
const activePage = $derived(pages[activeIndex]);
const refs = $state<HTMLButtonElement[]>([]);
const changePage = (index: number) => {
if (activeIndex === index) return;
pages[activeIndex].onUnmount?.(); // run cleanup
prevIndex = activeIndex; // store previous index
activeIndex = index; // update active index
onchange?.({ index, tab: pages[index] }); // run on change
updateIndicator(); // update selected tab indicator
// run on mount after tick
tick().then(() => {
pages[index].onMount?.();
});
};
const updateIndicator = () => {
if (!indicatorEl || !refs[activeIndex]) return;
const parentRect = tabContainerEl.getBoundingClientRect();
const rect = refs[activeIndex].getBoundingClientRect();
const width = rect.width;
const mul = 0.75;
indicatorEl.style.left = `${rect.left - parentRect.left + (width / 2) * (1 - mul)}px`;
indicatorEl.style.width = `${rect.width * mul}px`;
};
const flyX = (
node: HTMLElement,
{ direction = 1, ...opts }: { direction: number } & FlyParams
) => {
return fly(node, {
...opts,
x: direction * 100, // fly left or right
y: 0
});
};
const lockHeight = () => {
if (!primaryContainerEl) return;
const height = primaryContainerEl.getBoundingClientRect().height;
primaryContainerEl.style.height = `${height}px`;
primaryContainerEl.style.overflow = 'hidden';
};
const unlockHeight = () => {
if (!primaryContainerEl) return;
primaryContainerEl.style.height = '';
primaryContainerEl.style.overflow = '';
};
onMount(() => {
updateIndicator();
setTimeout(() => {
indicatorEl.style.opacity = '1';
}, 100);
});
</script>
<div bind:this={primaryContainerEl} class={[classValue]}>
<div
bind:this={tabContainerEl}
class={['border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2']}
>
{#each pages as page, i (page.title)}
{@const active = activeIndex === i}
<button
bind:this={refs[i]}
class={['-mb-[2px] cursor-pointer px-2']}
onclick={() => {
changePage(i);
}}
>
<div
class={[
'hover:text-sui-text border-b-0 py-1.5 text-lg font-medium transition-colors duration-100',
active ? 'border-sui-text-900 text-sui-text' : 'text-sui-text/75 border-transparent'
]}
>
{page.title}
</div>
</button>
{/each}
<div
bind:this={indicatorEl}
class={[
'border-sui-text-900 pointer-events-none absolute top-0 -bottom-[2px] w-16 border-b-2',
'opacity-0 transition-[left,width,opacity]'
]}
></div>
</div>
{#key activeIndex}
<div
class={[]}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight}
onintroend={unlockHeight}
>
{@render activePage.content(activePage)}
</div>
{/key}
</div>

View File

@@ -18,6 +18,7 @@ export { default as RadioGroup } from './RadioGroup.svelte';
export { default as Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte';
export { default as Tabs, type TabPage } from './Tabs.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as TimeInput } from './TimeInput.svelte';
export { default as TimezoneInput } from './TimezoneInput.svelte';

View File

@@ -29,6 +29,7 @@
TextUnderline
} from 'phosphor-svelte';
import type { Option } from '$lib';
import Tabs from '$lib/Tabs.svelte';
let dateInputValue = $state<CalendarDate | undefined>(undefined);
let checkboxValue = $state<CheckboxState>('indeterminate');
@@ -276,6 +277,76 @@
</div>
</div>
<div class="component">
<p class="title">Tabs</p>
<Tabs
pages={[
{
title: 'Dashboard',
content: tab1
},
{
title: 'Activity',
content: tab2
},
{
title: 'Settings',
content: tab3
}
]}
/>
{#snippet tab1()}
<h3 class="mb-4 text-2xl font-bold">Dashboard Overview</h3>
<div class="grid grid-cols-2 gap-4">
<div class="rounded bg-blue-100 p-4">
<h4 class="font-semibold">Total Users</h4>
<p class="text-3xl font-bold text-blue-600">1,247</p>
</div>
<div class="rounded bg-green-100 p-4">
<h4 class="font-semibold">Revenue</h4>
<p class="text-3xl font-bold text-green-600">$12,450</p>
</div>
</div>
{/snippet}
{#snippet tab2()}
<h3 class="mb-3 text-xl font-semibold">Recent Activity</h3>
<ul class="space-y-2">
<li class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
<span>User John Doe logged in</span>
</li>
<li class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-blue-500"></span>
<span>New order #1234 received</span>
</li>
<li class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-yellow-500"></span>
<span>System backup completed</span>
</li>
</ul>
{/snippet}
{#snippet tab3()}
<h3 class="mb-3 text-xl font-semibold">Settings</h3>
<form class="space-y-3">
<div class="flex items-center gap-2">
<input type="checkbox" id="notifications" checked />
<label for="notifications">Enable notifications</label>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="darkmode" />
<label for="darkmode">Dark mode</label>
</div>
<button class="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
Save Changes
</button>
</form>
{/snippet}
</div>
<Dialog
bind:open={dialogOpen}
title="Dialog Title"