UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

353 lines (352 loc) 11 kB
/*! * All material copyright ESRI, All Rights Reserved, unless otherwise specified. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details. * v1.5.0-next.4 */ import { arrow, autoPlacement, autoUpdate, computePosition, flip, hide, offset, platform, shift } from "@floating-ui/dom"; import { Build } from "@stencil/core"; import { debounce } from "lodash-es"; import { config } from "./config"; import { getElementDir } from "./dom"; import { getUserAgentData, getUserAgentString } from "./browser"; const floatingUIBrowserCheck = patchFloatingUiForNonChromiumBrowsers(); function isChrome109OrAbove() { const uaData = getUserAgentData(); if (uaData?.brands) { return !!uaData.brands.find(({ brand, version }) => (brand === "Google Chrome" || brand === "Chromium") && Number(version) >= 109); } return !!navigator.userAgent.split(" ").find((ua) => { const [browser, version] = ua.split("/"); return browser === "Chrome" && parseInt(version) >= 109; }); } async function patchFloatingUiForNonChromiumBrowsers() { if (Build.isBrowser && config.floatingUINonChromiumPositioningFix && // ⚠️ browser-sniffing is not a best practice and should be avoided ⚠️ (/firefox|safari/i.test(getUserAgentString()) || isChrome109OrAbove())) { const { offsetParent } = await import("composed-offset-position"); const originalGetOffsetParent = platform.getOffsetParent; platform.getOffsetParent = (element) => originalGetOffsetParent(element, offsetParent); } } /** * Positions the floating element relative to the reference element. * * **Note:** exported for testing purposes only * * @param root0 * @param root0.referenceEl * @param root0.floatingEl * @param root0.overlayPositioning * @param root0.placement * @param root0.flipDisabled * @param root0.flipPlacements * @param root0.offsetDistance * @param root0.offsetSkidding * @param root0.arrowEl * @param root0.type * @param component * @param root0.referenceEl.referenceEl * @param root0.referenceEl.floatingEl * @param root0.referenceEl.overlayPositioning * @param root0.referenceEl.placement * @param root0.referenceEl.flipDisabled * @param root0.referenceEl.flipPlacements * @param root0.referenceEl.offsetDistance * @param root0.referenceEl.offsetSkidding * @param root0.referenceEl.arrowEl * @param root0.referenceEl.type * @param component.referenceEl * @param component.floatingEl * @param component.overlayPositioning * @param component.placement * @param component.flipDisabled * @param component.flipPlacements * @param component.offsetDistance * @param component.offsetSkidding * @param component.arrowEl * @param component.type */ export const positionFloatingUI = /* we export arrow function to allow us to spy on it during testing */ async (component, { referenceEl, floatingEl, overlayPositioning = "absolute", placement, flipDisabled, flipPlacements, offsetDistance, offsetSkidding, arrowEl, type }) => { if (!referenceEl || !floatingEl) { return null; } await floatingUIBrowserCheck; const { x, y, placement: effectivePlacement, strategy: position, middlewareData } = await computePosition(referenceEl, floatingEl, { strategy: overlayPositioning, placement: placement === "auto" || placement === "auto-start" || placement === "auto-end" ? undefined : getEffectivePlacement(floatingEl, placement), middleware: getMiddleware({ placement, flipDisabled, flipPlacements, offsetDistance, offsetSkidding, arrowEl, type }) }); if (arrowEl && middlewareData.arrow) { const { x, y } = middlewareData.arrow; const side = effectivePlacement.split("-")[0]; const alignment = x != null ? "left" : "top"; const transform = ARROW_CSS_TRANSFORM[side]; const reset = { left: "", top: "", bottom: "", right: "" }; if ("floatingLayout" in component) { component.floatingLayout = side === "left" || side === "right" ? "horizontal" : "vertical"; } Object.assign(arrowEl.style, { ...reset, [alignment]: `${alignment == "left" ? x : y}px`, [side]: "100%", transform }); } const referenceHidden = middlewareData.hide?.referenceHidden; const visibility = referenceHidden ? "hidden" : null; const pointerEvents = visibility ? "none" : null; floatingEl.setAttribute(placementDataAttribute, effectivePlacement); const transform = `translate(${Math.round(x)}px,${Math.round(y)}px)`; Object.assign(floatingEl.style, { visibility, pointerEvents, position, top: "0", left: "0", transform }); }; /** * Exported for testing purposes only */ export const placementDataAttribute = "data-placement"; /** * Exported for testing purposes only */ export const repositionDebounceTimeout = 100; export const placements = [ // auto placements "auto", "auto-start", "auto-end", // placements "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "right", "right-start", "right-end", "left", "left-start", "left-end", // variation placements "leading-start", "leading", "leading-end", "trailing-end", "trailing", "trailing-start" ]; export const effectivePlacements = [ "top", "bottom", "right", "left", "top-start", "top-end", "bottom-start", "bottom-end", "right-start", "right-end", "left-start", "left-end" ]; export const menuPlacements = ["top-start", "top", "top-end", "bottom-start", "bottom", "bottom-end"]; export const menuEffectivePlacements = [ "top-start", "top", "top-end", "bottom-start", "bottom", "bottom-end" ]; export const flipPlacements = [ "top", "bottom", "right", "left", "top-start", "top-end", "bottom-start", "bottom-end", "right-start", "right-end", "left-start", "left-end" ]; export const defaultMenuPlacement = "bottom-start"; export const FloatingCSS = { animation: "calcite-floating-ui-anim", animationActive: "calcite-floating-ui-anim--active" }; function getMiddleware({ placement, flipDisabled, flipPlacements, offsetDistance, offsetSkidding, arrowEl, type }) { const defaultMiddleware = [shift(), hide()]; if (type === "menu") { return [ ...defaultMiddleware, flip({ fallbackPlacements: flipPlacements || ["top-start", "top", "top-end", "bottom-start", "bottom", "bottom-end"] }) ]; } if (type === "popover" || type === "tooltip") { const middleware = [ ...defaultMiddleware, offset({ mainAxis: typeof offsetDistance === "number" ? offsetDistance : 0, crossAxis: typeof offsetSkidding === "number" ? offsetSkidding : 0 }) ]; if (placement === "auto" || placement === "auto-start" || placement === "auto-end") { middleware.push(autoPlacement({ alignment: placement === "auto-start" ? "start" : placement === "auto-end" ? "end" : null })); } else if (!flipDisabled) { middleware.push(flip(flipPlacements ? { fallbackPlacements: flipPlacements } : {})); } if (arrowEl) { middleware.push(arrow({ element: arrowEl })); } return middleware; } return []; } export function filterComputedPlacements(placements, el) { const filteredPlacements = placements.filter((placement) => effectivePlacements.includes(placement)); if (filteredPlacements.length !== placements.length) { console.warn(`${el.tagName}: Invalid value found in: flipPlacements. Try any of these: ${effectivePlacements .map((placement) => `"${placement}"`) .join(", ") .trim()}`, { el }); } return filteredPlacements; } export function getEffectivePlacement(floatingEl, placement) { const placements = ["left", "right"]; if (getElementDir(floatingEl) === "rtl") { placements.reverse(); } return placement.replace(/leading/gi, placements[0]).replace(/trailing/gi, placements[1]); } /** * Convenience function to manage `reposition` calls for FloatingUIComponents that use `positionFloatingUI. * * Note: this is not needed for components that use `calcite-popover`. * * @param component * @param options * @param options.referenceEl * @param options.floatingEl * @param options.overlayPositioning * @param options.placement * @param options.flipDisabled * @param options.flipPlacements * @param options.offsetDistance * @param options.offsetSkidding * @param options.arrowEl * @param options.type * @param delayed */ export async function reposition(component, options, delayed = false) { if (!component.open) { return; } const positionFunction = delayed ? getDebouncedReposition(component) : positionFloatingUI; return positionFunction(component, options); } function getDebouncedReposition(component) { let debounced = componentToDebouncedRepositionMap.get(component); if (debounced) { return debounced; } debounced = debounce(positionFloatingUI, repositionDebounceTimeout, { leading: true, maxWait: repositionDebounceTimeout }); componentToDebouncedRepositionMap.set(component, debounced); return debounced; } const ARROW_CSS_TRANSFORM = { top: "", left: "rotate(-90deg)", bottom: "rotate(180deg)", right: "rotate(90deg)" }; /** * Exported for testing purposes only * * @internal */ export const cleanupMap = new WeakMap(); const componentToDebouncedRepositionMap = new WeakMap(); /** * Helper to set up floating element interactions on connectedCallback. * * @param component * @param referenceEl * @param floatingEl */ export function connectFloatingUI(component, referenceEl, floatingEl) { if (!floatingEl || !referenceEl) { return; } disconnectFloatingUI(component, referenceEl, floatingEl); Object.assign(floatingEl.style, { visibility: "hidden", pointerEvents: "none", // initial positioning based on https://floating-ui.com/docs/computePosition#initial-layout position: component.overlayPositioning, top: "0", left: "0" }); const runAutoUpdate = Build.isBrowser ? autoUpdate : (_refEl, _floatingEl, updateCallback) => { updateCallback(); return () => { /* noop */ }; }; cleanupMap.set(component, runAutoUpdate(referenceEl, floatingEl, () => component.reposition())); } /** * Helper to tear down floating element interactions on disconnectedCallback. * * @param component * @param referenceEl * @param floatingEl */ export function disconnectFloatingUI(component, referenceEl, floatingEl) { if (!floatingEl || !referenceEl) { return; } cleanupMap.get(component)?.(); cleanupMap.delete(component); componentToDebouncedRepositionMap.get(component)?.cancel(); componentToDebouncedRepositionMap.delete(component); } const visiblePointerSize = 4; /** * Default offset the position of the floating element away from the reference element. * * @default 6 */ export const defaultOffsetDistance = Math.ceil(Math.hypot(visiblePointerSize, visiblePointerSize));