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.4 kB 163 lines
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>