<script module lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { tv, type VariantProps } from "tailwind-variants";
export interface TabDef {
id: string;
label: string;
href: string;
icon?: Component<SvelteHTMLElements["svg"]>;
count?: number | Promise<number | undefined>;
}
export const tabs = tv({
slots: {
nav: "isolate",
plate: "pointer-events-none absolute top-0 left-0 z-1 transition-[translate,width,height]",
item: "group/item relative flex items-center no-underline transition-colors *:relative *:z-2 hover:no-underline",
icon: "size-3.5",
label: "",
ghost: "invisible h-0 overflow-hidden font-medium select-none",
count: "rounded-md px-2xs typography-paragraph-small transition-colors"
},
variants: {
vertical: {
true: {
nav: "relative flex w-full flex-col gap-2xs bg-background-navigation-frame p-xs",
plate: "rounded-md bg-background-navigation-item edge-ring-inset inset-ring-border-navigation-item-active",
item: "min-h-8 w-full gap-1.5 overflow-hidden rounded-md px-sm py-1.5 typography-paragraph-regular text-foreground-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
icon: "size-3.5 shrink-0",
label: "min-w-0 truncate",
count: "ml-auto"
},
false: {
nav: "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-md",
plate: "rounded-t-md border border-b-0 border-border-default",
item: "mr-2xs rounded-t-md border border-b-0 border-transparent px-md pt-2xs pb-1.25 whitespace-nowrap text-foreground-default",
icon: "mr-xs",
label: "flex flex-col",
count: "ml-2xs"
}
},
selected: {
true: {
count: "bg-background-muted"
},
false: {
count: "bg-background-muted group-hover/item:bg-background-strong dark:bg-background-subtle group-hover/item:dark:bg-background-subtle"
}
},
layered: { true: {}, false: {} },
ready: { true: { plate: "opacity-100" }, false: { plate: "opacity-0" } }
},
compoundVariants: [
{
vertical: true,
selected: true,
ready: false,
class: {
item: "bg-background-navigation-item edge-ring-inset inset-ring-border-navigation-item-active"
}
},
{
vertical: true,
selected: false,
class: { item: "hover:bg-background-inset" }
},
{
vertical: false,
selected: true,
ready: false,
class: { item: "border-border-default" }
},
{
vertical: false,
layered: false,
class: { plate: "bg-background-default" }
},
{
vertical: false,
layered: true,
class: { plate: "bg-background-subtle dark:bg-background-canvas" }
},
{
vertical: false,
selected: true,
ready: false,
layered: false,
class: { item: "bg-background-default" }
},
{
vertical: false,
selected: true,
ready: false,
layered: true,
class: { item: "bg-background-subtle dark:bg-background-canvas" }
},
{
vertical: false,
selected: false,
class: { item: "hover:bg-background-muted hover:dark:bg-background-subtle/50" }
}
],
defaultVariants: {
vertical: false,
selected: false,
ready: false,
layered: false
}
});
export type TabsVariants = VariantProps<typeof tabs>;
</script>
<script lang="ts">
import { browser } from "$app/env";
import { resolve } from "$app/paths";
import { navigating, page } from "$app/state";
import type { ResolvedPathname } from "$app/types";
import { untrack } from "svelte";
import { prefersReducedMotion } from "svelte/motion";
import { RAIL_DURATION, RAIL_EASING, railEasing } from "#lib/motion/spring.ts";
import NumberFlip from "#lib/components/ui/NumberFlip.svelte";
import Pending from "#lib/components/ui/Pending.svelte";
import { formatCount } from "#lib/format.ts";
import Bones from "#lib/components/ui/Bones.svelte";
import bonesLoadingprimitivesCount from "#lib/bones/bones-loadingprimitives--count.bones.json";
interface Props {
tabs: TabDef[];
active: string;
label?: string;
vertical?: boolean;
layered?: boolean;
class?: string;
}
let {
tabs: defs,
active,
label,
vertical = false,
layered = false,
class: navClass
}: Props = $props();
const items = $derived(
defs.map((tab) => ({
...tab,
url: (tab.href.startsWith("/")
? resolve(tab.href as "/")
: tab.href) as ResolvedPathname
}))
);
const selected = $derived.by(() => {
// page.url can roll back in svelte 5 async boundary forks, so browser falls back to window.location
const target = navigating.to?.url ?? (browser ? new URL(window.location.href) : page.url);
// a url carrying a key the query tabs switch on (?tab=settings§ion=knots) belongs to
// one of them, so the bare-path tab can't claim it; `active` resolves which
const claimed = items.some(
(item) =>
item.url.includes("?") &&
[...new URL(item.url, target.href).searchParams.keys()].some((key) =>
target.searchParams.has(key)
)
);
let best: string | undefined;
let bestLength = -1;
for (const item of items) {
if (item.url === target.pathname + target.search) return item.id;
if (item.url.includes("?")) continue;
if (claimed && target.pathname === item.url) continue;
const owns = target.pathname === item.url || target.pathname.startsWith(`${item.url}/`);
if (owns && item.url.length > bestLength) {
best = item.id;
bestLength = item.url.length;
}
}
return best ?? active;
});
let nav = $state<HTMLElement>();
let nodes = $state<(HTMLElement | undefined)[]>([]);
let box = $state<{ top: number; left: number; width: number; height: number }>();
let animate = $state(false);
let moving = $state(false);
let shown = $state<string>();
const railTime = (share: number) => {
let progress = 0;
while (railEasing(progress) < share) progress += 0.01;
return Math.round(progress * RAIL_DURATION);
};
const landing = railTime(0.98);
let placed: string | undefined;
const measure = (animated: boolean) => {
const node = nodes[items.findIndex((item) => item.id === selected)];
if (!node?.offsetParent) return false;
const next = {
top: node.offsetTop,
left: node.offsetLeft,
width: node.offsetWidth,
height: node.offsetHeight
};
const previous = untrack(() => box);
if (
previous?.top === next.top &&
previous.left === next.left &&
previous.width === next.width &&
previous.height === next.height
)
return true;
animate =
previous !== undefined &&
(animated || untrack(() => moving)) &&
!prefersReducedMotion.current;
moving = animate;
shown = selected;
box = next;
return true;
};
$effect(() => {
placed = measure(placed !== undefined && placed !== selected) ? selected : undefined;
});
// watch every tab, not just the row: a preceding tab changing width (a count
// arriving, a webfont landing) shifts the plate without resizing the row
$effect(() => {
if (!nav) return;
const observer = new ResizeObserver(() => measure(false));
observer.observe(nav);
for (const node of nodes) if (node) observer.observe(node);
return () => observer.disconnect();
});
const settle = (event: TransitionEvent & { currentTarget: HTMLElement }) => {
if (event.type === "transitionend" || !event.currentTarget.getAnimations().length)
moving = false;
};
const style = $derived(tabs({ vertical, ready: box !== undefined, layered }));
</script>
<nav class={style.nav({ class: navClass })} aria-label={label} bind:this={nav}>
<span
aria-hidden="true"
class={style.plate()}
style:translate="{box?.left ?? 0}px {box?.top ?? 0}px"
style:width="{box?.width ?? 0}px"
style:height="{box?.height ?? 0}px"
style:transition-duration="{animate ? RAIL_DURATION : 0}ms"
style:transition-timing-function={RAIL_EASING}
ontransitionend={settle}
ontransitioncancel={settle}
></span>
{#each items as tab, i (tab.id)}
{@const isSelected = (box ? shown : selected) === tab.id}
{@const arriving = moving && shown === tab.id}
{@const Glyph = tab.icon}
<a
data-testid="tab"
data-tab={tab.id}
bind:this={nodes[i]}
href={tab.url}
aria-current={selected === tab.id ? "page" : undefined}
class={style.item({ selected: isSelected })}
style:transition-duration="{arriving ? landing : 0}ms"
>
{#if Glyph}
<Glyph class={style.icon()} aria-hidden="true" />
{/if}
<span class={style.label()}>
{tab.label}
{#if !vertical}
<span aria-hidden="true" class={style.ghost()}>{tab.label}</span>
{/if}
</span>
{#if tab.count !== undefined}
{#if typeof tab.count === "object" && tab.count !== null && "then" in tab.count}
<Pending>
{#snippet skeleton()}
<Bones
capture={bonesLoadingprimitivesCount}
class="inline-block h-5 w-8 overflow-hidden {style.count({
selected: isSelected
})}"
/>
{/snippet}
{@const count = await tab.count}
{#if count}
<span
class={style.count({ selected: isSelected })}
style:transition-duration="{arriving ? landing : 0}ms"
><NumberFlip value={count} format={formatCount} /></span
>
{/if}
</Pending>
{:else if tab.count}
<span
class={style.count({ selected: isSelected })}
style:transition-duration="{arriving ? landing : 0}ms"
><NumberFlip value={tab.count} format={formatCount} /></span
>
{/if}
{/if}
</a>
{/each}
</nav>