bits-ui
Version:
The headless components for Svelte.
209 lines (208 loc) • 7.87 kB
JavaScript
import { executeCallbacks, getDocument, getWindow, } from "svelte-toolbelt";
import { on } from "svelte/events";
import { watch } from "runed";
import { boxAutoReset } from "./box-auto-reset.svelte.js";
import { isElement, isHTMLElement } from "./is.js";
export class GraceArea {
#opts;
#enabled;
#isPointerInTransit;
#pointerGraceArea = $state(null);
constructor(opts) {
this.#opts = opts;
this.#enabled = $derived(this.#opts.enabled());
this.#isPointerInTransit = boxAutoReset(false, {
afterMs: opts.transitTimeout ?? 300,
onChange: (value) => {
if (!this.#enabled)
return;
this.#opts.setIsPointerInTransit?.(value);
},
getWindow: () => getWindow(this.#opts.triggerNode()),
});
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
if (!triggerNode || !contentNode || !enabled)
return;
const handleTriggerLeave = (e) => {
this.#createGraceArea(e, contentNode);
};
const handleContentLeave = (e) => {
this.#createGraceArea(e, triggerNode);
};
return executeCallbacks(on(triggerNode, "pointerleave", handleTriggerLeave), on(contentNode, "pointerleave", handleContentLeave));
});
watch(() => this.#pointerGraceArea, () => {
const handleTrackPointerGrace = (e) => {
if (!this.#pointerGraceArea)
return;
const target = e.target;
if (!isElement(target))
return;
const pointerPosition = { x: e.clientX, y: e.clientY };
const hasEnteredTarget = opts.triggerNode()?.contains(target) ||
opts.contentNode()?.contains(target);
const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, this.#pointerGraceArea);
if (hasEnteredTarget) {
this.#removeGraceArea();
}
else if (isPointerOutsideGraceArea) {
this.#removeGraceArea();
opts.onPointerExit();
}
};
const doc = getDocument(opts.triggerNode() ?? opts.contentNode());
if (!doc)
return;
return on(doc, "pointermove", handleTrackPointerGrace);
});
}
#removeGraceArea() {
this.#pointerGraceArea = null;
this.#isPointerInTransit.current = false;
}
#createGraceArea(e, hoverTarget) {
const currentTarget = e.currentTarget;
if (!isHTMLElement(currentTarget))
return;
const exitPoint = { x: e.clientX, y: e.clientY };
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect());
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide);
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect());
const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]);
this.#pointerGraceArea = graceArea;
this.#isPointerInTransit.current = true;
}
}
function getExitSideFromRect(point, rect) {
const top = Math.abs(rect.top - point.y);
const bottom = Math.abs(rect.bottom - point.y);
const right = Math.abs(rect.right - point.x);
const left = Math.abs(rect.left - point.x);
switch (Math.min(top, bottom, right, left)) {
case left:
return "left";
case right:
return "right";
case top:
return "top";
case bottom:
return "bottom";
default:
throw new Error("unreachable");
}
}
function getPaddedExitPoints(exitPoint, exitSide, padding = 5) {
// we extend the tip of the exit point to make it easier to navigate without
// a minor jitter triggering a pointer exit
const tipPadding = padding * 1.5;
switch (exitSide) {
case "top":
return [
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
{ x: exitPoint.x, y: exitPoint.y - tipPadding },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
];
case "bottom":
return [
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x, y: exitPoint.y + tipPadding },
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
];
case "left":
return [
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
{ x: exitPoint.x - tipPadding, y: exitPoint.y },
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
];
case "right":
return [
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
{ x: exitPoint.x + tipPadding, y: exitPoint.y },
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
];
}
}
function getPointsFromRect(rect) {
const { top, right, bottom, left } = rect;
return [
{ x: left, y: top },
{ x: right, y: top },
{ x: right, y: bottom },
{ x: left, y: bottom },
];
}
// Determine if a point is inside of a polygon.
// Based on https://github.com/substack/point-in-polygon
function isPointInPolygon(point, polygon) {
const { x, y } = point;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
// prettier-ignore
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect)
inside = !inside;
}
return inside;
}
// Returns a new array of points representing the convex hull of the given set of points.
// https://www.nayuki.io/page/convex-hull-algorithm
function getHull(points) {
const newPoints = points.slice();
newPoints.sort((a, b) => {
if (a.x < b.x)
return -1;
else if (a.x > b.x)
return +1;
else if (a.y < b.y)
return -1;
else if (a.y > b.y)
return +1;
else
return 0;
});
return getHullPresorted(newPoints);
}
// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
function getHullPresorted(points) {
if (points.length <= 1)
return points.slice();
const upperHull = [];
for (let i = 0; i < points.length; i++) {
const p = points[i];
while (upperHull.length >= 2) {
const q = upperHull[upperHull.length - 1];
const r = upperHull[upperHull.length - 2];
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
upperHull.pop();
else
break;
}
upperHull.push(p);
}
upperHull.pop();
const lowerHull = [];
for (let i = points.length - 1; i >= 0; i--) {
const p = points[i];
while (lowerHull.length >= 2) {
const q = lowerHull[lowerHull.length - 1];
const r = lowerHull[lowerHull.length - 2];
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
lowerHull.pop();
else
break;
}
lowerHull.push(p);
}
lowerHull.pop();
if (upperHull.length === 1 &&
lowerHull.length === 1 &&
upperHull[0].x === lowerHull[0].x &&
upperHull[0].y === lowerHull[0].y)
return upperHull;
else
return upperHull.concat(lowerHull);
}