UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

248 lines (241 loc) 11.2 kB
import { isElement } from '@floating-ui/utils/dom'; import { Timeout } from '@base-ui/utils/useTimeout'; import { contains, getTarget } from "./utils/element.js"; import { getNodeChildren } from "./utils/nodes.js"; /* eslint-disable no-nested-ternary */ const CURSOR_SPEED_THRESHOLD = 0.1; const CURSOR_SPEED_THRESHOLD_SQUARED = CURSOR_SPEED_THRESHOLD * CURSOR_SPEED_THRESHOLD; const POLYGON_BUFFER = 0.5; function hasIntersectingEdge(pointX, pointY, xi, yi, xj, yj) { return yi >= pointY !== yj >= pointY && pointX <= (xj - xi) * (pointY - yi) / (yj - yi) + xi; } function isPointInQuadrilateral(pointX, pointY, x1, y1, x2, y2, x3, y3, x4, y4) { let isInsideValue = false; if (hasIntersectingEdge(pointX, pointY, x1, y1, x2, y2)) { isInsideValue = !isInsideValue; } if (hasIntersectingEdge(pointX, pointY, x2, y2, x3, y3)) { isInsideValue = !isInsideValue; } if (hasIntersectingEdge(pointX, pointY, x3, y3, x4, y4)) { isInsideValue = !isInsideValue; } if (hasIntersectingEdge(pointX, pointY, x4, y4, x1, y1)) { isInsideValue = !isInsideValue; } return isInsideValue; } function isInsideRect(pointX, pointY, rect) { return pointX >= rect.x && pointX <= rect.x + rect.width && pointY >= rect.y && pointY <= rect.y + rect.height; } function isInsideAxisAlignedRect(pointX, pointY, x1, y1, x2, y2) { const minX = Math.min(x1, x2); const maxX = Math.max(x1, x2); const minY = Math.min(y1, y2); const maxY = Math.max(y1, y2); return pointX >= minX && pointX <= maxX && pointY >= minY && pointY <= maxY; } /** * Generates a safe polygon area that the user can traverse without closing the * floating element once leaving the reference element. * @see https://floating-ui.com/docs/useHover#safepolygon */ export function safePolygon(options = {}) { const { blockPointerEvents = false } = options; const timeout = new Timeout(); const fn = ({ x, y, placement, elements, onClose, nodeId, tree }) => { const side = placement?.split('-')[0]; let hasLanded = false; let lastX = null; let lastY = null; let lastCursorTime = typeof performance !== 'undefined' ? performance.now() : 0; function isCursorMovingSlowly(nextX, nextY) { const currentTime = performance.now(); const elapsedTime = currentTime - lastCursorTime; if (lastX === null || lastY === null || elapsedTime === 0) { lastX = nextX; lastY = nextY; lastCursorTime = currentTime; return false; } const deltaX = nextX - lastX; const deltaY = nextY - lastY; const distanceSquared = deltaX * deltaX + deltaY * deltaY; const thresholdSquared = elapsedTime * elapsedTime * CURSOR_SPEED_THRESHOLD_SQUARED; lastX = nextX; lastY = nextY; lastCursorTime = currentTime; return distanceSquared < thresholdSquared; } function close() { timeout.clear(); onClose(); } return function onMouseMove(event) { timeout.clear(); const domReference = elements.domReference; const floating = elements.floating; if (!domReference || !floating || side == null || x == null || y == null) { return undefined; } const { clientX, clientY } = event; const target = getTarget(event); const isLeave = event.type === 'mouseleave'; const isOverFloatingEl = contains(floating, target); const isOverReferenceEl = contains(domReference, target); if (isOverFloatingEl) { hasLanded = true; if (!isLeave) { return undefined; } } if (isOverReferenceEl) { hasLanded = false; if (!isLeave) { hasLanded = true; return undefined; } } // Prevent overlapping floating element from being stuck in an open-close // loop: https://github.com/floating-ui/floating-ui/issues/1910 if (isLeave && isElement(event.relatedTarget) && contains(floating, event.relatedTarget)) { return undefined; } function hasOpenChildNode() { return Boolean(tree && getNodeChildren(tree.nodesRef.current, nodeId).length > 0); } function closeIfNoOpenChild() { if (!hasOpenChildNode()) { close(); } } // If any nested child is open, abort. if (hasOpenChildNode()) { return undefined; } const refRect = domReference.getBoundingClientRect(); const rect = floating.getBoundingClientRect(); const cursorLeaveFromRight = x > rect.right - rect.width / 2; const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2; const isFloatingWider = rect.width > refRect.width; const isFloatingTaller = rect.height > refRect.height; const left = (isFloatingWider ? refRect : rect).left; const right = (isFloatingWider ? refRect : rect).right; const top = (isFloatingTaller ? refRect : rect).top; const bottom = (isFloatingTaller ? refRect : rect).bottom; // If the pointer is leaving from the opposite side, the "buffer" logic // creates a point where the floating element remains open, but should be // ignored. // A constant of 1 handles floating point rounding errors. if (side === 'top' && y >= refRect.bottom - 1 || side === 'bottom' && y <= refRect.top + 1 || side === 'left' && x >= refRect.right - 1 || side === 'right' && x <= refRect.left + 1) { closeIfNoOpenChild(); return undefined; } // Ignore when the cursor is within the rectangular trough between the // two elements. Since the triangle is created from the cursor point, // which can start beyond the ref element's edge, traversing back and // forth from the ref to the floating element can cause it to close. This // ensures it always remains open in that case. let isInsideTroughRect = false; switch (side) { case 'top': isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, left, refRect.top + 1, right, rect.bottom - 1); break; case 'bottom': isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, left, rect.top + 1, right, refRect.bottom - 1); break; case 'left': isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, rect.right - 1, bottom, refRect.left + 1, top); break; case 'right': isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, refRect.right - 1, bottom, rect.left + 1, top); break; default: } if (isInsideTroughRect) { return undefined; } if (hasLanded && !isInsideRect(clientX, clientY, refRect)) { closeIfNoOpenChild(); return undefined; } if (!isLeave && isCursorMovingSlowly(clientX, clientY)) { closeIfNoOpenChild(); return undefined; } let isInsidePolygon = false; switch (side) { case 'top': { const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; const cursorPointOneX = isFloatingWider ? x + cursorXOffset : cursorLeaveFromRight ? x + cursorXOffset : x - cursorXOffset; const cursorPointTwoX = isFloatingWider ? x - cursorXOffset : cursorLeaveFromRight ? x + cursorXOffset : x - cursorXOffset; const cursorPointY = y + POLYGON_BUFFER + 1; const commonYLeft = cursorLeaveFromRight ? rect.bottom - POLYGON_BUFFER : isFloatingWider ? rect.bottom - POLYGON_BUFFER : rect.top; const commonYRight = cursorLeaveFromRight ? isFloatingWider ? rect.bottom - POLYGON_BUFFER : rect.top : rect.bottom - POLYGON_BUFFER; isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointOneX, cursorPointY, cursorPointTwoX, cursorPointY, rect.left, commonYLeft, rect.right, commonYRight); break; } case 'bottom': { const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; const cursorPointOneX = isFloatingWider ? x + cursorXOffset : cursorLeaveFromRight ? x + cursorXOffset : x - cursorXOffset; const cursorPointTwoX = isFloatingWider ? x - cursorXOffset : cursorLeaveFromRight ? x + cursorXOffset : x - cursorXOffset; const cursorPointY = y - POLYGON_BUFFER; const commonYLeft = cursorLeaveFromRight ? rect.top + POLYGON_BUFFER : isFloatingWider ? rect.top + POLYGON_BUFFER : rect.bottom; const commonYRight = cursorLeaveFromRight ? isFloatingWider ? rect.top + POLYGON_BUFFER : rect.bottom : rect.top + POLYGON_BUFFER; isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointOneX, cursorPointY, cursorPointTwoX, cursorPointY, rect.left, commonYLeft, rect.right, commonYRight); break; } case 'left': { const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; const cursorPointOneY = isFloatingTaller ? y + cursorYOffset : cursorLeaveFromBottom ? y + cursorYOffset : y - cursorYOffset; const cursorPointTwoY = isFloatingTaller ? y - cursorYOffset : cursorLeaveFromBottom ? y + cursorYOffset : y - cursorYOffset; const cursorPointX = x + POLYGON_BUFFER + 1; const commonXTop = cursorLeaveFromBottom ? rect.right - POLYGON_BUFFER : isFloatingTaller ? rect.right - POLYGON_BUFFER : rect.left; const commonXBottom = cursorLeaveFromBottom ? isFloatingTaller ? rect.right - POLYGON_BUFFER : rect.left : rect.right - POLYGON_BUFFER; isInsidePolygon = isPointInQuadrilateral(clientX, clientY, commonXTop, rect.top, commonXBottom, rect.bottom, cursorPointX, cursorPointOneY, cursorPointX, cursorPointTwoY); break; } case 'right': { const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4; const cursorPointOneY = isFloatingTaller ? y + cursorYOffset : cursorLeaveFromBottom ? y + cursorYOffset : y - cursorYOffset; const cursorPointTwoY = isFloatingTaller ? y - cursorYOffset : cursorLeaveFromBottom ? y + cursorYOffset : y - cursorYOffset; const cursorPointX = x - POLYGON_BUFFER; const commonXTop = cursorLeaveFromBottom ? rect.left + POLYGON_BUFFER : isFloatingTaller ? rect.left + POLYGON_BUFFER : rect.right; const commonXBottom = cursorLeaveFromBottom ? isFloatingTaller ? rect.left + POLYGON_BUFFER : rect.right : rect.left + POLYGON_BUFFER; isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointX, cursorPointOneY, cursorPointX, cursorPointTwoY, commonXTop, rect.top, commonXBottom, rect.bottom); break; } default: } if (!isInsidePolygon) { closeIfNoOpenChild(); } else if (!hasLanded) { timeout.start(40, closeIfNoOpenChild); } return undefined; }; }; // eslint-disable-next-line no-underscore-dangle fn.__options = { ...options, blockPointerEvents }; return fn; }