UNPKG

bits-ui

Version:

The headless components for Svelte.

209 lines (208 loc) 7.87 kB
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); }