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
7.0 kB 231 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: "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>