Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants"; import { popoverAnchorName, popoverOrigin, popoverSingleton, type PopoverOrigin } from "$lib/components/ui/popover.svelte";
export const hovercard = tv({ slots: { // the wrapper is the anchor: inline-flex so it hugs the trigger and the card // lines up with it rather than with a full-width box wrapper: "inline-flex", // overflow-visible so the card's own content (e.g. a nested popover) isn't clipped // pointer-events-auto because the card is a child of whatever the trigger sits in, // and a card the pointer falls through closes the moment it is reached for card: "hovercard-card pointer-events-auto fixed inset-auto z-50 m-none h-fit popover-transition overflow-visible rounded-sm border border-border-default bg-background-default text-foreground-default shadow-regular" }, variants: { align: { "top-left": {}, "top-middle": {}, "top-right": {}, "bottom-left": {}, "bottom-middle": {}, "bottom-right": {}, "left-top": {}, "left-middle": {}, "left-bottom": {}, "right-top": {}, "right-middle": {}, "right-bottom": {} } } });
export type HovercardVariants = VariantProps<typeof hovercard>;
// hovering a second trigger should swap cards, not stack them. native popover="hint" // would do this for us but isn't widely supported yet, so keep a module-level // singleton the way Tooltip and Dropdown do. const cardSingleton = popoverSingleton();</script>
<script lang="ts"> import type { Snippet } from "svelte"; import { onMount } from "svelte"; import { popoverExit } from "$lib/popoverExit";
interface Props { // the trigger — an avatar, a name, anything children: Snippet; // the card body, rendered inside the popover content: Snippet; // ms to wait on hover before opening openDelay?: number; // ms to wait before closing, bridging the gap so the pointer can reach the card closeDelay?: number; // skip the card entirely — handy where a hovercard would only be noise disabled?: boolean; // which corner or edge of the trigger the card hangs off align?: HovercardVariants["align"]; // wrapper (anchor) class class?: string; // extends the card's own classes, e.g. width and padding cardClass?: string; // fired when the card opens; a good place to lazily load its content onOpen?: () => void; origin?: PopoverOrigin; }
let { children, content, openDelay = 500, closeDelay = 150, disabled = false, align = "top-middle", class: className, cardClass, onOpen, origin }: Props = $props();
const classes = $derived(hovercard({ align }));
let wrapperEl = $state<HTMLElement>(); let cardEl = $state<HTMLElement>();
const uid = $props.id(); const cardId = `hovercard-${uid}`; // anchor-name must be a unique custom ident per instance, so derive it from the id const anchorName = popoverAnchorName("hovercard", uid);
let openTimer: ReturnType<typeof setTimeout> | undefined; let closeTimer: ReturnType<typeof setTimeout> | undefined; let open = $state(false);
const clearTimers = () => { clearTimeout(openTimer); clearTimeout(closeTimer); openTimer = closeTimer = undefined; };
const show = () => { clearTimers(); cardSingleton.claim(hide); // showPopover throws if already open, which the hover bridge can retrigger if (cardEl && !cardEl.matches(":popover-open")) cardEl.showPopover(); };
const hide = () => { clearTimers(); if (!cardEl?.matches(":popover-open")) return; // keep the card up while a nested popover (e.g. a modal launched from it) is open, // otherwise clicking into it from the card would dismiss the card out from under it if (cardEl.querySelector(":popover-open")) return; cardSingleton.release(hide); cardEl.hidePopover(); };
const scheduleShow = (event: PointerEvent) => { // touch has no hover; a tap would open the card and swallow the tap's own action if (disabled || event.pointerType === "touch") return; clearTimeout(closeTimer); openTimer = setTimeout(show, openDelay); };
const scheduleHide = () => { clearTimeout(openTimer); closeTimer = setTimeout(hide, closeDelay); };
const keepOpen = () => clearTimeout(closeTimer);
const onToggle = (event: Event) => { const isOpen = (event as ToggleEvent).newState === "open"; open = isOpen; if (isOpen) onOpen?.(); else cardSingleton.release(hide); };
onMount(() => { const wrapper = wrapperEl; const cardNode = cardEl; if (!wrapper) return;
// handlers live here rather than in the markup: a span carrying pointer handlers // has no honest ARIA role for the a11y lint, and pointerenter/leave fire on the // wrapper's own boundary, which encloses whatever trigger the caller passed wrapper.addEventListener("pointerenter", scheduleShow); wrapper.addEventListener("pointerleave", scheduleHide); cardNode?.addEventListener("pointerenter", keepOpen); cardNode?.addEventListener("pointerleave", scheduleHide);
return () => { wrapper.removeEventListener("pointerenter", scheduleShow); wrapper.removeEventListener("pointerleave", scheduleHide); cardNode?.removeEventListener("pointerenter", keepOpen); cardNode?.removeEventListener("pointerleave", scheduleHide); clearTimers(); cardSingleton.release(hide); }; });
$effect(() => { if (disabled) hide(); });
$effect(() => { if (!open) return; const onKeydown = (event: KeyboardEvent) => { if (event.key === "Escape") hide(); }; document.addEventListener("keydown", onKeydown); return () => document.removeEventListener("keydown", onKeydown); });</script>
<span bind:this={wrapperEl} class={classes.wrapper({ class: className })} style="anchor-name: {anchorName}"> {@render children()}</span>
{#if !disabled} <div bind:this={cardEl} id={cardId} popover="manual" data-align={align} style="position-anchor: {anchorName}" class={classes.card({ class: cardClass })} ontoggle={onToggle} {@attach popoverExit()} {@attach popoverOrigin( () => wrapperEl, () => origin )} > {@render content()} </div>{/if}
<style> /* anchor positioning went Baseline in early 2026; without it the card falls back to the UA's centred position, the same trade-off Tooltip and Dropdown take. above and below the trigger the card aligns on its left edge; beside it, it centres. */ @supports (position-area: bottom) and (position-try: flip-block) { .hovercard-card { position-try: flip-block, flip-inline; }
.hovercard-card[data-align="top-left"] { margin-block: 0.375rem; position-area: block-start span-inline-end; }
.hovercard-card[data-align="top-middle"] { margin-block: 0.375rem; position-area: block-start center; }
.hovercard-card[data-align="top-right"] { margin-block: 0.375rem; position-area: block-start span-inline-start; }
.hovercard-card[data-align="bottom-left"] { margin-block: 0.375rem; position-area: block-end span-inline-end; }
.hovercard-card[data-align="bottom-middle"] { margin-block: 0.375rem; position-area: block-end center; }
.hovercard-card[data-align="bottom-right"] { margin-block: 0.375rem; position-area: block-end span-inline-start; }
.hovercard-card[data-align="left-top"] { margin-inline: 0.375rem; position-area: inline-start span-block-end; }
.hovercard-card[data-align="left-middle"] { margin-inline: 0.375rem; position-area: inline-start center; }
.hovercard-card[data-align="left-bottom"] { margin-inline: 0.375rem; position-area: inline-start span-block-start; }
.hovercard-card[data-align="right-top"] { margin-inline: 0.375rem; position-area: inline-end span-block-end; }
.hovercard-card[data-align="right-middle"] { margin-inline: 0.375rem; position-area: inline-end center; }
.hovercard-card[data-align="right-bottom"] { margin-inline: 0.375rem; position-area: inline-end span-block-start; } }</style>