This repository has no description
1<script module lang="ts">
2 import type { Component } from "svelte";
3 import type { SvelteHTMLElements } from "svelte/elements";
4
5 export interface TabDef {
6 id: string;
7 label: string;
8 href: string;
9 icon?: Component<SvelteHTMLElements["svg"]>;
10 count?: number;
11 }
12</script>
13
14<script lang="ts">
15 import { resolve } from "$app/paths";
16 import { navigating } from "$app/state";
17 import type { ResolvedPathname } from "$app/types";
18 import { Spring, prefersReducedMotion } from "svelte/motion";
19
20 interface Props {
21 tabs: TabDef[];
22 active: string;
23 label?: string;
24 vertical?: boolean;
25 overlapBottom?: boolean;
26 }
27
28 let { tabs, active, label, vertical = false, overlapBottom = false }: Props = $props();
29
30 // resolved up front so the plate can match hrefs against the destination url
31 const items = $derived(
32 tabs.map((tab) => ({
33 ...tab,
34 url: (tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href) as ResolvedPathname
35 }))
36 );
37
38 // the plate follows where we're heading rather than where we are, so it leaves
39 // the moment you click instead of waiting on the route's load; aria-current
40 // stays behind on the page that's still on screen
41 const selected = $derived.by(() => {
42 const dest = navigating.to?.url;
43 if (!dest) return active;
44
45 let best: string | undefined;
46 let bestLength = -1;
47 for (const item of items) {
48 if (item.url === dest.pathname + dest.search) return item.id;
49 // a plain path also owns everything nested under it, so an issue page
50 // keeps the issues tab lit; a query-scoped tab only matches exactly
51 if (item.url.includes("?")) continue;
52 const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`);
53 if (owns && item.url.length > bestLength) {
54 best = item.id;
55 bestLength = item.url.length;
56 }
57 }
58 return best ?? active;
59 });
60
61 let nav = $state<HTMLElement>();
62 let nodes = $state<(HTMLElement | undefined)[]>([]);
63 let box = $state<{ x: number; width: number; height: number }>();
64 let ready = $state(false);
65
66 const plate = new Spring({ x: 0, width: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 });
67
68 const measure = () => {
69 const node = nodes[items.findIndex((item) => item.id === selected)];
70 if (!node) return;
71 box = { x: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight };
72 };
73
74 $effect(() => {
75 if (vertical) return;
76 measure();
77 });
78
79 // watch every tab, not just the row: a preceding tab changing width (a count
80 // arriving, a webfont landing) shifts the plate without resizing the row
81 $effect(() => {
82 if (vertical || !nav) return;
83 const observer = new ResizeObserver(measure);
84 observer.observe(nav);
85 for (const node of nodes) if (node) observer.observe(node);
86 return () => observer.disconnect();
87 });
88
89 // the first placement lands instantly so the plate never slides in from the
90 // left; until it happens the selected tab wears the chrome itself, which is
91 // also what server-rendered and script-less pages get
92 let placed = false;
93 $effect(() => {
94 if (!box) return;
95 plate.set({ x: box.x, width: box.width }, { instant: !placed || prefersReducedMotion.current });
96 placed = true;
97 ready = true;
98 });
99
100 const navClass = $derived(
101 vertical
102 ? "h-fit divide-y divide-border-default overflow-hidden rounded border border-border-default"
103 : "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4"
104 );
105
106 const itemClass = (isSelected: boolean) =>
107 vertical
108 ? `flex items-center gap-3 px-3 py-2 typography-paragraph-small no-underline hover:no-underline ${
109 isSelected
110 ? "bg-background-default text-foreground-default dark:bg-background-inset"
111 : "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default"
112 }`
113 : `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 ${
114 isSelected
115 ? `[-webkit-text-stroke:0.3px_currentColor] ${ready ? "" : "border-border-default bg-background-default"}`
116 : "hover:bg-background-muted hover:dark:bg-background-subtle/50"
117 }`;
118</script>
119
120<nav class={[navClass, overlapBottom && "-mb-px"]} aria-label={label} bind:this={nav}>
121 {#if !vertical}
122 <span
123 aria-hidden="true"
124 class={[
125 "pointer-events-none absolute top-0 left-0 rounded-t border border-b-0 border-border-default bg-background-default",
126 ready ? "opacity-100" : "opacity-0"
127 ]}
128 style="translate: {plate.current.x}px; width: {plate.current.width}px; height: {box?.height ??
129 0}px"
130 ></span>
131 {/if}
132 {#each items as tab, i (tab.id)}
133 {@const isSelected = selected === tab.id}
134 {@const Glyph = tab.icon}
135 <a
136 bind:this={nodes[i]}
137 href={tab.url}
138 aria-current={active === tab.id ? "page" : undefined}
139 class={itemClass(isSelected)}
140 >
141 {#if Glyph}
142 <Glyph class={vertical ? "size-4 shrink-0" : "mr-2 size-4 "} aria-hidden="true" />
143 {/if}
144 {#if vertical}
145 {tab.label}
146 {:else}
147 <span class="flex flex-col">
148 {tab.label}
149 <span aria-hidden="true" class="invisible h-0 overflow-hidden font-medium select-none"
150 >{tab.label}</span
151 >
152 </span>
153 {/if}
154 {#if tab.count}
155 <span
156 class="rounded-sm bg-background-inset px-1 typography-paragraph-small {vertical
157 ? 'ml-auto'
158 : 'ml-1'}">{tab.count}</span
159 >
160 {/if}
161 </a>
162 {/each}
163</nav>