Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315<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>