@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
353 lines (352 loc) • 11 kB
JavaScript
/*!
* 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));