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:
18 "pointer-events-none absolute top-0 left-0 rounded-t border border-b-0 border-border-default bg-background-default",
19 item: "flex items-center no-underline hover:no-underline",
20 icon: "size-4",
21 label: "flex flex-col",
22 ghost: "invisible h-0 overflow-hidden font-medium select-none",
23 count: "rounded-sm px-1 typography-paragraph-small"
24 },
25 variants: {
26 vertical: {
27 true: {
28 nav: "h-fit divide-y divide-border-default overflow-hidden rounded-sm border border-border-default",
29 item: "gap-3 px-3 py-2 typography-paragraph-small",
30 icon: "shrink-0",
31 count: "ml-auto"
32 },
33 false: {
34 nav: "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4",
35 item: "relative mr-1 rounded-t border border-b-0 border-transparent px-4 pt-1 pb-1.25 whitespace-nowrap text-foreground-default",
36 icon: "mr-2",
37 count: "ml-1"
38 }
39 },
40 selected: {
41 true: {
42 count: "bg-background-muted"
43 },
44 false: {
45 count: "bg-background-strong"
46 }
47 },
48 ready: { true: { plate: "opacity-100" }, false: { plate: "opacity-0" } },
49 overlapBottom: { true: { nav: "-mb-px" }, false: {} }
50 },
51 compoundVariants: [
52 {
53 vertical: true,
54 selected: true,
55 class: { item: "bg-background-default text-foreground-default dark:bg-background-inset" }
56 },
57 {
58 vertical: true,
59 selected: false,
60 class: {
61 item: "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default"
62 }
63 },
64 {
65 vertical: false,
66 selected: true,
67 class: { item: "[-webkit-text-stroke:0.3px_currentColor]" }
68 },
69 {
70 vertical: false,
71 selected: true,
72 ready: false,
73 class: { item: "border-border-default bg-background-default" }
74 },
75 {
76 vertical: false,
77 selected: false,
78 class: { item: "hover:bg-background-muted hover:dark:bg-background-subtle/50" }
79 }
80 ],
81 defaultVariants: {
82 vertical: false,
83 selected: false,
84 ready: false,
85 overlapBottom: false
86 }
87 });
88
89 export type TabsVariants = VariantProps<typeof tabs>;
90</script>
91
92<script lang="ts">
93 import { resolve } from "$app/paths";
94 import { navigating } from "$app/state";
95 import type { ResolvedPathname } from "$app/types";
96 import { Spring, prefersReducedMotion } from "svelte/motion";
97
98 interface Props {
99 tabs: TabDef[];
100 active: string;
101 label?: string;
102 vertical?: boolean;
103 overlapBottom?: boolean;
104 }
105
106 let { tabs: defs, active, label, vertical = false, overlapBottom = false }: Props = $props();
107
108 const items = $derived(
109 defs.map((tab) => ({
110 ...tab,
111 url: (tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href) as ResolvedPathname
112 }))
113 );
114
115 const selected = $derived.by(() => {
116 const dest = navigating.to?.url;
117 if (!dest) return active;
118
119 let best: string | undefined;
120 let bestLength = -1;
121 for (const item of items) {
122 if (item.url === dest.pathname + dest.search) return item.id;
123 if (item.url.includes("?")) continue;
124 const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`);
125 if (owns && item.url.length > bestLength) {
126 best = item.id;
127 bestLength = item.url.length;
128 }
129 }
130 return best ?? active;
131 });
132
133 let nav = $state<HTMLElement>();
134 let nodes = $state<(HTMLElement | undefined)[]>([]);
135 let box = $state<{ x: number; width: number; height: number }>();
136 let ready = $state(false);
137
138 const plate = new Spring({ x: 0, width: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 });
139
140 const measure = () => {
141 const node = nodes[items.findIndex((item) => item.id === selected)];
142 if (!node) return;
143 box = { x: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight };
144 };
145
146 $effect(() => {
147 if (vertical) return;
148 measure();
149 });
150
151 $effect(() => {
152 if (vertical || !nav) return;
153 const observer = new ResizeObserver(measure);
154 observer.observe(nav);
155 for (const node of nodes) if (node) observer.observe(node);
156 return () => observer.disconnect();
157 });
158
159 let placed = false;
160 $effect(() => {
161 if (!box) return;
162 plate.set({ x: box.x, width: box.width }, { instant: !placed || prefersReducedMotion.current });
163 placed = true;
164 ready = true;
165 });
166
167 const style = $derived(tabs({ vertical, ready, overlapBottom }));
168</script>
169
170<nav class={style.nav()} aria-label={label} bind:this={nav}>
171 {#if !vertical}
172 <span
173 aria-hidden="true"
174 class={style.plate()}
175 style="translate: {plate.current.x}px; width: {plate.current.width}px; height: {box?.height ??
176 0}px"
177 ></span>
178 {/if}
179 {#each items as tab, i (tab.id)}
180 {@const isSelected = selected === tab.id}
181 {@const Glyph = tab.icon}
182 <a
183 bind:this={nodes[i]}
184 href={tab.url}
185 aria-current={active === tab.id ? "page" : undefined}
186 class={style.item({ selected: isSelected })}
187 >
188 {#if Glyph}
189 <Glyph class={style.icon()} aria-hidden="true" />
190 {/if}
191 {#if vertical}
192 {tab.label}
193 {:else}
194 <span class={style.label()}>
195 {tab.label}
196 <span aria-hidden="true" class={style.ghost()}>{tab.label}</span>
197 </span>
198 {/if}
199 {#if tab.count}
200 <span class={style.count({ selected: isSelected })}>{tab.count}</span>
201 {/if}
202 </a>
203 {/each}
204</nav>