This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / ui / Tabs.svelte
5.6 kB 204 lines
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>