diff --git a/internal/assets/static/main.js b/internal/assets/static/js/main.js similarity index 99% rename from internal/assets/static/main.js rename to internal/assets/static/js/main.js index 906b930..228f57d 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/js/main.js @@ -1,3 +1,5 @@ +import { setupPopovers } from './popover.js'; + function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { let debounceTimeout; let timesDebounced = 0; @@ -593,6 +595,7 @@ async function setupPage() { pageContentElement.innerHTML = pageContent; try { + setupPopovers(); setupClocks() setupCarousels(); setupSearchBoxes(); diff --git a/internal/assets/static/js/popover.js b/internal/assets/static/js/popover.js new file mode 100644 index 0000000..d431502 --- /dev/null +++ b/internal/assets/static/js/popover.js @@ -0,0 +1,155 @@ +const defaultShowDelayMs = 200; +const defaultHideDelayMs = 500; +const defaultMaxWidth = "300px"; +const defaultDistanceFromTarget = "0px" +const htmlContentSelector = "[data-popover-html]"; + +let activeTarget = null; +let pendingTarget = null; +let cleanupOnHidePopover = null; +let togglePopoverTimeout = null; + +const containerElement = document.createElement("div"); +const containerComputedStyle = getComputedStyle(containerElement); +containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout); +containerElement.addEventListener("mouseleave", handleMouseLeave); +containerElement.classList.add("popover-container"); + +const frameElement = document.createElement("div"); +frameElement.classList.add("popover-frame"); + +const contentElement = document.createElement("div"); +contentElement.classList.add("popover-content"); + +frameElement.append(contentElement); +containerElement.append(frameElement); +document.body.append(containerElement); + +const observer = new ResizeObserver(repositionContainer); + +function handleMouseEnter(event) { + clearTogglePopoverTimeout(); + const target = event.target; + pendingTarget = target; + const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs; + + if (activeTarget !== null) { + if (activeTarget !== target) { + hidePopover(); + setTimeout(showPopover, 5); + } + + return; + } + + togglePopoverTimeout = setTimeout(showPopover, showDelay); +} + +function handleMouseLeave(event) { + clearTogglePopoverTimeout(); + const target = activeTarget || event.target; + togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs); +} + +function clearTogglePopoverTimeout() { + clearTimeout(togglePopoverTimeout); +} + +function showPopover() { + activeTarget = pendingTarget; + pendingTarget = null; + + const popoverType = activeTarget.dataset.popoverType; + const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth; + + if (popoverType === "text") { + const text = activeTarget.dataset.popoverText; + if (text === undefined || text === "") return; + contentElement.textContent = text; + } else if (popoverType === "html") { + const htmlContent = activeTarget.querySelector(htmlContentSelector); + if (htmlContent === null) return; + /** + * The reason for all of the below shenanigans is that I want to preserve + * all attached event listeners of the original HTML content. This is so I don't have to + * re-setup events for things like lazy images, they'd just work as expected. + */ + const placeholder = document.createComment(""); + htmlContent.replaceWith(placeholder); + contentElement.replaceChildren(htmlContent); + htmlContent.removeAttribute("data-popover-html"); + cleanupOnHidePopover = () => { + htmlContent.setAttribute("data-popover-html", ""); + placeholder.replaceWith(htmlContent); + placeholder.remove(); + }; + } else { + return; + } + + contentElement.style.maxWidth = contentMaxWidth; + containerElement.style.display = "block"; + activeTarget.classList.add("popover-active"); + document.addEventListener("keydown", handleHidePopoverOnEscape); + window.addEventListener("resize", repositionContainer); + observer.observe(containerElement); +} + +function repositionContainer() { + const activeTargetBounds = activeTarget.getBoundingClientRect(); + const containerBounds = containerElement.getBoundingClientRect(); + const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline")); + const activeTargetBoundsWidthOffset = activeTargetBounds.width * (activeTarget.dataset.popoverOffset || 0.5); + const left = activeTargetBounds.left + activeTargetBoundsWidthOffset - (containerBounds.width / 2); + + if (left < 0) { + containerElement.style.left = 0; + containerElement.style.removeProperty("right"); + containerElement.style.setProperty("--triangle-offset", activeTargetBounds.left - containerInlinePadding + activeTargetBoundsWidthOffset + "px"); + } else if (left + containerBounds.width > window.innerWidth) { + containerElement.style.removeProperty("left"); + containerElement.style.right = 0; + containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - activeTargetBounds.left - activeTargetBoundsWidthOffset) + "px"); + } else { + containerElement.style.removeProperty("right"); + containerElement.style.left = left + "px"; + containerElement.style.removeProperty("--triangle-offset"); + } + + frameElement.style.marginTop = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget; + containerElement.style.top = activeTargetBounds.top + window.scrollY + activeTargetBounds.height + "px"; +} + +function hidePopover() { + if (activeTarget === null) return; + + activeTarget.classList.remove("popover-active"); + containerElement.style.display = "none"; + document.removeEventListener("keydown", handleHidePopoverOnEscape); + window.removeEventListener("resize", repositionContainer); + observer.unobserve(containerElement); + + if (cleanupOnHidePopover !== null) { + cleanupOnHidePopover(); + cleanupOnHidePopover = null; + } + + activeTarget = null; +} + +function handleHidePopoverOnEscape(event) { + if (event.key === "Escape") { + hidePopover(); + } +} + +export function setupPopovers() { + const targets = document.querySelectorAll("[data-popover-type]"); + + for (let i = 0; i < targets.length; i++) { + const target = targets[i]; + + target.addEventListener("mouseenter", handleMouseEnter); + target.addEventListener("mouseleave", handleMouseLeave); + } +} diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index d96e7fd..4c04d61 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -34,6 +34,8 @@ --color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm)))); --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); + --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 2%)); + --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 7%))); --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); @@ -436,6 +438,50 @@ kbd:active { box-shadow: 0 0 0 0 var(--color-widget-background-highlight); } +.popover-container, [data-popover-html] { + display: none; +} + +.popover-container { + --triangle-size: 10px; + --triangle-offset: 50%; + z-index: 20; + position: absolute; + padding-top: calc(var(--triangle-size) + 3px); + padding-inline: var(--content-bounds-padding); +} + +.popover-frame { + position: relative; + padding: 10px; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 5px; + animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 0 15px 30px -5px hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5); +} + +.popover-frame::before { + content: ''; + position: absolute; + width: var(--triangle-size); + height: var(--triangle-size); + transform: rotate(45deg); + background-color: var(--color-popover-background); + border-top-left-radius: 4px; + border-left: 1px solid var(--color-popover-border); + border-top: 1px solid var(--color-popover-border); + left: calc(var(--triangle-offset) - (var(--triangle-size) / 2)); + top: calc(var(--triangle-size) / 2 * -1 - 1px); +} + +@keyframes popoverFrameEntrance { + from { + opacity: 0; + transform: translateY(-8px); + } +} + .content-bounds { max-width: 1600px; width: 100%; diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index d37ac56..6aa1029 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -16,7 +16,7 @@ - + {{ block "document-head-after" . }}{{ end }}