melt
Version:
The next generation of Melt UI. Built for Svelte 5.
355 lines (354 loc) • 14.2 kB
JavaScript
import { Synced } from "../Synced.svelte";
import { dataAttr, styleAttr } from "../utils/attribute";
import { extract } from "../utils/extract";
import { createBuilderMetadata } from "../utils/identifiers";
import { isHtmlElement } from "../utils/is";
import { isPointerInGraceArea } from "../utils/pointer";
import { computeConvexHull, getPointsFromEl } from "../utils/polygon";
import { autoOpenPopover, safelyHidePopover } from "../utils/popover.js";
import { useFloating, } from "../utils/use-floating.svelte";
import { dequal } from "dequal";
import { useEventListener, watch } from "runed";
import { createAttachmentKey } from "svelte/attachments";
import { on } from "svelte/events";
const { createIds, dataAttrs, dataSelectors } = createBuilderMetadata("tooltip", [
"trigger",
"content",
"arrow",
]);
export class Tooltip {
ids = $state(createIds());
invokerRect = $state();
/** Props */
#props;
closeOnPointerDown = $derived(extract(this.#props.closeOnPointerDown, true));
openDelay = $derived(extract(this.#props.openDelay, 1000));
closeDelay = $derived(extract(this.#props.closeDelay, 0));
disableHoverableContent = $derived(extract(this.#props.disableHoverableContent, false));
forceVisible = $derived(extract(this.#props.forceVisible, false));
floatingConfig = $derived.by(() => {
const config = extract(this.#props.floatingConfig, {});
config.computePosition = {
...config.computePosition,
middleware: [
...(config.computePosition?.middleware ?? []),
{
name: "grabInvokerRect",
fn: ({ rects }) => {
const prev = $state.snapshot(this.invokerRect);
const curr = rects.reference;
if (dequal(prev, curr))
return {};
this.invokerRect = rects.reference;
return {};
},
},
],
};
return config;
});
/** State */
isVisible = $derived(this.open || this.forceVisible);
#open;
#openReason = $state(null);
#clickedTrigger = $state(false);
#isPointerInsideTrigger = $state(false);
#isPointerInsideContent = $state(false);
#isMouseInTooltipArea = $state(false);
#openTimeout = $state(null);
#closeTimeout = $state(null);
#floatingData = $state();
get graceAreaPolygon() {
const contentEl = document.getElementById(this.ids.content);
const triggerEl = document.getElementById(this.ids.trigger);
if (!contentEl || !triggerEl) {
return [];
}
const PADDING = 6;
const [tl, tr, br, bl] = getPointsFromEl(triggerEl);
const contentPoints = this.disableHoverableContent ? [] : getPointsFromEl(contentEl);
const placement = this.#floatingData?.placement;
const points = [...contentPoints];
if (placement?.startsWith("top")) {
points.push(tl, tr);
}
else if (placement?.startsWith("right")) {
points.push(tr, br);
}
else if (placement?.startsWith("bottom")) {
points.push(br, bl);
}
else {
points.push(bl, tl);
}
const withPadding = points.reduce((acc, point) => {
return [
...acc,
{ x: point.x + PADDING, y: point.y + PADDING },
{ x: point.x + PADDING, y: point.y - PADDING },
{ x: point.x - PADDING, y: point.y + PADDING },
{ x: point.x - PADDING, y: point.y - PADDING },
];
}, []);
return computeConvexHull(withPadding);
}
constructor(props = {}) {
this.#open = new Synced({
value: props.open,
onChange: props.onOpenChange,
defaultValue: false,
});
this.#props = props;
watch([() => this.open, () => this.#openReason], () => {
if (!this.open || typeof document === "undefined")
return;
return on(document, "mousemove", (e) => {
const contentEl = document.getElementById(this.ids.content);
const triggerEl = document.getElementById(this.ids.trigger);
if (!contentEl || !triggerEl) {
if (this.open)
this.#closeTooltip();
return;
}
const polygon = this.graceAreaPolygon;
// DEBUG PURPOSES ONLY.
// Draw the polygon on the screen
// const debugEl = document.createElement("div");
// debugEl.style.position = "fixed";
// debugEl.style.pointerEvents = "none";
// debugEl.style.zIndex = "9999";
// debugEl.style.backgroundColor = "rgba(255, 0, 0, 0.2)";
// debugEl.style.border = "1px solid red";
//
// const points = polygon.map((p) => `${p.x}px ${p.y}px`).join(", ");
// debugEl.style.clipPath = `polygon(${points})`;
//
// debugEl.style.width = "100vw";
// debugEl.style.height = "100vh";
// debugEl.style.left = "0";
// debugEl.style.top = "0";
//
// document.body.appendChild(debugEl);
//
// // Clean up previous debug element if any
// const prevDebug = document.querySelector("[data-melt-tooltip-debug]");
// if (prevDebug) prevDebug.remove();
//
// debugEl.setAttribute("data-melt-tooltip-debug", "");
// DEBUG END
this.#isMouseInTooltipArea =
this.#isPointerInsideContent ||
this.#isPointerInsideTrigger ||
isPointerInGraceArea(e, polygon);
if (this.#openReason !== "pointer")
return;
if (!this.#isMouseInTooltipArea) {
this.#closeTooltip();
}
});
});
}
get open() {
return this.#open.current;
}
set open(value) {
this.#open.current = value;
}
get #sharedProps() {
return {
onfocusout: async () => {
await new Promise((r) => setTimeout(r)); // tick
const contentEl = document.getElementById(this.ids.content);
const triggerEl = document.getElementById(this.ids.trigger);
if (contentEl?.contains(document.activeElement) ||
triggerEl?.contains(document.activeElement)) {
return;
}
this.open = false;
},
style: styleAttr({
"--melt-invoker-width": `${this.invokerRect?.width ?? 0}px`,
"--melt-invoker-height": `${this.invokerRect?.height ?? 0}px`,
"--melt-invoker-x": `${this.invokerRect?.x ?? 0}px`,
"--melt-invoker-y": `${this.invokerRect?.y ?? 0}px`,
}),
};
}
get trigger() {
return {
[dataAttrs.trigger]: "",
id: this.ids.trigger,
"aria-describedby": this.ids.content,
"data-open": dataAttr(this.open),
onpointerdown: () => {
if (!this.closeOnPointerDown)
return;
this.open = false;
this.#clickedTrigger = true;
this.#stopOpening();
},
onpointerenter: (e) => {
this.#isPointerInsideTrigger = true;
if (e.pointerType === "touch")
return;
this.#openTooltip("pointer");
},
onpointermove: () => {
this.#isPointerInsideTrigger = true;
},
onpointerleave: (e) => {
this.#isPointerInsideTrigger = false;
if (e.pointerType === "touch")
return;
this.#stopOpening();
},
onfocus: () => {
if (this.#clickedTrigger)
return;
this.#openTooltip("focus");
},
onblur: () => this.#closeTooltip(true),
[createAttachmentKey()]: () => {
const el = document.getElementById(this.ids.content);
if (!isHtmlElement(el))
return;
return () => (this.#isPointerInsideTrigger = false);
},
...this.#sharedProps,
};
}
get content() {
return {
[dataAttrs.content]: "",
id: this.ids.content,
popover: "manual",
role: "tooltip",
tabindex: -1,
inert: !this.open,
"data-open": dataAttr(this.open),
onpointerenter: () => {
this.#isPointerInsideContent = true;
this.#openTooltip("pointer");
},
onpointerleave: () => {
this.#isPointerInsideContent = false;
},
onpointerdown: () => this.#openTooltip("pointer"),
[createAttachmentKey()]: () => {
$effect(() => {
const triggerEl = document.getElementById(this.ids.trigger);
const contentEl = document.getElementById(this.ids.content);
if (!triggerEl || !contentEl || !this.open)
return;
useFloating({
node: () => triggerEl,
floating: () => contentEl,
config: {
...this.floatingConfig,
onCompute: ({ floatingApply, arrowApply, ...data }) => {
this.#floatingData = data;
if (this.floatingConfig?.onCompute) {
this.floatingConfig.onCompute({ floatingApply, arrowApply, ...data });
}
else {
floatingApply();
arrowApply();
}
},
},
});
});
$effect(() => {
const triggerEl = document.getElementById(this.ids.trigger);
const contentEl = document.getElementById(this.ids.content);
if (!triggerEl || !contentEl)
return;
if (!this.isVisible) {
safelyHidePopover(contentEl);
return () => (this.#isPointerInsideContent = false);
}
return autoOpenPopover({ el: contentEl });
});
useEventListener(() => document, "scroll", (e) => this.#handleScroll(e), { capture: true });
useEventListener(() => document, "keydown", (e) => {
const el = document.getElementById(this.ids.content);
if (e.key !== "Escape" || !this.open || !el)
return;
e.preventDefault();
const openTooltips = [...el.querySelectorAll("[popover]")].filter((child) => {
if (!isHtmlElement(child))
return false;
// If child is a Melt popover, check if it's open
if (child.matches(dataSelectors.content))
return child.dataset.open !== undefined;
return child.matches(":popover-open");
});
if (openTooltips.length)
return;
this.#stopOpening();
setTimeout(() => (this.open = false));
});
},
...this.#sharedProps,
style: this.#sharedProps.style + `overflow: visible;`,
};
}
get arrow() {
return {
[dataAttrs.arrow]: "",
id: this.ids.arrow,
"data-arrow": "",
"aria-hidden": true,
"data-open": dataAttr(this.open),
};
}
#openTooltip(reason) {
if (this.#closeTimeout) {
window.clearTimeout(this.#closeTimeout);
this.#closeTimeout = null;
}
if (!this.#openTimeout) {
this.#openTimeout = window.setTimeout(() => {
this.open = true;
this.#openReason = this.#openReason ?? reason;
this.#openTimeout = null;
}, this.openDelay);
}
}
#stopOpening() {
if (this.#openTimeout) {
window.clearTimeout(this.#openTimeout);
this.#openTimeout = null;
}
}
#closeTooltip(isBlur) {
const contentEl = document.getElementById(this.ids.content);
if (!isHtmlElement(contentEl))
return;
this.#stopOpening();
if (isBlur && this.#isMouseInTooltipArea) {
this.#openReason = "pointer";
return;
}
if (!this.#closeTimeout) {
this.#closeTimeout = window.setTimeout(() => {
this.open = false;
this.#openReason = null;
if (isBlur)
this.#clickedTrigger = false;
this.#closeTimeout = null;
}, this.closeDelay);
}
}
#handleScroll(e) {
if (!this.open)
return;
const target = e.target;
if (!(target instanceof Element) && !(target instanceof Document))
return;
const triggerEl = document.getElementById(this.ids.trigger);
if ((triggerEl && target.contains(triggerEl)) || this.open) {
this.#closeTooltip();
}
}
}