This repository has no description
0

Configure Feed

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

web/components: add motion to Tabs

Signed-off-by: eti <eti@eti.tf>

+101 -14
+101 -14
web/src/lib/components/ui/Tabs.svelte
··· 13 13 14 14 <script lang="ts"> 15 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"; 16 19 17 20 interface Props { 18 21 tabs: TabDef[]; ··· 24 27 25 28 let { tabs, active, label, vertical = false, overlapBottom = false }: Props = $props(); 26 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 + 27 100 const navClass = $derived( 28 101 vertical 29 102 ? "h-fit divide-y divide-border-default overflow-hidden rounded border border-border-default" 30 103 : "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4" 31 104 ); 32 105 33 - const itemClass = (isActive: boolean) => 106 + const itemClass = (isSelected: boolean) => 34 107 vertical 35 108 ? `flex items-center gap-3 px-3 py-2 typography-paragraph-small no-underline hover:no-underline ${ 36 - isActive 109 + isSelected 37 110 ? "bg-background-default text-foreground-default dark:bg-background-inset" 38 111 : "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default" 39 112 }` 40 - : `relative mr-1 flex items-center rounded-t px-4 pt-1 pb-[5px] whitespace-nowrap text-foreground-default no-underline hover:no-underline ${ 41 - isActive 42 - ? "border border-b-0 border-border-default bg-background-default [-webkit-text-stroke:0.3px_currentColor]" 43 - : "border border-b-0 border-transparent hover:bg-background-muted hover:dark:bg-background-subtle/50" 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" 44 117 }`; 45 118 </script> 46 119 47 - <nav class={[navClass, overlapBottom && "-mb-px"]} aria-label={label}> 48 - {#each tabs as tab (tab.id)} 49 - {@const isActive = active === tab.id} 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} 50 134 {@const Glyph = tab.icon} 51 135 <a 52 - href={tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href} 53 - aria-current={isActive ? "page" : undefined} 54 - class={itemClass(isActive)} 136 + bind:this={nodes[i]} 137 + href={tab.url} 138 + aria-current={active === tab.id ? "page" : undefined} 139 + class={itemClass(isSelected)} 55 140 > 56 141 {#if Glyph} 57 142 <Glyph class={vertical ? "size-4 shrink-0" : "mr-2 size-4 "} aria-hidden="true" /> ··· 67 152 </span> 68 153 {/if} 69 154 {#if tab.count} 70 - <span class="rounded-sm bg-background-inset px-1 typography-paragraph-small {vertical ? 'ml-auto' : 'ml-1'}" 71 - >{tab.count}</span 155 + <span 156 + class="rounded-sm bg-background-inset px-1 typography-paragraph-small {vertical 157 + ? 'ml-auto' 158 + : 'ml-1'}">{tab.count}</span 72 159 > 73 160 {/if} 74 161 </a>