This repository has no description
1<script module lang="ts">
2 import type { Component } from "svelte";
3 import type { SvelteHTMLElements } from "svelte/elements";
4 import { tv, type VariantProps } from "tailwind-variants";
5
6 export interface TabDef {
7 id: string;
8 label: string;
9 href: string;
10 icon?: Component<SvelteHTMLElements["svg"]>;
11 count?: number;
12 }
13
14 export const tabs = tv({
15 slots: {
16 nav: "",
17 plate: "pointer-events-none absolute top-0 left-0",
18 item: "relative flex items-center no-underline hover:no-underline",
19 icon: "size-4",
20 label: "",
21 ghost: "invisible h-0 overflow-hidden font-medium select-none",
22 count: "rounded-sm px-1 typography-paragraph-small"
23 },
24 variants: {
25 vertical: {
26 true: {
27 nav: "relative flex w-full flex-col gap-1 bg-background-navigation-frame p-2",
28 plate:
29 "rounded-sm border border-border-navigation-item-active bg-background-navigation-item",
30 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",
31 icon: "size-3.5 shrink-0",
32 // Figma clips the item rather than letting a long label wrap it taller; an
33 // ellipsis keeps that height without hiding the label outright
34 label: "min-w-0 truncate",
35 count: "ml-auto"
36 },
37 false: {
38 nav: "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4",
39 plate: "rounded-t border border-b-0 border-border-default bg-background-default",
40 item: "mr-1 rounded-t border border-b-0 border-transparent px-4 pt-1 pb-1.25 whitespace-nowrap text-foreground-default",
41 icon: "mr-2",
42 label: "flex flex-col",
43 count: "ml-1"
44 }
45 },
46 selected: {
47 true: {
48 count: "bg-background-muted"
49 },
50 false: {
51 count: "bg-background-strong"
52 }
53 },
54 ready: { true: { plate: "opacity-100" }, false: { plate: "opacity-0" } },
55 overlapBottom: { true: { nav: "-mb-px" }, false: {} }
56 },
57 compoundVariants: [
58 {
59 vertical: true,
60 selected: true,
61 ready: false,
62 class: {
63 item: "border-border-navigation-item-active bg-background-navigation-item"
64 }
65 },
66 {
67 vertical: true,
68 selected: false,
69 class: { item: "hover:bg-background-inset" }
70 },
71 {
72 vertical: false,
73 selected: true,
74 ready: false,
75 class: { item: "border-border-default bg-background-default" }
76 },
77 {
78 vertical: false,
79 selected: false,
80 class: { item: "hover:bg-background-muted hover:dark:bg-background-subtle/50" }
81 }
82 ],
83 defaultVariants: {
84 vertical: false,
85 selected: false,
86 ready: false,
87 overlapBottom: false
88 }
89 });
90
91 export type TabsVariants = VariantProps<typeof tabs>;
92</script>
93
94<script lang="ts">
95 import { resolve } from "$app/paths";
96 import { navigating } from "$app/state";
97 import type { ResolvedPathname } from "$app/types";
98 import { Spring, prefersReducedMotion } from "svelte/motion";
99
100 interface Props {
101 tabs: TabDef[];
102 active: string;
103 label?: string;
104 vertical?: boolean;
105 overlapBottom?: boolean;
106 class?: string;
107 }
108
109 let {
110 tabs: defs,
111 active,
112 label,
113 vertical = false,
114 overlapBottom = false,
115 class: navClass
116 }: Props = $props();
117
118 // resolved up front so the plate can match hrefs against the destination url
119 const items = $derived(
120 defs.map((tab) => ({
121 ...tab,
122 url: (tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href) as ResolvedPathname
123 }))
124 );
125
126 // the plate follows where we're heading rather than where we are, so it leaves
127 // the moment you click instead of waiting on the route's load; aria-current
128 // stays behind on the page that's still on screen
129 const selected = $derived.by(() => {
130 const dest = navigating.to?.url;
131 if (!dest) return active;
132
133 let best: string | undefined;
134 let bestLength = -1;
135 for (const item of items) {
136 if (item.url === dest.pathname + dest.search) return item.id;
137 // a plain path also owns everything nested under it, so an issue page
138 // keeps the issues tab lit; a query-scoped tab only matches exactly
139 if (item.url.includes("?")) continue;
140 const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`);
141 if (owns && item.url.length > bestLength) {
142 best = item.id;
143 bestLength = item.url.length;
144 }
145 }
146 return best ?? active;
147 });
148
149 let nav = $state<HTMLElement>();
150 let nodes = $state<(HTMLElement | undefined)[]>([]);
151 let box = $state<{ top: number; left: number; width: number; height: number }>();
152 let ready = $state(false);
153
154 const plate = new Spring(
155 { pos: 0, size: 0 },
156 { stiffness: 0.145, damping: 0.65, precision: 0.1 }
157 );
158
159 const measure = () => {
160 const node = nodes[items.findIndex((item) => item.id === selected)];
161 if (!node?.offsetParent) return;
162 box = {
163 top: node.offsetTop,
164 left: node.offsetLeft,
165 width: node.offsetWidth,
166 height: node.offsetHeight
167 };
168 };
169
170 $effect(() => {
171 measure();
172 });
173
174 // watch every tab, not just the row: a preceding tab changing width (a count
175 // arriving, a webfont landing) shifts the plate without resizing the row
176 $effect(() => {
177 if (!nav) return;
178 const observer = new ResizeObserver(measure);
179 observer.observe(nav);
180 for (const node of nodes) if (node) observer.observe(node);
181 return () => observer.disconnect();
182 });
183
184 // the first placement lands instantly so the plate never slides in from nowhere;
185 // until it happens the selected tab wears the chrome itself, which is also what
186 // server-rendered and script-less pages get
187 let placed = false;
188 $effect(() => {
189 if (!box) return;
190 plate.set(vertical ? { pos: box.top, size: box.height } : { pos: box.left, size: box.width }, {
191 instant: !placed || prefersReducedMotion.current
192 });
193 placed = true;
194 ready = true;
195 });
196
197 const style = $derived(tabs({ vertical, ready, overlapBottom }));
198
199 const plateStyle = $derived(
200 vertical
201 ? `translate: ${box?.left ?? 0}px ${plate.current.pos}px; width: ${box?.width ?? 0}px; height: ${plate.current.size}px`
202 : `translate: ${plate.current.pos}px; width: ${plate.current.size}px; height: ${box?.height ?? 0}px`
203 );
204</script>
205
206<nav class={style.nav({ class: navClass })} aria-label={label} bind:this={nav}>
207 <span aria-hidden="true" class={style.plate()} style={plateStyle}></span>
208 {#each items as tab, i (tab.id)}
209 {@const isSelected = selected === tab.id}
210 {@const Glyph = tab.icon}
211 <a
212 bind:this={nodes[i]}
213 href={tab.url}
214 aria-current={active === tab.id ? "page" : undefined}
215 class={style.item({ selected: isSelected })}
216 >
217 {#if Glyph}
218 <Glyph class={style.icon()} aria-hidden="true" />
219 {/if}
220 <span class={style.label()}>
221 {tab.label}
222 {#if !vertical}
223 <span aria-hidden="true" class={style.ghost()}>{tab.label}</span>
224 {/if}
225 </span>
226 {#if tab.count}
227 <span class={style.count({ selected: isSelected })}>{tab.count}</span>
228 {/if}
229 </a>
230 {/each}
231</nav>