<script module lang="ts">
	import { tv, type VariantProps } from "tailwind-variants";

	export const tooltip = tv({
		slots: {
			// the wrapper is the anchor: it has to hug its child so the tooltip lines up with
			// the trigger and not with a full-width box around it
			wrapper: "inline-flex",
			// overflow-visible undoes the UA stylesheet's `overflow: auto` on [popover], which
			// would otherwise clip the arrow off at the bubble's padding box
			bubble:
				"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"
		},
		variants: {
			side: {
				top: {},
				right: {},
				bottom: {},
				left: {}
			}
		},
		defaultVariants: {
			side: "top"
		}
	});

	export type TooltipVariants = VariantProps<typeof tooltip>;

	// hovering a second trigger while one tooltip is up should swap them, not stack them.
	// `popover="hint"` would do this natively but is not widely supported yet, so keep a
	// module-level singleton the way Dropdown keeps its groups.
	let openTooltip: (() => void) | undefined;

	// once a tooltip has been shown, moving to a neighbouring trigger inside this window
	// opens instantly — a toolbar of icon buttons feels broken if every hop re-waits.
	const SKIP_DELAY = 300;
	let lastHiddenAt = 0;
</script>

<script lang="ts">
	import type { Snippet } from "svelte";
	import { onMount } from "svelte";

	interface Props {
		/** Tooltip body: a plain string for the common case, a snippet for rich content. */
		content: string | Snippet;
		side?: TooltipVariants["side"];
		/** ms to wait before opening on hover. Keyboard focus always opens immediately. */
		delay?: number;
		/** Skip the tooltip entirely — handy when the label is only useful in some states. */
		disabled?: boolean;
		/** Two-way: set it to drive the tooltip yourself, e.g. a transient "Copied!". */
		open?: boolean;
		/** Draw the little pointer at the trigger. */
		arrow?: boolean;
		id?: string;
		class?: string;
		bubbleClass?: string;
		children: Snippet;
	}

	let {
		content,
		side = "top",
		delay = 300,
		disabled = false,
		open = $bindable(false),
		arrow = true,
		id,
		class: className,
		bubbleClass,
		children
	}: Props = $props();

	const classes = $derived(tooltip({ side }));
	const fallbackId = $props.id();
	const bubbleId = $derived(id ?? fallbackId);
	// anchor-name has to be a custom ident and unique per instance, so it cannot live in the
	// stylesheet — derive it from the same id the bubble is labelled with
	const anchorName = $derived(`--tooltip-${bubbleId.replace(/[^a-zA-Z0-9_-]/g, "-")}`);

	let wrapperEl = $state<HTMLElement>();
	let bubbleEl = $state<HTMLElement>();
	let timer: ReturnType<typeof setTimeout> | undefined;

	// The arrow cannot be driven off `side`: position-try flips the bubble to the opposite
	// edge without touching any attribute, so a preference of "top" can render below the
	// trigger and the arrow would point away from it. Both the edge the bubble actually
	// landed on and the arrow's offset along that edge are therefore measured from geometry.
	// undefined until the first measurement, so `side` stays the honest answer until then
	let measuredSide = $state<TooltipVariants["side"] | undefined>();
	const resolvedSide = $derived(measuredSide ?? side);
	let arrowOffset = $state(0);

	// keeps the arrow clear of the bubble's rounded corners when the trigger sits near one end
	const ARROW_INSET = 8;

	const measureArrow = () => {
		if (!arrow || !bubbleEl || !wrapperEl) return;
		const bubble = bubbleEl.getBoundingClientRect();
		const trigger = wrapperEl.getBoundingClientRect();
		if (bubble.width === 0) return;

		// only the axis the preferred side lives on can flip, so one comparison settles it
		const vertical = side === "top" || side === "bottom";
		measuredSide = vertical
			? bubble.top >= trigger.bottom - 1
				? "bottom"
				: "top"
			: bubble.left >= trigger.right - 1
				? "right"
				: "left";

		// centre the arrow on the trigger rather than on the bubble: near a viewport edge the
		// bubble slides sideways to stay visible while the trigger stays put
		const along = vertical
			? trigger.left + trigger.width / 2 - bubble.left
			: trigger.top + trigger.height / 2 - bubble.top;
		const extent = vertical ? bubble.width : bubble.height;
		arrowOffset = Math.min(
			Math.max(along, ARROW_INSET),
			Math.max(extent - ARROW_INSET, ARROW_INSET)
		);
	};

	const cancel = () => {
		clearTimeout(timer);
		timer = undefined;
	};

	const show = () => {
		if (disabled) return;
		cancel();
		if (openTooltip && openTooltip !== hide) openTooltip();
		openTooltip = hide;
		bubbleEl?.showPopover();
	};

	const hide = () => {
		cancel();
		if (openTooltip === hide) openTooltip = undefined;
		bubbleEl?.hidePopover();
	};

	const scheduleShow = () => {
		if (disabled || open) return;
		const instant = delay === 0 || Date.now() - lastHiddenAt < SKIP_DELAY;
		if (instant) {
			show();
			return;
		}
		cancel();
		timer = setTimeout(show, delay);
	};

	const onPointerEnter = (event: PointerEvent) => {
		// touch has no hover: the tap would open the tooltip and swallow the visual feedback
		// of whatever was actually tapped
		if (event.pointerType === "touch") return;
		scheduleShow();
	};

	const onFocusIn = () => {
		// no delay on focus: aria-describedby only resolves while the bubble is rendered, so
		// it has to be up by the time a keyboard user lands on the trigger
		if (!disabled) show();
	};

	const onToggle = (event: Event) => {
		const { newState } = event as ToggleEvent;
		open = newState === "open";
		if (!open) lastHiddenAt = Date.now();
	};

	$effect(() => {
		if (!bubbleEl) return;
		// keeps a consumer-driven `open` in sync with the popover; both calls are no-ops when
		// the state already matches, so this cannot ping-pong with onToggle
		if (open) bubbleEl.showPopover();
		else bubbleEl.hidePopover();
	});

	$effect(() => {
		if (disabled) hide();
	});

	// declared after the sync effect above so the popover is already rendered by the time this
	// one measures — effects in the same flush run in declaration order
	$effect(() => {
		if (!open) return;
		measureArrow();

		const onKeydown = (event: KeyboardEvent) => {
			if (event.key === "Escape") hide();
		};
		// the bubble tracks its anchor on scroll for free, but a flip mid-scroll would leave the
		// arrow on the wrong edge, so re-measure whenever the geometry can have changed
		document.addEventListener("keydown", onKeydown);
		document.addEventListener("scroll", measureArrow, { capture: true, passive: true });
		window.addEventListener("resize", measureArrow);
		return () => {
			document.removeEventListener("keydown", onKeydown);
			document.removeEventListener("scroll", measureArrow, { capture: true });
			window.removeEventListener("resize", measureArrow);
		};
	});

	onMount(() => {
		const wrapper = wrapperEl;
		if (!wrapper) return;

		// the handlers go on the wrapper rather than in the markup because a span carrying
		// pointer handlers has no honest ARIA role to give the a11y lint — the interactive
		// element is the consumer's child. pointerenter/leave don't bubble but do fire on the
		// wrapper's own boundary, which encloses the trigger, so this is equivalent.
		wrapper.addEventListener("pointerenter", onPointerEnter);
		wrapper.addEventListener("pointerleave", hide);
		wrapper.addEventListener("focusin", onFocusIn);
		wrapper.addEventListener("focusout", hide);
		// a click means the user acted; the label has served its purpose and is now in the way
		wrapper.addEventListener("click", hide);

		// point aria-describedby at whatever interactive element is inside rather than at the
		// wrapper span, which AT ignores
		const target =
			wrapper.querySelector<HTMLElement>(
				'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
			) ?? wrapper;
		const previous = target.getAttribute("aria-describedby");
		target.setAttribute("aria-describedby", previous ? `${previous} ${bubbleId}` : bubbleId);

		return () => {
			wrapper.removeEventListener("pointerenter", onPointerEnter);
			wrapper.removeEventListener("pointerleave", hide);
			wrapper.removeEventListener("focusin", onFocusIn);
			wrapper.removeEventListener("focusout", hide);
			wrapper.removeEventListener("click", hide);
			if (previous) target.setAttribute("aria-describedby", previous);
			else target.removeAttribute("aria-describedby");
			cancel();
			if (openTooltip === hide) openTooltip = undefined;
		};
	});
