<script module lang="ts">
	import type { Component } from "svelte";
	import type { SvelteHTMLElements } from "svelte/elements";

	export interface TabDef {
		id: string;
		label: string;
		href: string;
		icon?: Component<SvelteHTMLElements["svg"]>;
		count?: number;
	}
</script>

<script lang="ts">
	import { resolve } from "$app/paths";
	import { navigating } from "$app/state";
	import type { ResolvedPathname } from "$app/types";
	import { Spring, prefersReducedMotion } from "svelte/motion";

	interface Props {
		tabs: TabDef[];
		active: string;
		label?: string;
		vertical?: boolean;
		overlapBottom?: boolean;
	}

	let { tabs, active, label, vertical = false, overlapBottom = false }: Props = $props();

	// resolved up front so the plate can match hrefs against the destination url
	const items = $derived(
		tabs.map((tab) => ({
			...tab,
			url: (tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href) as ResolvedPathname
		}))
	);

	// the plate follows where we're heading rather than where we are, so it leaves
	// the moment you click instead of waiting on the route's load; aria-current
	// stays behind on the page that's still on screen
	const selected = $derived.by(() => {
		const dest = navigating.to?.url;
		if (!dest) return active;

		let best: string | undefined;
		let bestLength = -1;
		for (const item of items) {
			if (item.url === dest.pathname + dest.search) return item.id;
			// a plain path also owns everything nested under it, so an issue page
			// keeps the issues tab lit; a query-scoped tab only matches exactly
			if (item.url.includes("?")) continue;
			const owns = dest.pathname === item.url || dest.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<{ x: number; width: number; height: number }>();
	let ready = $state(false);

	const plate = new Spring({ x: 0, width: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 });

	const measure = () => {
		const node = nodes[items.findIndex((item) => item.id === selected)];
		if (!node) return;
		box = { x: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight };
	};

	$effect(() => {
		if (vertical) return;
		measure();
	});

	// 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 (vertical || !nav) return;
		const observer = new ResizeObserver(measure);
		observer.observe(nav);
		for (const node of nodes) if (node) observer.observe(node);
		return () => observer.disconnect();
	});

	// the first placement lands instantly so the plate never slides in from the
	// left; until it happens the selected tab wears the chrome itself, which is
	// also what server-rendered and script-less pages get
	let placed = false;
	$effect(() => {
		if (!box) return;
		plate.set({ x: box.x, width: box.width }, { instant: !placed || prefersReducedMotion.current });
		placed = true;
		ready = true;
	});

	const navClass = $derived(
		vertical
			? "h-fit divide-y divide-border-default overflow-hidden rounded border border-border-default"
			: "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4"
	);

	const itemClass = (isSelected: boolean) =>
		vertical
			? `flex items-center gap-3 px-3 py-2 typography-paragraph-small no-underline hover:no-underline ${
					isSelected
						? "bg-background-default text-foreground-default dark:bg-background-inset"
						: "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default"
				}`
			: `relative mr-1 flex items-center rounded-t border border-b-0 border-transparent px-4 pt-1 pb-[5px] whitespace-nowrap text-foreground-default no-underline hover:no-underline ${
					isSelected
						? `[-webkit-text-stroke:0.3px_currentColor] ${ready ? "" : "border-border-default bg-background-default"}`
						: "hover:bg-background-muted hover:dark:bg-background-subtle/50"
				}`;
</script>

<nav class={[navClass, overlapBottom && "-mb-px"]} aria-label={label} bind:this={nav}>
	{#if !vertical}
		<span
			aria-hidden="true"
			class={[
				"pointer-events-none absolute top-0 left-0 rounded-t border border-b-0 border-border-default bg-background-default",
				ready ? "opacity-100" : "opacity-0"
			]}
			style="translate: {plate.current.x}px; width: {plate.current.width}px; height: {box?.height ??
				0}px"
		></span>
	{/if}
	{#each items as tab, i (tab.id)}
		{@const isSelected = selected === tab.id}
		{@const Glyph = tab.icon}
		<a
			bind:this={nodes[i]}
			href={tab.url}
			aria-current={active === tab.id ? "page" : undefined}
			class={itemClass(isSelected)}
		>
			{#if Glyph}
				<Glyph class={vertical ? "size-4 shrink-0" : "mr-2 size-4 "} aria-hidden="true" />
			{/if}
			{#if vertical}
				{tab.label}
			{:else}
				<span class="flex flex-col">
					{tab.label}
					<span aria-hidden="true" class="invisible h-0 overflow-hidden font-medium select-none"
						>{tab.label}</span
					>
				</span>
			{/if}
			{#if tab.count}
				<span
					class="rounded-sm bg-background-inset px-1 typography-paragraph-small {vertical
						? 'ml-auto'
						: 'ml-1'}">{tab.count}</span
				>
			{/if}
		</a>
	{/each}
</nav>
