This repository has no description
0

Configure Feed

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

web/components: make Tabs use tv

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

+88 -47
+88 -47
web/src/lib/components/ui/Tabs.svelte
··· 1 1 <script module lang="ts"> 2 2 import type { Component } from "svelte"; 3 3 import type { SvelteHTMLElements } from "svelte/elements"; 4 + import { tv, type VariantProps } from "tailwind-variants"; 4 5 5 6 export interface TabDef { 6 7 id: string; ··· 9 10 icon?: Component<SvelteHTMLElements["svg"]>; 10 11 count?: number; 11 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>; 12 90 </script> 13 91 14 92 <script lang="ts"> ··· 25 103 overlapBottom?: boolean; 26 104 } 27 105 28 - let { tabs, active, label, vertical = false, overlapBottom = false }: Props = $props(); 106 + let { tabs: defs, active, label, vertical = false, overlapBottom = false }: Props = $props(); 29 107 30 - // resolved up front so the plate can match hrefs against the destination url 31 108 const items = $derived( 32 - tabs.map((tab) => ({ 109 + defs.map((tab) => ({ 33 110 ...tab, 34 111 url: (tab.href.startsWith("/") ? resolve(tab.href as "/") : tab.href) as ResolvedPathname 35 112 })) 36 113 ); 37 114 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 115 const selected = $derived.by(() => { 42 116 const dest = navigating.to?.url; 43 117 if (!dest) return active; ··· 46 120 let bestLength = -1; 47 121 for (const item of items) { 48 122 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 123 if (item.url.includes("?")) continue; 52 124 const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`); 53 125 if (owns && item.url.length > bestLength) { ··· 76 148 measure(); 77 149 }); 78 150 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 151 $effect(() => { 82 152 if (vertical || !nav) return; 83 153 const observer = new ResizeObserver(measure); ··· 86 156 return () => observer.disconnect(); 87 157 }); 88 158 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 159 let placed = false; 93 160 $effect(() => { 94 161 if (!box) return; ··· 97 164 ready = true; 98 165 }); 99 166 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 - }`; 167 + const style = $derived(tabs({ vertical, ready, overlapBottom })); 118 168 </script> 119 169 120 - <nav class={[navClass, overlapBottom && "-mb-px"]} aria-label={label} bind:this={nav}> 170 + <nav class={style.nav()} aria-label={label} bind:this={nav}> 121 171 {#if !vertical} 122 172 <span 123 173 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 - ]} 174 + class={style.plate()} 128 175 style="translate: {plate.current.x}px; width: {plate.current.width}px; height: {box?.height ?? 129 176 0}px" 130 177 ></span> ··· 136 183 bind:this={nodes[i]} 137 184 href={tab.url} 138 185 aria-current={active === tab.id ? "page" : undefined} 139 - class={itemClass(isSelected)} 186 + class={style.item({ selected: isSelected })} 140 187 > 141 188 {#if Glyph} 142 - <Glyph class={vertical ? "size-4 shrink-0" : "mr-2 size-4 "} aria-hidden="true" /> 189 + <Glyph class={style.icon()} aria-hidden="true" /> 143 190 {/if} 144 191 {#if vertical} 145 192 {tab.label} 146 193 {:else} 147 - <span class="flex flex-col"> 194 + <span class={style.label()}> 148 195 {tab.label} 149 - <span aria-hidden="true" class="invisible h-0 overflow-hidden font-medium select-none" 150 - >{tab.label}</span 151 - > 196 + <span aria-hidden="true" class={style.ghost()}>{tab.label}</span> 152 197 </span> 153 198 {/if} 154 199 {#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 - > 200 + <span class={style.count({ selected: isSelected })}>{tab.count}</span> 160 201 {/if} 161 202 </a> 162 203 {/each}