Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250<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: "", plate: "pointer-events-none absolute top-0 left-0", item: "group/item relative flex items-center no-underline hover:no-underline", icon: "size-4", label: "", ghost: "invisible h-0 overflow-hidden font-medium select-none", count: "rounded-sm px-1 typography-paragraph-small" }, variants: { vertical: { true: { nav: "relative flex w-full flex-col gap-1 bg-background-navigation-frame p-2", plate: "rounded-sm border border-border-navigation-item-active bg-background-navigation-item", item: "min-h-8 w-full gap-1.5 overflow-hidden rounded-sm border border-transparent px-3 py-1.5 typography-paragraph-regular text-foreground-default transition-colors duration-150 ease-in-out 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-4", plate: "rounded-t border border-b-0 border-border-default bg-background-default", item: "mr-1 rounded-t border border-b-0 border-transparent px-4 pt-1 pb-1.25 whitespace-nowrap text-foreground-default", icon: "mr-2", label: "flex flex-col", count: "ml-1" } }, 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" } }, ready: { true: { plate: "opacity-100" }, false: { plate: "opacity-0" } }, overlapBottom: { true: { nav: "-mb-px" }, false: {} } }, compoundVariants: [ { vertical: true, selected: true, ready: false, class: { item: "border-border-navigation-item-active bg-background-navigation-item" } }, { vertical: true, selected: false, class: { item: "hover:bg-background-inset" } }, { vertical: false, selected: true, ready: false, class: { item: "border-border-default bg-background-default" } }, { vertical: false, selected: false, class: { item: "hover:bg-background-muted hover:dark:bg-background-subtle/50" } } ], defaultVariants: { vertical: false, selected: false, ready: false, overlapBottom: false } });
export type TabsVariants = VariantProps<typeof tabs>;</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"; import Pending from "$lib/components/ui/Pending.svelte"; import { formatCount } from "$lib/format"; 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; overlapBottom?: boolean; class?: string; }
let { tabs: defs, active, label, vertical = false, overlapBottom = 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(() => { 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; 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<{ top: number; left: number; width: number; height: number }>(); let ready = $state(false);
const plate = new Spring( { pos: 0, size: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 } );
const measure = () => { const node = nodes[items.findIndex((item) => item.id === selected)]; if (!node?.offsetParent) return; box = { top: node.offsetTop, left: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight }; };
$effect(() => { 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 (!nav) return; const observer = new ResizeObserver(measure); observer.observe(nav); for (const node of nodes) if (node) observer.observe(node); return () => observer.disconnect(); });
let placed = false; $effect(() => { if (!box) return; plate.set( vertical ? { pos: box.top, size: box.height } : { pos: box.left, size: box.width }, { instant: !placed || prefersReducedMotion.current } ); placed = true; ready = true; });
const style = $derived(tabs({ vertical, ready, overlapBottom }));
const plateStyle = $derived( vertical ? `translate: ${box?.left ?? 0}px ${plate.current.pos}px; width: ${box?.width ?? 0}px; height: ${plate.current.size}px` : `translate: ${plate.current.pos}px; width: ${plate.current.size}px; height: ${box?.height ?? 0}px` );</script>
<nav class={style.nav({ class: navClass })} aria-label={label} bind:this={nav}> <span aria-hidden="true" class={style.plate()} style={plateStyle}></span> {#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={style.item({ selected: isSelected })} > {#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 })} >{formatCount(count)}</span > {/if} </Pending> {:else if tab.count} <span class={style.count({ selected: isSelected })} >{formatCount(tab.count)}</span > {/if} {/if} </a> {/each}</nav>