Add popover functionality

This commit is contained in:
Svilen Markov 2024-08-19 02:15:22 +01:00
parent b4a4df480e
commit 83c7c4a14a
4 changed files with 205 additions and 1 deletions

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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%;

View File

@ -16,7 +16,7 @@
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>