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 }}