</script>

<span
	bind:this={wrapperEl}
	class={classes.wrapper({ class: className })}
	style="anchor-name: {anchorName}"
>
	{@render children()}
</span>

<div
	bind:this={bubbleEl}
	id={bubbleId}
	role="tooltip"
	popover="manual"
	data-side={side}
	data-resolved-side={resolvedSide}
	data-arrow={arrow || undefined}
	style="position-anchor: {anchorName}; --tooltip-arrow: {arrowOffset}px"
	class={classes.bubble({ class: bubbleClass })}
	ontoggle={onToggle}
>
	{#if typeof content === "string"}
		{content}
	{:else}
		{@render content()}
	{/if}
</div>

<style>
	/* anchor positioning went Baseline in early 2026; without it the popover falls back to
	   the UA's centred position, same trade-off Dropdown already takes */
	@supports (position-area: bottom) and (position-try: flip-block) {
		.tooltip-bubble {
			/* the gap between trigger and bubble — margin, because position-area owns inset */
			margin: 0.25rem;
			position-try: flip-block, flip-inline;
		}

		.tooltip-bubble[data-side="top"] {
			position-area: block-start;
		}

		.tooltip-bubble[data-side="bottom"] {
			position-area: block-end;
		}

		.tooltip-bubble[data-side="left"] {
			position-area: inline-start;
		}

		.tooltip-bubble[data-side="right"] {
			position-area: inline-end;
		}
	}

	/* the arrow exactly fills the 0.25rem gap, so its tip meets the trigger's edge.
	   background-color: inherit keeps it in step with a bubbleClass that repaints the bubble. */
	.tooltip-bubble[data-arrow]::after {
		content: "";
		position: absolute;
		background-color: inherit;
	}

	.tooltip-bubble[data-arrow][data-resolved-side="top"]::after {
		top: 100%;
		left: var(--tooltip-arrow);
		width: 0.5rem;
		height: 0.25rem;
		translate: -50% 0;
		clip-path: polygon(50% 100%, 0 0, 100% 0);
	}

	.tooltip-bubble[data-arrow][data-resolved-side="bottom"]::after {
		bottom: 100%;
		left: var(--tooltip-arrow);
		width: 0.5rem;
		height: 0.25rem;
		translate: -50% 0;
		clip-path: polygon(50% 0, 0 100%, 100% 100%);
	}

	.tooltip-bubble[data-arrow][data-resolved-side="left"]::after {
		left: 100%;
		top: var(--tooltip-arrow);
		width: 0.25rem;
		height: 0.5rem;
		translate: 0 -50%;
		clip-path: polygon(100% 50%, 0 0, 0 100%);
	}

	.tooltip-bubble[data-arrow][data-resolved-side="right"]::after {
		right: 100%;
		top: var(--tooltip-arrow);
		width: 0.25rem;
		height: 0.5rem;
		translate: 0 -50%;
		clip-path: polygon(0 50%, 100% 0, 100% 100%);
	}

	.tooltip-bubble {
		opacity: 0;
		transition:
			opacity 100ms ease-out,
			overlay 100ms allow-discrete,
			display 100ms allow-discrete;
	}

	.tooltip-bubble:popover-open {
		opacity: 1;
	}

	@starting-style {
		.tooltip-bubble:popover-open {
			opacity: 0;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.tooltip-bubble {
			transition: none;
		}
	}
</style>
