This repository has no description
0

Configure Feed

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

web/components: make the settings sidebar a vertical Tabs

The vertical variant now looks like the old SettingsNav and slides the
same plate the horizontal tabs do, so the current section animates as you
move between pages. SettingsNav keeps only the phone picker, which is now
a Select.

This also restores the tv refactor in Tabs.svelte, which c54416b2 undid by
accident.

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

+100 -111
+16 -71
web/src/lib/components/settings/SettingsNav.svelte
··· 1 1 <script module lang="ts"> 2 - import type { Component } from "svelte"; 3 - import type { SvelteHTMLElements } from "svelte/elements"; 2 + import type { TabDef } from "$lib/components/ui/Tabs.svelte"; 4 3 5 - export interface SettingsNavItem { 6 - id: string; 7 - label: string; 8 - href: string; 9 - icon?: Component<SvelteHTMLElements["svg"]>; 10 - } 4 + export type SettingsNavItem = TabDef; 11 5 </script> 12 6 13 7 <script lang="ts"> 14 8 import { goto } from "$app/navigation"; 15 9 import { resolve } from "$app/paths"; 16 - import ChevronDown from "$icon/chevron-down"; 10 + import Select from "$lib/components/ui/Select.svelte"; 11 + import Tabs from "$lib/components/ui/Tabs.svelte"; 17 12 18 13 interface Props { 19 14 items: SettingsNavItem[]; ··· 23 18 24 19 let { items, active, label = "Settings" }: Props = $props(); 25 20 21 + const options = $derived(items.map((item) => ({ value: item.id, label: item.label }))); 26 22 const ActiveGlyph = $derived(items.find((i) => i.id === active)?.icon); 27 23 28 - const jump = (event: Event) => { 29 - const item = items.find((i) => i.id === (event.currentTarget as HTMLSelectElement).value); 24 + const jump = (id: string) => { 25 + const item = items.find((i) => i.id === id); 30 26 if (item) void goto(resolve(item.href as "/")); 31 27 }; 32 - 33 - // Figma DS "Navigation / Button" (1009:2662) has four states across Active × 34 - // Hovered. Inactive/resting is the only one with no fill and no border, so the 35 - // hover borders have to be added rather than recoloured — otherwise the item 36 - // grows by 1px on hover. 37 - const itemClass = (isActive: boolean) => 38 - [ 39 - "flex min-h-8 w-full items-center gap-1.5 overflow-hidden rounded-sm border px-3 py-1.5", 40 - "typography-paragraph-regular text-foreground-default no-underline hover:no-underline", 41 - "transition-colors duration-150 ease-in-out", 42 - "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus", 43 - isActive 44 - ? "border-border-navigation-item-active bg-background-navigation-item hover:border-border-strong hover:bg-background-muted" 45 - : "border-transparent hover:border-border-strong hover:bg-background-inset" 46 - ].join(" "); 47 28 </script> 48 29 49 30 <!-- Below md the panel is only as wide as the phone, so the sidebar collapses into 50 31 a native picker rather than seven rows that push the page content off-screen. 51 32 The hairline under it stands in for the sidebar's edge. --> 52 33 <div class="flex w-full flex-col gap-4 md:hidden"> 53 - <div class="relative w-full"> 54 - {#if ActiveGlyph} 55 - <ActiveGlyph 56 - class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-foreground-default" 57 - aria-hidden="true" 58 - /> 59 - {/if} 60 - <select 61 - aria-label={label} 62 - value={active} 63 - onchange={jump} 64 - class="h-8 w-full appearance-none rounded-sm border border-border-default bg-background-default pr-8 typography-paragraph-regular text-foreground-default outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus {ActiveGlyph 65 - ? 'pl-7' 66 - : 'pl-2'}" 67 - > 68 - {#each items as item (item.id)} 69 - <option value={item.id}>{item.label}</option> 70 - {/each} 71 - </select> 72 - <ChevronDown 73 - class="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2 text-foreground-default" 74 - aria-hidden="true" 75 - /> 76 - </div> 34 + <!-- the active tab lives in the url, so navigation is what updates it: no two way binding --> 35 + <Select {options} value={active} onSelect={jump} {label} icon={ActiveGlyph} /> 77 36 <hr class="w-full border-0 border-t border-border-default" /> 78 37 </div> 79 38 80 - <nav 81 - class="hidden w-full shrink-0 flex-col gap-1 bg-background-navigation-frame p-2 md:flex md:w-44 md:self-stretch md:border-r md:border-border-default" 82 - aria-label={label} 83 - > 84 - {#each items as item (item.id)} 85 - {@const isActive = active === item.id} 86 - {@const Glyph = item.icon} 87 - <a 88 - href={resolve(item.href as "/")} 89 - aria-current={isActive ? "page" : undefined} 90 - class={itemClass(isActive)} 91 - > 92 - {#if Glyph} 93 - <Glyph class="size-3.5 shrink-0" aria-hidden="true" /> 94 - {/if} 95 - <!-- Figma clips the item rather than letting a long label wrap it taller; an 96 - ellipsis keeps that height without hiding the label outright --> 97 - <span class="min-w-0 truncate">{item.label}</span> 98 - </a> 99 - {/each} 100 - </nav> 39 + <Tabs 40 + tabs={items} 41 + {active} 42 + {label} 43 + vertical 44 + class="hidden shrink-0 md:flex md:w-44 md:self-stretch md:border-r md:border-border-default" 45 + />
+17
web/src/lib/components/ui/Tabs.stories.svelte
··· 30 30 <Story name="Default" /> 31 31 <Story name="SecondActive" args={{ active: "repos" }} /> 32 32 <Story name="WithoutIcons" args={{ tabs: plainTabs, active: "open" }} /> 33 + <!-- the vertical variant is the settings sidebar shape: it stretches to its container 34 + rather than hugging its items, so the stories give it a tall narrow box --> 35 + <Story name="Vertical" args={{ vertical: true }}> 36 + {#snippet template(args)} 37 + <div class="flex h-80 w-44 items-stretch"> 38 + <Tabs {...args} /> 39 + </div> 40 + {/snippet} 41 + </Story> 42 + 43 + <Story name="VerticalSecondActive" args={{ vertical: true, active: "repos" }}> 44 + {#snippet template(args)} 45 + <div class="flex h-80 w-44 items-stretch"> 46 + <Tabs {...args} /> 47 + </div> 48 + {/snippet} 49 + </Story>
+67 -40
web/src/lib/components/ui/Tabs.svelte
··· 14 14 export const tabs = tv({ 15 15 slots: { 16 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", 17 + plate: "pointer-events-none absolute top-0 left-0", 18 + item: "relative flex items-center no-underline hover:no-underline", 20 19 icon: "size-4", 21 - label: "flex flex-col", 20 + label: "", 22 21 ghost: "invisible h-0 overflow-hidden font-medium select-none", 23 22 count: "rounded-sm px-1 typography-paragraph-small" 24 23 }, 25 24 variants: { 26 25 vertical: { 27 26 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", 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", 31 35 count: "ml-auto" 32 36 }, 33 37 false: { 34 38 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", 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", 36 41 icon: "mr-2", 42 + label: "flex flex-col", 37 43 count: "ml-1" 38 44 } 39 45 }, ··· 52 58 { 53 59 vertical: true, 54 60 selected: true, 55 - class: { item: "bg-background-default text-foreground-default dark:bg-background-inset" } 56 - }, 57 - { 58 - vertical: true, 59 - selected: false, 61 + ready: false, 60 62 class: { 61 - item: "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default" 63 + item: "border-border-navigation-item-active bg-background-navigation-item" 62 64 } 63 65 }, 64 66 { 65 - vertical: false, 66 - selected: true, 67 - class: { item: "[-webkit-text-stroke:0.3px_currentColor]" } 67 + vertical: true, 68 + selected: false, 69 + class: { item: "hover:bg-background-inset" } 68 70 }, 69 71 { 70 72 vertical: false, ··· 101 103 label?: string; 102 104 vertical?: boolean; 103 105 overlapBottom?: boolean; 106 + class?: string; 104 107 } 105 108 106 - let { tabs: defs, active, label, vertical = false, overlapBottom = false }: Props = $props(); 109 + let { 110 + tabs: defs, 111 + active, 112 + label, 113 + vertical = false, 114 + overlapBottom = false, 115 + class: navClass 116 + }: Props = $props(); 107 117 118 + // resolved up front so the plate can match hrefs against the destination url 108 119 const items = $derived( 109 120 defs.map((tab) => ({ 110 121 ...tab, ··· 112 123 })) 113 124 ); 114 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 115 129 const selected = $derived.by(() => { 116 130 const dest = navigating.to?.url; 117 131 if (!dest) return active; ··· 120 134 let bestLength = -1; 121 135 for (const item of items) { 122 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 123 139 if (item.url.includes("?")) continue; 124 140 const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`); 125 141 if (owns && item.url.length > bestLength) { ··· 132 148 133 149 let nav = $state<HTMLElement>(); 134 150 let nodes = $state<(HTMLElement | undefined)[]>([]); 135 - let box = $state<{ x: number; width: number; height: number }>(); 151 + let box = $state<{ top: number; left: number; width: number; height: number }>(); 136 152 let ready = $state(false); 137 153 138 - const plate = new Spring({ x: 0, width: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 }); 154 + const plate = new Spring( 155 + { pos: 0, size: 0 }, 156 + { stiffness: 0.145, damping: 0.65, precision: 0.1 } 157 + ); 139 158 140 159 const measure = () => { 141 160 const node = nodes[items.findIndex((item) => item.id === selected)]; 142 - if (!node) return; 143 - box = { x: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight }; 161 + if (!node?.offsetParent) return; 162 + box = { 163 + top: node.offsetTop, 164 + left: node.offsetLeft, 165 + width: node.offsetWidth, 166 + height: node.offsetHeight 167 + }; 144 168 }; 145 169 146 170 $effect(() => { 147 - if (vertical) return; 148 171 measure(); 149 172 }); 150 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 151 176 $effect(() => { 152 - if (vertical || !nav) return; 177 + if (!nav) return; 153 178 const observer = new ResizeObserver(measure); 154 179 observer.observe(nav); 155 180 for (const node of nodes) if (node) observer.observe(node); 156 181 return () => observer.disconnect(); 157 182 }); 158 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 159 187 let placed = false; 160 188 $effect(() => { 161 189 if (!box) return; 162 - plate.set({ x: box.x, width: box.width }, { instant: !placed || prefersReducedMotion.current }); 190 + plate.set(vertical ? { pos: box.top, size: box.height } : { pos: box.left, size: box.width }, { 191 + instant: !placed || prefersReducedMotion.current 192 + }); 163 193 placed = true; 164 194 ready = true; 165 195 }); 166 196 167 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 + ); 168 204 </script> 169 205 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} 206 + <nav class={style.nav({ class: navClass })} aria-label={label} bind:this={nav}> 207 + <span aria-hidden="true" class={style.plate()} style={plateStyle}></span> 179 208 {#each items as tab, i (tab.id)} 180 209 {@const isSelected = selected === tab.id} 181 210 {@const Glyph = tab.icon} ··· 188 217 {#if Glyph} 189 218 <Glyph class={style.icon()} aria-hidden="true" /> 190 219 {/if} 191 - {#if vertical} 220 + <span class={style.label()}> 192 221 {tab.label} 193 - {:else} 194 - <span class={style.label()}> 195 - {tab.label} 222 + {#if !vertical} 196 223 <span aria-hidden="true" class={style.ghost()}>{tab.label}</span> 197 - </span> 198 - {/if} 224 + {/if} 225 + </span> 199 226 {#if tab.count} 200 227 <span class={style.count({ selected: isSelected })}>{tab.count}</span> 201 228 {/if}