floating-ui.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. /*!
  2. * All material copyright ESRI, All Rights Reserved, unless otherwise specified.
  3. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
  4. * v1.0.0-beta.97
  5. */
  6. import { arrow, autoPlacement, autoUpdate, computePosition, flip, hide, offset, platform, shift } from "@floating-ui/dom";
  7. import { closestElementCrossShadowBoundary, getElementDir } from "./dom";
  8. import { debounce } from "lodash-es";
  9. import { Build } from "@stencil/core";
  10. import { config } from "./config";
  11. const floatingUIBrowserCheck = patchFloatingUiForNonChromiumBrowsers();
  12. async function patchFloatingUiForNonChromiumBrowsers() {
  13. function getUAString() {
  14. const uaData = navigator.userAgentData;
  15. if (uaData === null || uaData === void 0 ? void 0 : uaData.brands) {
  16. return uaData.brands.map((item) => `${item.brand}/${item.version}`).join(" ");
  17. }
  18. return navigator.userAgent;
  19. }
  20. if (Build.isBrowser &&
  21. config.floatingUINonChromiumPositioningFix &&
  22. // ⚠️ browser-sniffing is not a best practice and should be avoided ⚠️
  23. /firefox|safari/i.test(getUAString())) {
  24. const { getClippingRect, getElementRects, getOffsetParent } = await import("./floating-ui/nonChromiumPlatformUtils");
  25. platform.getClippingRect = getClippingRect;
  26. platform.getOffsetParent = getOffsetParent;
  27. platform.getElementRects = getElementRects;
  28. }
  29. }
  30. const placementDataAttribute = "data-placement";
  31. /**
  32. * Exported for testing purposes only
  33. */
  34. export const repositionDebounceTimeout = 100;
  35. export const placements = [
  36. "auto",
  37. "auto-start",
  38. "auto-end",
  39. "top",
  40. "top-start",
  41. "top-end",
  42. "bottom",
  43. "bottom-start",
  44. "bottom-end",
  45. "right",
  46. "right-start",
  47. "right-end",
  48. "left",
  49. "left-start",
  50. "left-end",
  51. "leading-start",
  52. "leading",
  53. "leading-end",
  54. "trailing-end",
  55. "trailing",
  56. "trailing-start"
  57. ];
  58. export const effectivePlacements = [
  59. "top",
  60. "bottom",
  61. "right",
  62. "left",
  63. "top-start",
  64. "top-end",
  65. "bottom-start",
  66. "bottom-end",
  67. "right-start",
  68. "right-end",
  69. "left-start",
  70. "left-end"
  71. ];
  72. export const menuPlacements = ["top-start", "top", "top-end", "bottom-start", "bottom", "bottom-end"];
  73. export const menuEffectivePlacements = [
  74. "top-start",
  75. "top",
  76. "top-end",
  77. "bottom-start",
  78. "bottom",
  79. "bottom-end"
  80. ];
  81. export const flipPlacements = [
  82. "top",
  83. "bottom",
  84. "right",
  85. "left",
  86. "top-start",
  87. "top-end",
  88. "bottom-start",
  89. "bottom-end",
  90. "right-start",
  91. "right-end",
  92. "left-start",
  93. "left-end"
  94. ];
  95. export const defaultMenuPlacement = "bottom-start";
  96. export const FloatingCSS = {
  97. animation: "calcite-floating-ui-anim",
  98. animationActive: "calcite-floating-ui-anim--active"
  99. };
  100. function getMiddleware({ placement, disableFlip, flipPlacements, offsetDistance, offsetSkidding, arrowEl, type }) {
  101. const defaultMiddleware = [shift(), hide()];
  102. if (type === "menu") {
  103. return [
  104. ...defaultMiddleware,
  105. flip({
  106. fallbackPlacements: flipPlacements || ["top-start", "top", "top-end", "bottom-start", "bottom", "bottom-end"]
  107. })
  108. ];
  109. }
  110. if (type === "popover" || type === "tooltip") {
  111. const middleware = [
  112. ...defaultMiddleware,
  113. offset({
  114. mainAxis: typeof offsetDistance === "number" ? offsetDistance : 0,
  115. crossAxis: typeof offsetSkidding === "number" ? offsetSkidding : 0
  116. })
  117. ];
  118. if (placement === "auto" || placement === "auto-start" || placement === "auto-end") {
  119. middleware.push(autoPlacement({ alignment: placement === "auto-start" ? "start" : placement === "auto-end" ? "end" : null }));
  120. }
  121. else if (!disableFlip) {
  122. middleware.push(flip(flipPlacements ? { fallbackPlacements: flipPlacements } : {}));
  123. }
  124. if (arrowEl) {
  125. middleware.push(arrow({
  126. element: arrowEl
  127. }));
  128. }
  129. return middleware;
  130. }
  131. return [];
  132. }
  133. export function filterComputedPlacements(placements, el) {
  134. const filteredPlacements = placements.filter((placement) => effectivePlacements.includes(placement));
  135. if (filteredPlacements.length !== placements.length) {
  136. console.warn(`${el.tagName}: Invalid value found in: flipPlacements. Try any of these: ${effectivePlacements
  137. .map((placement) => `"${placement}"`)
  138. .join(", ")
  139. .trim()}`, { el });
  140. }
  141. return filteredPlacements;
  142. }
  143. /*
  144. In floating-ui, "*-start" and "*-end" are already flipped in RTL.
  145. There is no need for our "*-leading" and "*-trailing" values anymore.
  146. https://github.com/floating-ui/floating-ui/issues/1530
  147. https://github.com/floating-ui/floating-ui/issues/1563
  148. */
  149. export function getEffectivePlacement(floatingEl, placement) {
  150. const placements = ["left", "right"];
  151. if (getElementDir(floatingEl) === "rtl") {
  152. placements.reverse();
  153. }
  154. return placement
  155. .replace(/-leading/gi, "-start")
  156. .replace(/-trailing/gi, "-end")
  157. .replace(/leading/gi, placements[0])
  158. .replace(/trailing/gi, placements[1]);
  159. }
  160. /**
  161. * Convenience function to manage `reposition` calls for FloatingUIComponents that use `positionFloatingUI.
  162. *
  163. * Note: this is not needed for components that use `calcite-popover`.
  164. *
  165. * @param component
  166. * @param options
  167. * @param options.referenceEl
  168. * @param options.floatingEl
  169. * @param options.overlayPositioning
  170. * @param options.placement
  171. * @param options.disableFlip
  172. * @param options.flipPlacements
  173. * @param options.offsetDistance
  174. * @param options.offsetSkidding
  175. * @param options.arrowEl
  176. * @param options.type
  177. * @param delayed
  178. */
  179. export async function reposition(component, options, delayed = false) {
  180. if (!component.open) {
  181. return;
  182. }
  183. return delayed ? debouncedReposition(options) : positionFloatingUI(options);
  184. }
  185. const debouncedReposition = debounce(positionFloatingUI, repositionDebounceTimeout, {
  186. leading: true,
  187. maxWait: repositionDebounceTimeout
  188. });
  189. /**
  190. * Positions the floating element relative to the reference element.
  191. *
  192. * **Note:** exported for testing purposes only
  193. *
  194. * @param root0
  195. * @param root0.referenceEl
  196. * @param root0.floatingEl
  197. * @param root0.overlayPositioning
  198. * @param root0.placement
  199. * @param root0.disableFlip
  200. * @param root0.flipPlacements
  201. * @param root0.offsetDistance
  202. * @param root0.offsetSkidding
  203. * @param root0.arrowEl
  204. * @param root0.type
  205. * @param root0.includeArrow
  206. */
  207. export async function positionFloatingUI({ referenceEl, floatingEl, overlayPositioning = "absolute", placement, disableFlip, flipPlacements, offsetDistance, offsetSkidding, includeArrow = false, arrowEl, type }) {
  208. var _a;
  209. if (!referenceEl || !floatingEl || (includeArrow && !arrowEl)) {
  210. return null;
  211. }
  212. await floatingUIBrowserCheck;
  213. const { x, y, placement: effectivePlacement, strategy: position, middlewareData } = await computePosition(referenceEl, floatingEl, {
  214. strategy: overlayPositioning,
  215. placement: placement === "auto" || placement === "auto-start" || placement === "auto-end"
  216. ? undefined
  217. : getEffectivePlacement(floatingEl, placement),
  218. middleware: getMiddleware({
  219. placement,
  220. disableFlip,
  221. flipPlacements,
  222. offsetDistance,
  223. offsetSkidding,
  224. arrowEl,
  225. type
  226. })
  227. });
  228. if (middlewareData === null || middlewareData === void 0 ? void 0 : middlewareData.arrow) {
  229. const { x: arrowX, y: arrowY } = middlewareData.arrow;
  230. Object.assign(arrowEl.style, {
  231. left: arrowX != null ? `${arrowX}px` : "",
  232. top: arrowY != null ? `${arrowY}px` : ""
  233. });
  234. }
  235. const referenceHidden = (_a = middlewareData === null || middlewareData === void 0 ? void 0 : middlewareData.hide) === null || _a === void 0 ? void 0 : _a.referenceHidden;
  236. const visibility = referenceHidden ? "hidden" : null;
  237. const pointerEvents = visibility ? "none" : null;
  238. floatingEl.setAttribute(placementDataAttribute, effectivePlacement);
  239. const transform = `translate(${Math.round(x)}px,${Math.round(y)}px)`;
  240. Object.assign(floatingEl.style, {
  241. visibility,
  242. pointerEvents,
  243. position,
  244. top: "0",
  245. left: "0",
  246. transform
  247. });
  248. }
  249. /**
  250. * Exported for testing purposes only
  251. *
  252. * @internal
  253. */
  254. export const cleanupMap = new WeakMap();
  255. /**
  256. * Helper to set up floating element interactions on connectedCallback.
  257. *
  258. * @param component
  259. * @param referenceEl
  260. * @param floatingEl
  261. */
  262. export function connectFloatingUI(component, referenceEl, floatingEl) {
  263. if (!floatingEl || !referenceEl) {
  264. return;
  265. }
  266. disconnectFloatingUI(component, referenceEl, floatingEl);
  267. const position = component.overlayPositioning;
  268. // ensure position matches for initial positioning
  269. floatingEl.style.position = position;
  270. if (position === "absolute") {
  271. moveOffScreen(floatingEl);
  272. }
  273. const runAutoUpdate = Build.isBrowser
  274. ? autoUpdate
  275. : (_refEl, _floatingEl, updateCallback) => {
  276. updateCallback();
  277. return () => {
  278. /* noop */
  279. };
  280. };
  281. cleanupMap.set(component, runAutoUpdate(referenceEl, floatingEl, () => component.reposition()));
  282. }
  283. /**
  284. * Helper to tear down floating element interactions on disconnectedCallback.
  285. *
  286. * @param component
  287. * @param referenceEl
  288. * @param floatingEl
  289. */
  290. export function disconnectFloatingUI(component, referenceEl, floatingEl) {
  291. if (!floatingEl || !referenceEl) {
  292. return;
  293. }
  294. getTransitionTarget(floatingEl).removeEventListener("transitionend", handleTransitionElTransitionEnd);
  295. const cleanup = cleanupMap.get(component);
  296. if (cleanup) {
  297. cleanup();
  298. }
  299. cleanupMap.delete(component);
  300. }
  301. const visiblePointerSize = 4;
  302. /**
  303. * Default offset the position of the floating element away from the reference element.
  304. *
  305. * @default 6
  306. */
  307. export const defaultOffsetDistance = Math.ceil(Math.hypot(visiblePointerSize, visiblePointerSize));
  308. /**
  309. * This utils applies floating element styles to avoid affecting layout when closed.
  310. *
  311. * This should be called when the closing transition will start.
  312. *
  313. * @param floatingEl
  314. */
  315. export function updateAfterClose(floatingEl) {
  316. if (!floatingEl || floatingEl.style.position !== "absolute") {
  317. return;
  318. }
  319. getTransitionTarget(floatingEl).addEventListener("transitionend", handleTransitionElTransitionEnd);
  320. }
  321. function getTransitionTarget(floatingEl) {
  322. // assumes floatingEl w/ shadowRoot is a FloatingUIComponent
  323. return floatingEl.shadowRoot || floatingEl;
  324. }
  325. function handleTransitionElTransitionEnd(event) {
  326. const floatingTransitionEl = event.target;
  327. if (
  328. // using any prop from floating-ui transition
  329. event.propertyName === "opacity" &&
  330. floatingTransitionEl.classList.contains(FloatingCSS.animation)) {
  331. const floatingEl = getFloatingElFromTransitionTarget(floatingTransitionEl);
  332. moveOffScreen(floatingEl);
  333. getTransitionTarget(floatingEl).removeEventListener("transitionend", handleTransitionElTransitionEnd);
  334. }
  335. }
  336. function moveOffScreen(floatingEl) {
  337. floatingEl.style.transform = "";
  338. floatingEl.style.top = "-99999px";
  339. floatingEl.style.left = "-99999px";
  340. }
  341. function getFloatingElFromTransitionTarget(floatingTransitionEl) {
  342. return closestElementCrossShadowBoundary(floatingTransitionEl, `[${placementDataAttribute}]`);
  343. }