This repository has no description
0

Configure Feed

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

web/components: add Tooltip

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

+461
+94
web/src/lib/components/ui/Tooltip.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Plus from "$icon/plus"; 4 + import Button from "./Button.svelte"; 5 + import Tooltip from "./Tooltip.svelte"; 6 + 7 + const { Story } = defineMeta({ 8 + title: "UI/Tooltip", 9 + component: Tooltip, 10 + tags: ["autodocs"] 11 + }); 12 + </script> 13 + 14 + <!-- Tooltip's `children` is the trigger it wraps, so every story renders via `asChild` 15 + rather than through the args table. Hover or tab to the trigger in the canvas — `open` 16 + is internal state unless you bind it. The stories pad generously because the bubble is 17 + a popover in the top layer and would otherwise sit over the canvas edge. --> 18 + 19 + {#snippet richContent()} 20 + <span class="font-medium">Sign the commit</span> 21 + <span class="block text-foreground-on-emphasis/70"> 22 + Requires a key registered on your account. 23 + </span> 24 + {/snippet} 25 + 26 + <Story name="Default" asChild> 27 + <div class="flex justify-center p-16"> 28 + <Tooltip content="Add to library"> 29 + <Button>Hover me</Button> 30 + </Tooltip> 31 + </div> 32 + </Story> 33 + 34 + <!-- `side` picks the preferred edge; the bubble still flips to the opposite one when the 35 + preferred side would overflow the viewport. --> 36 + <Story name="Sides" asChild> 37 + <div class="flex justify-center gap-2 p-16"> 38 + {#each ["left", "top", "bottom", "right"] as const as side (side)} 39 + <Tooltip {side} content={side}> 40 + <Button>{side}</Button> 41 + </Tooltip> 42 + {/each} 43 + </div> 44 + </Story> 45 + 46 + <!-- the canonical use: an icon-only button whose aria-label and tooltip say the same thing --> 47 + <Story name="Icon button" asChild> 48 + <div class="flex justify-center p-16"> 49 + <Tooltip content="New issue"> 50 + <Button icon={Plus} aria-label="New issue" class="px-2" /> 51 + </Tooltip> 52 + </div> 53 + </Story> 54 + 55 + <!-- `content` also takes a snippet when a single string is not enough --> 56 + <Story name="Rich content" asChild> 57 + <div class="flex justify-center p-16"> 58 + <Tooltip content={richContent}> 59 + <Button>Signed commits</Button> 60 + </Tooltip> 61 + </div> 62 + </Story> 63 + 64 + <!-- `delay={0}` opens on hover with no wait. Note that once any tooltip has been shown, 65 + moving to a neighbouring trigger opens instantly regardless of delay. --> 66 + <Story name="No delay" asChild> 67 + <div class="flex justify-center gap-2 p-16"> 68 + <Tooltip content="Opens immediately" delay={0}> 69 + <Button>No delay</Button> 70 + </Tooltip> 71 + <Tooltip content="Waits 700ms" delay={700}> 72 + <Button>Slow</Button> 73 + </Tooltip> 74 + </div> 75 + </Story> 76 + 77 + <!-- the arrow points at whichever edge the bubble actually landed on, so it stays correct 78 + even when the preferred side had to flip. `arrow={false}` drops it. --> 79 + <Story name="No arrow" asChild> 80 + <div class="flex justify-center p-16"> 81 + <Tooltip content="No pointer" arrow={false}> 82 + <Button>No arrow</Button> 83 + </Tooltip> 84 + </div> 85 + </Story> 86 + 87 + <!-- `disabled` skips the tooltip without changing the markup around the trigger --> 88 + <Story name="Disabled" asChild> 89 + <div class="flex justify-center p-16"> 90 + <Tooltip content="You will not see this" disabled> 91 + <Button>No tooltip</Button> 92 + </Tooltip> 93 + </div> 94 + </Story>
+367
web/src/lib/components/ui/Tooltip.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const tooltip = tv({ 5 + slots: { 6 + // the wrapper is the anchor: it has to hug its child so the tooltip lines up with 7 + // the trigger and not with a full-width box around it 8 + wrapper: "inline-flex", 9 + // overflow-visible undoes the UA stylesheet's `overflow: auto` on [popover], which 10 + // would otherwise clip the arrow off at the bubble's padding box 11 + bubble: 12 + "tooltip-bubble fixed z-50 m-0 w-max max-w-64 overflow-visible rounded-sm bg-background-emphasis px-2 pt-1.5 pb-1 typography-paragraph-small text-foreground-inverted shadow-regular" 13 + }, 14 + variants: { 15 + side: { 16 + top: {}, 17 + right: {}, 18 + bottom: {}, 19 + left: {} 20 + } 21 + }, 22 + defaultVariants: { 23 + side: "top" 24 + } 25 + }); 26 + 27 + export type TooltipVariants = VariantProps<typeof tooltip>; 28 + 29 + // hovering a second trigger while one tooltip is up should swap them, not stack them. 30 + // `popover="hint"` would do this natively but is not widely supported yet, so keep a 31 + // module-level singleton the way Dropdown keeps its groups. 32 + let openTooltip: (() => void) | undefined; 33 + 34 + // once a tooltip has been shown, moving to a neighbouring trigger inside this window 35 + // opens instantly — a toolbar of icon buttons feels broken if every hop re-waits. 36 + const SKIP_DELAY = 300; 37 + let lastHiddenAt = 0; 38 + </script> 39 + 40 + <script lang="ts"> 41 + import type { Snippet } from "svelte"; 42 + import { onMount } from "svelte"; 43 + 44 + interface Props { 45 + /** Tooltip body: a plain string for the common case, a snippet for rich content. */ 46 + content: string | Snippet; 47 + side?: TooltipVariants["side"]; 48 + /** ms to wait before opening on hover. Keyboard focus always opens immediately. */ 49 + delay?: number; 50 + /** Skip the tooltip entirely — handy when the label is only useful in some states. */ 51 + disabled?: boolean; 52 + /** Two-way: set it to drive the tooltip yourself, e.g. a transient "Copied!". */ 53 + open?: boolean; 54 + /** Draw the little pointer at the trigger. */ 55 + arrow?: boolean; 56 + id?: string; 57 + class?: string; 58 + bubbleClass?: string; 59 + children: Snippet; 60 + } 61 + 62 + let { 63 + content, 64 + side = "top", 65 + delay = 300, 66 + disabled = false, 67 + open = $bindable(false), 68 + arrow = true, 69 + id, 70 + class: className, 71 + bubbleClass, 72 + children 73 + }: Props = $props(); 74 + 75 + const classes = $derived(tooltip({ side })); 76 + const fallbackId = $props.id(); 77 + const bubbleId = $derived(id ?? fallbackId); 78 + // anchor-name has to be a custom ident and unique per instance, so it cannot live in the 79 + // stylesheet — derive it from the same id the bubble is labelled with 80 + const anchorName = $derived(`--tooltip-${bubbleId.replace(/[^a-zA-Z0-9_-]/g, "-")}`); 81 + 82 + let wrapperEl = $state<HTMLElement>(); 83 + let bubbleEl = $state<HTMLElement>(); 84 + let timer: ReturnType<typeof setTimeout> | undefined; 85 + 86 + // The arrow cannot be driven off `side`: position-try flips the bubble to the opposite 87 + // edge without touching any attribute, so a preference of "top" can render below the 88 + // trigger and the arrow would point away from it. Both the edge the bubble actually 89 + // landed on and the arrow's offset along that edge are therefore measured from geometry. 90 + // undefined until the first measurement, so `side` stays the honest answer until then 91 + let measuredSide = $state<TooltipVariants["side"] | undefined>(); 92 + const resolvedSide = $derived(measuredSide ?? side); 93 + let arrowOffset = $state(0); 94 + 95 + // keeps the arrow clear of the bubble's rounded corners when the trigger sits near one end 96 + const ARROW_INSET = 8; 97 + 98 + const measureArrow = () => { 99 + if (!arrow || !bubbleEl || !wrapperEl) return; 100 + const bubble = bubbleEl.getBoundingClientRect(); 101 + const trigger = wrapperEl.getBoundingClientRect(); 102 + if (bubble.width === 0) return; 103 + 104 + // only the axis the preferred side lives on can flip, so one comparison settles it 105 + const vertical = side === "top" || side === "bottom"; 106 + measuredSide = vertical 107 + ? bubble.top >= trigger.bottom - 1 108 + ? "bottom" 109 + : "top" 110 + : bubble.left >= trigger.right - 1 111 + ? "right" 112 + : "left"; 113 + 114 + // centre the arrow on the trigger rather than on the bubble: near a viewport edge the 115 + // bubble slides sideways to stay visible while the trigger stays put 116 + const along = vertical 117 + ? trigger.left + trigger.width / 2 - bubble.left 118 + : trigger.top + trigger.height / 2 - bubble.top; 119 + const extent = vertical ? bubble.width : bubble.height; 120 + arrowOffset = Math.min( 121 + Math.max(along, ARROW_INSET), 122 + Math.max(extent - ARROW_INSET, ARROW_INSET) 123 + ); 124 + }; 125 + 126 + const cancel = () => { 127 + clearTimeout(timer); 128 + timer = undefined; 129 + }; 130 + 131 + const show = () => { 132 + if (disabled) return; 133 + cancel(); 134 + if (openTooltip && openTooltip !== hide) openTooltip(); 135 + openTooltip = hide; 136 + bubbleEl?.showPopover(); 137 + }; 138 + 139 + const hide = () => { 140 + cancel(); 141 + if (openTooltip === hide) openTooltip = undefined; 142 + bubbleEl?.hidePopover(); 143 + }; 144 + 145 + const scheduleShow = () => { 146 + if (disabled || open) return; 147 + const instant = delay === 0 || Date.now() - lastHiddenAt < SKIP_DELAY; 148 + if (instant) { 149 + show(); 150 + return; 151 + } 152 + cancel(); 153 + timer = setTimeout(show, delay); 154 + }; 155 + 156 + const onPointerEnter = (event: PointerEvent) => { 157 + // touch has no hover: the tap would open the tooltip and swallow the visual feedback 158 + // of whatever was actually tapped 159 + if (event.pointerType === "touch") return; 160 + scheduleShow(); 161 + }; 162 + 163 + const onFocusIn = () => { 164 + // no delay on focus: aria-describedby only resolves while the bubble is rendered, so 165 + // it has to be up by the time a keyboard user lands on the trigger 166 + if (!disabled) show(); 167 + }; 168 + 169 + const onToggle = (event: Event) => { 170 + const { newState } = event as ToggleEvent; 171 + open = newState === "open"; 172 + if (!open) lastHiddenAt = Date.now(); 173 + }; 174 + 175 + $effect(() => { 176 + if (!bubbleEl) return; 177 + // keeps a consumer-driven `open` in sync with the popover; both calls are no-ops when 178 + // the state already matches, so this cannot ping-pong with onToggle 179 + if (open) bubbleEl.showPopover(); 180 + else bubbleEl.hidePopover(); 181 + }); 182 + 183 + $effect(() => { 184 + if (disabled) hide(); 185 + }); 186 + 187 + // declared after the sync effect above so the popover is already rendered by the time this 188 + // one measures — effects in the same flush run in declaration order 189 + $effect(() => { 190 + if (!open) return; 191 + measureArrow(); 192 + 193 + const onKeydown = (event: KeyboardEvent) => { 194 + if (event.key === "Escape") hide(); 195 + }; 196 + // the bubble tracks its anchor on scroll for free, but a flip mid-scroll would leave the 197 + // arrow on the wrong edge, so re-measure whenever the geometry can have changed 198 + document.addEventListener("keydown", onKeydown); 199 + document.addEventListener("scroll", measureArrow, { capture: true, passive: true }); 200 + window.addEventListener("resize", measureArrow); 201 + return () => { 202 + document.removeEventListener("keydown", onKeydown); 203 + document.removeEventListener("scroll", measureArrow, { capture: true }); 204 + window.removeEventListener("resize", measureArrow); 205 + }; 206 + }); 207 + 208 + onMount(() => { 209 + const wrapper = wrapperEl; 210 + if (!wrapper) return; 211 + 212 + // the handlers go on the wrapper rather than in the markup because a span carrying 213 + // pointer handlers has no honest ARIA role to give the a11y lint — the interactive 214 + // element is the consumer's child. pointerenter/leave don't bubble but do fire on the 215 + // wrapper's own boundary, which encloses the trigger, so this is equivalent. 216 + wrapper.addEventListener("pointerenter", onPointerEnter); 217 + wrapper.addEventListener("pointerleave", hide); 218 + wrapper.addEventListener("focusin", onFocusIn); 219 + wrapper.addEventListener("focusout", hide); 220 + // a click means the user acted; the label has served its purpose and is now in the way 221 + wrapper.addEventListener("click", hide); 222 + 223 + // point aria-describedby at whatever interactive element is inside rather than at the 224 + // wrapper span, which AT ignores 225 + const target = 226 + wrapper.querySelector<HTMLElement>( 227 + 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 228 + ) ?? wrapper; 229 + const previous = target.getAttribute("aria-describedby"); 230 + target.setAttribute("aria-describedby", previous ? `${previous} ${bubbleId}` : bubbleId); 231 + 232 + return () => { 233 + wrapper.removeEventListener("pointerenter", onPointerEnter); 234 + wrapper.removeEventListener("pointerleave", hide); 235 + wrapper.removeEventListener("focusin", onFocusIn); 236 + wrapper.removeEventListener("focusout", hide); 237 + wrapper.removeEventListener("click", hide); 238 + if (previous) target.setAttribute("aria-describedby", previous); 239 + else target.removeAttribute("aria-describedby"); 240 + cancel(); 241 + if (openTooltip === hide) openTooltip = undefined; 242 + }; 243 + }); 244 + </script> 245 + 246 + <span 247 + bind:this={wrapperEl} 248 + class={classes.wrapper({ class: className })} 249 + style="anchor-name: {anchorName}" 250 + > 251 + {@render children()} 252 + </span> 253 + 254 + <div 255 + bind:this={bubbleEl} 256 + id={bubbleId} 257 + role="tooltip" 258 + popover="manual" 259 + data-side={side} 260 + data-resolved-side={resolvedSide} 261 + data-arrow={arrow || undefined} 262 + style="position-anchor: {anchorName}; --tooltip-arrow: {arrowOffset}px" 263 + class={classes.bubble({ class: bubbleClass })} 264 + ontoggle={onToggle} 265 + > 266 + {#if typeof content === "string"} 267 + {content} 268 + {:else} 269 + {@render content()} 270 + {/if} 271 + </div> 272 + 273 + <style> 274 + /* anchor positioning went Baseline in early 2026; without it the popover falls back to 275 + the UA's centred position, same trade-off Dropdown already takes */ 276 + @supports (position-area: bottom) and (position-try: flip-block) { 277 + .tooltip-bubble { 278 + /* the gap between trigger and bubble — margin, because position-area owns inset */ 279 + margin: 0.25rem; 280 + position-try: flip-block, flip-inline; 281 + } 282 + 283 + .tooltip-bubble[data-side="top"] { 284 + position-area: block-start; 285 + } 286 + 287 + .tooltip-bubble[data-side="bottom"] { 288 + position-area: block-end; 289 + } 290 + 291 + .tooltip-bubble[data-side="left"] { 292 + position-area: inline-start; 293 + } 294 + 295 + .tooltip-bubble[data-side="right"] { 296 + position-area: inline-end; 297 + } 298 + } 299 + 300 + /* the arrow exactly fills the 0.25rem gap, so its tip meets the trigger's edge. 301 + background-color: inherit keeps it in step with a bubbleClass that repaints the bubble. */ 302 + .tooltip-bubble[data-arrow]::after { 303 + content: ""; 304 + position: absolute; 305 + background-color: inherit; 306 + } 307 + 308 + .tooltip-bubble[data-arrow][data-resolved-side="top"]::after { 309 + top: 100%; 310 + left: var(--tooltip-arrow); 311 + width: 0.5rem; 312 + height: 0.25rem; 313 + translate: -50% 0; 314 + clip-path: polygon(50% 100%, 0 0, 100% 0); 315 + } 316 + 317 + .tooltip-bubble[data-arrow][data-resolved-side="bottom"]::after { 318 + bottom: 100%; 319 + left: var(--tooltip-arrow); 320 + width: 0.5rem; 321 + height: 0.25rem; 322 + translate: -50% 0; 323 + clip-path: polygon(50% 0, 0 100%, 100% 100%); 324 + } 325 + 326 + .tooltip-bubble[data-arrow][data-resolved-side="left"]::after { 327 + left: 100%; 328 + top: var(--tooltip-arrow); 329 + width: 0.25rem; 330 + height: 0.5rem; 331 + translate: 0 -50%; 332 + clip-path: polygon(100% 50%, 0 0, 0 100%); 333 + } 334 + 335 + .tooltip-bubble[data-arrow][data-resolved-side="right"]::after { 336 + right: 100%; 337 + top: var(--tooltip-arrow); 338 + width: 0.25rem; 339 + height: 0.5rem; 340 + translate: 0 -50%; 341 + clip-path: polygon(0 50%, 100% 0, 100% 100%); 342 + } 343 + 344 + .tooltip-bubble { 345 + opacity: 0; 346 + transition: 347 + opacity 100ms ease-out, 348 + overlay 100ms allow-discrete, 349 + display 100ms allow-discrete; 350 + } 351 + 352 + .tooltip-bubble:popover-open { 353 + opacity: 1; 354 + } 355 + 356 + @starting-style { 357 + .tooltip-bubble:popover-open { 358 + opacity: 0; 359 + } 360 + } 361 + 362 + @media (prefers-reduced-motion: reduce) { 363 + .tooltip-bubble { 364 + transition: none; 365 + } 366 + } 367 + </style>