Files
beavergram/frontend/src/lib/components/FolderTabs.svelte
T

172 lines
3.6 KiB
Svelte

<script lang="ts">
import { folders } from "$lib/stores/folders.svelte";
interface Tab {
id: number | null;
title: string;
}
const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" },
...folders.list.map((folder) => ({
id: folder.folder_id,
title: folder.title,
})),
]);
const activeTab = $derived(
Math.max(
0,
tabs.findIndex((tab) => tab.id === folders.selectedId)
)
);
let containerEl = $state<HTMLDivElement>();
let indicatorEl = $state<HTMLDivElement>();
let clipPath = $state("");
function updateClipPath() {
const indicator = indicatorEl;
const activeEl = indicator?.children[activeTab] as HTMLElement | undefined;
if (!(indicator && activeEl) || indicator.offsetWidth === 0) {
return;
}
const { offsetLeft, offsetWidth } = activeEl;
const width = indicator.offsetWidth;
const left = ((offsetLeft / width) * 100).toFixed(1);
const right = (
((width - (offsetLeft + offsetWidth)) / width) *
100
).toFixed(1);
clipPath = `inset(0.25rem ${right}% 0.25rem ${left}% round var(--tab-radius))`;
}
$effect(() => {
const index = activeTab;
const total = tabs.length;
updateClipPath();
const baseEl =
total > 0
? (containerEl?.children[index] as HTMLElement | undefined)
: undefined;
baseEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
$effect(() => {
if (!indicatorEl) {
return;
}
const observer = new ResizeObserver(() => updateClipPath());
observer.observe(indicatorEl);
return () => observer.disconnect();
});
</script>
<div bind:this={containerEl} class="container" class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)}
<button type="button" class="tab" onclick={() => folders.select(tab.id)}>
{tab.title}
</button>
{/each}
<div
bind:this={indicatorEl}
class="active-indicator"
style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden="true"
>
{#each tabs as tab (tab.id)}
<button type="button" class="tab" tabindex="-1">{tab.title}</button>
{/each}
</div>
</div>
<style lang="scss">
.container,
.active-indicator {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
padding-block: 0.375rem;
padding-inline: 0.25rem;
}
.container {
--tab-radius: 1.25rem;
user-select: none;
scrollbar-width: none;
position: relative;
overflow-x: auto;
border-radius: 1.5rem;
opacity: 0;
background-color: var(--color-background);
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.1);
transition: opacity 150ms;
&::-webkit-scrollbar {
display: none;
}
&.ready {
opacity: 1;
}
}
.active-indicator {
pointer-events: none;
will-change: clip-path;
isolation: isolate;
position: absolute;
z-index: 10;
inset: 0;
contain: layout style paint;
overflow: hidden;
width: fit-content;
background-color: var(--color-primary-opacity);
transition: clip-path var(--slide-transition);
}
.tab {
cursor: var(--custom-cursor, pointer);
display: flex;
flex-shrink: 0;
gap: 0.25rem;
align-items: center;
padding: 0.375rem 1rem;
border: none;
border-radius: var(--tab-radius);
font-family: inherit;
font-size: 1rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
appearance: none;
background: none;
&:hover {
opacity: 0.85;
}
.active-indicator & {
color: var(--color-primary);
}
}
</style>