popover.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. const defaultShowDelayMs = 200;
  2. const defaultHideDelayMs = 500;
  3. const defaultMaxWidth = "300px";
  4. const defaultDistanceFromTarget = "0px"
  5. const htmlContentSelector = "[data-popover-html]";
  6. let activeTarget = null;
  7. let pendingTarget = null;
  8. let cleanupOnHidePopover = null;
  9. let togglePopoverTimeout = null;
  10. const containerElement = document.createElement("div");
  11. const containerComputedStyle = getComputedStyle(containerElement);
  12. containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout);
  13. containerElement.addEventListener("mouseleave", handleMouseLeave);
  14. containerElement.classList.add("popover-container");
  15. const frameElement = document.createElement("div");
  16. frameElement.classList.add("popover-frame");
  17. const contentElement = document.createElement("div");
  18. contentElement.classList.add("popover-content");
  19. frameElement.append(contentElement);
  20. containerElement.append(frameElement);
  21. document.body.append(containerElement);
  22. const observer = new ResizeObserver(repositionContainer);
  23. function handleMouseEnter(event) {
  24. clearTogglePopoverTimeout();
  25. const target = event.target;
  26. pendingTarget = target;
  27. const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs;
  28. if (activeTarget !== null) {
  29. if (activeTarget !== target) {
  30. hidePopover();
  31. requestAnimationFrame(() => requestAnimationFrame(showPopover));
  32. }
  33. return;
  34. }
  35. togglePopoverTimeout = setTimeout(showPopover, showDelay);
  36. }
  37. function handleMouseLeave(event) {
  38. clearTogglePopoverTimeout();
  39. const target = activeTarget || event.target;
  40. togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs);
  41. }
  42. function clearTogglePopoverTimeout() {
  43. clearTimeout(togglePopoverTimeout);
  44. }
  45. function showPopover() {
  46. if (pendingTarget === null) return;
  47. activeTarget = pendingTarget;
  48. pendingTarget = null;
  49. const popoverType = activeTarget.dataset.popoverType;
  50. if (popoverType === "text") {
  51. const text = activeTarget.dataset.popoverText;
  52. if (text === undefined || text === "") return;
  53. contentElement.textContent = text;
  54. } else if (popoverType === "html") {
  55. const htmlContent = activeTarget.querySelector(htmlContentSelector);
  56. if (htmlContent === null) return;
  57. /**
  58. * The reason for all of the below shenanigans is that I want to preserve
  59. * all attached event listeners of the original HTML content. This is so I don't have to
  60. * re-setup events for things like lazy images, they'd just work as expected.
  61. */
  62. const placeholder = document.createComment("");
  63. htmlContent.replaceWith(placeholder);
  64. contentElement.replaceChildren(htmlContent);
  65. htmlContent.removeAttribute("data-popover-html");
  66. cleanupOnHidePopover = () => {
  67. htmlContent.setAttribute("data-popover-html", "");
  68. placeholder.replaceWith(htmlContent);
  69. placeholder.remove();
  70. };
  71. } else {
  72. return;
  73. }
  74. const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth;
  75. if (activeTarget.dataset.popoverTextAlign !== undefined) {
  76. contentElement.style.textAlign = activeTarget.dataset.popoverTextAlign;
  77. } else {
  78. contentElement.style.removeProperty("text-align");
  79. }
  80. contentElement.style.maxWidth = contentMaxWidth;
  81. containerElement.style.display = "block";
  82. activeTarget.classList.add("popover-active");
  83. document.addEventListener("keydown", handleHidePopoverOnEscape);
  84. window.addEventListener("resize", repositionContainer);
  85. observer.observe(containerElement);
  86. }
  87. function repositionContainer() {
  88. const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
  89. ? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
  90. : activeTarget.getBoundingClientRect();
  91. const containerBounds = containerElement.getBoundingClientRect();
  92. const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
  93. const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
  94. const position = activeTarget.dataset.popoverPosition || "below";
  95. const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
  96. if (left < 0) {
  97. containerElement.style.left = 0;
  98. containerElement.style.removeProperty("right");
  99. containerElement.style.setProperty("--triangle-offset", targetBounds.left - containerInlinePadding + targetBoundsWidthOffset + "px");
  100. } else if (left + containerBounds.width > window.innerWidth) {
  101. containerElement.style.removeProperty("left");
  102. containerElement.style.right = 0;
  103. containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
  104. } else {
  105. containerElement.style.removeProperty("right");
  106. containerElement.style.left = left + "px";
  107. containerElement.style.removeProperty("--triangle-offset");
  108. }
  109. const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
  110. const topWhenAbove = targetBounds.top + window.scrollY - containerBounds.height;
  111. const topWhenBelow = targetBounds.top + window.scrollY + targetBounds.height;
  112. if (
  113. position === "above" && topWhenAbove > window.scrollY ||
  114. (position === "below" && topWhenBelow + containerBounds.height > window.scrollY + window.innerHeight)
  115. ) {
  116. containerElement.classList.add("position-above");
  117. frameElement.style.removeProperty("margin-top");
  118. frameElement.style.marginBottom = distanceFromTarget;
  119. containerElement.style.top = topWhenAbove + "px";
  120. } else {
  121. containerElement.classList.remove("position-above");
  122. frameElement.style.removeProperty("margin-bottom");
  123. frameElement.style.marginTop = distanceFromTarget;
  124. containerElement.style.top = topWhenBelow + "px";
  125. }
  126. }
  127. function hidePopover() {
  128. if (activeTarget === null) return;
  129. activeTarget.classList.remove("popover-active");
  130. containerElement.style.display = "none";
  131. document.removeEventListener("keydown", handleHidePopoverOnEscape);
  132. window.removeEventListener("resize", repositionContainer);
  133. observer.unobserve(containerElement);
  134. if (cleanupOnHidePopover !== null) {
  135. cleanupOnHidePopover();
  136. cleanupOnHidePopover = null;
  137. }
  138. activeTarget = null;
  139. }
  140. function handleHidePopoverOnEscape(event) {
  141. if (event.key === "Escape") {
  142. hidePopover();
  143. }
  144. }
  145. export function setupPopovers() {
  146. const targets = document.querySelectorAll("[data-popover-type]");
  147. for (let i = 0; i < targets.length; i++) {
  148. const target = targets[i];
  149. target.addEventListener("mouseenter", handleMouseEnter);
  150. target.addEventListener("mouseleave", handleMouseLeave);
  151. }
  152. }