489 lines
18 KiB
Text
489 lines
18 KiB
Text
// templui component popover - version: main installed by templui v0.71.0
|
|
package popover
|
|
|
|
import (
|
|
"git.jmbit.de/jmb/scanfile/server/web/templui/utils"
|
|
"strconv"
|
|
)
|
|
|
|
type Placement string
|
|
|
|
const (
|
|
PlacementTop Placement = "top"
|
|
PlacementTopStart Placement = "top-start"
|
|
PlacementTopEnd Placement = "top-end"
|
|
PlacementRight Placement = "right"
|
|
PlacementRightStart Placement = "right-start"
|
|
PlacementRightEnd Placement = "right-end"
|
|
PlacementBottom Placement = "bottom"
|
|
PlacementBottomStart Placement = "bottom-start"
|
|
PlacementBottomEnd Placement = "bottom-end"
|
|
PlacementLeft Placement = "left"
|
|
PlacementLeftStart Placement = "left-start"
|
|
PlacementLeftEnd Placement = "left-end"
|
|
)
|
|
|
|
type TriggerType string
|
|
|
|
const (
|
|
TriggerTypeHover TriggerType = "hover"
|
|
TriggerTypeClick TriggerType = "click"
|
|
)
|
|
|
|
type Props struct {
|
|
Class string
|
|
}
|
|
|
|
type TriggerProps struct {
|
|
ID string
|
|
For string
|
|
TriggerType TriggerType
|
|
}
|
|
|
|
type ContentProps struct {
|
|
ID string
|
|
Class string
|
|
Attributes templ.Attributes
|
|
Placement Placement
|
|
Offset int
|
|
DisableClickAway bool
|
|
DisableESC bool
|
|
ShowArrow bool
|
|
HoverDelay int
|
|
HoverOutDelay int
|
|
MatchWidth bool
|
|
}
|
|
|
|
templ Popover(props ...Props) {
|
|
@Script()
|
|
{{ var p Props }}
|
|
if len(props) > 0 {
|
|
{{ p = props[0] }}
|
|
}
|
|
<div class={ p.Class }>
|
|
{ children... }
|
|
</div>
|
|
}
|
|
|
|
templ Trigger(props ...TriggerProps) {
|
|
{{ var p TriggerProps }}
|
|
if len(props) > 0 {
|
|
{{ p = props[0] }}
|
|
}
|
|
if p.TriggerType == "" {
|
|
{{ p.TriggerType = TriggerTypeClick }}
|
|
}
|
|
<span
|
|
if p.ID != "" {
|
|
id={ p.ID }
|
|
}
|
|
data-popover-trigger
|
|
data-popover-for={ p.For }
|
|
data-popover-type={ string(p.TriggerType) }
|
|
>
|
|
{ children... }
|
|
</span>
|
|
}
|
|
|
|
templ Content(props ...ContentProps) {
|
|
{{ var p ContentProps }}
|
|
if len(props) > 0 {
|
|
{{ p = props[0] }}
|
|
}
|
|
if p.Placement == "" {
|
|
{{ p.Placement = PlacementBottom }}
|
|
}
|
|
if p.Offset == 0 {
|
|
if p.ShowArrow {
|
|
{{ p.Offset = 8 }}
|
|
} else {
|
|
{{ p.Offset = 4 }}
|
|
}
|
|
}
|
|
<div
|
|
id={ p.ID }
|
|
data-popover-id={ p.ID }
|
|
data-popover-placement={ string(p.Placement) }
|
|
data-popover-offset={ strconv.Itoa(p.Offset) }
|
|
data-popover-disable-clickaway={ strconv.FormatBool(p.DisableClickAway) }
|
|
data-popover-disable-esc={ strconv.FormatBool(p.DisableESC) }
|
|
data-popover-show-arrow={ strconv.FormatBool(p.ShowArrow) }
|
|
data-popover-hover-delay={ strconv.Itoa(p.HoverDelay) }
|
|
data-popover-hover-out-delay={ strconv.Itoa(p.HoverOutDelay) }
|
|
if p.MatchWidth {
|
|
data-popover-match-width="true"
|
|
}
|
|
class={ utils.TwMerge(
|
|
"bg-background rounded-lg border text-sm shadow-lg pointer-events-auto absolute z-[9999] hidden top-0 left-0",
|
|
p.Class,
|
|
) }
|
|
{ p.Attributes... }
|
|
>
|
|
<div class="w-full overflow-hidden">
|
|
{ children... }
|
|
</div>
|
|
if p.ShowArrow {
|
|
<div data-popover-arrow class="absolute h-2.5 w-2.5 rotate-45 bg-background border"></div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
var handle = templ.NewOnceHandle()
|
|
|
|
templ Script() {
|
|
@handle.Once() {
|
|
@FloatingUICore()
|
|
@FloatingUIDom()
|
|
<script nonce={ templ.GetNonce(ctx) }>
|
|
if (typeof window.popoverState === 'undefined') {
|
|
window.popoverState = new Map();
|
|
}
|
|
|
|
(function() { // IIFE Start
|
|
if (window.popoverSystemInitialized) return;
|
|
|
|
// --- Ensure Global Portal Container ---
|
|
let portalContainer = document.querySelector('[data-popover-portal-container]');
|
|
if (!portalContainer) {
|
|
portalContainer = document.createElement('div');
|
|
portalContainer.setAttribute('data-popover-portal-container', '');
|
|
portalContainer.className = 'fixed inset-0 z-[9999] pointer-events-none';
|
|
document.body.appendChild(portalContainer);
|
|
}
|
|
// --- End Ensure Global Portal Container ---
|
|
|
|
// --- Floating UI Check & Helper ---
|
|
let FloatingUIDOM = null;
|
|
|
|
function whenFloatingUiReady(callback, attempt = 1) {
|
|
if (window.FloatingUIDOM) {
|
|
FloatingUIDOM = window.FloatingUIDOM;
|
|
callback();
|
|
} else if (attempt < 40) {
|
|
setTimeout(() => whenFloatingUiReady(callback, attempt + 1), 50);
|
|
} else {
|
|
console.error("Floating UI DOM failed to load after several attempts.");
|
|
}
|
|
}
|
|
|
|
// --- Helper Functions ---
|
|
function findReferenceElement(triggerSpan) {
|
|
const children = triggerSpan.children;
|
|
if (children.length === 0) return triggerSpan;
|
|
let bestElement = triggerSpan;
|
|
let largestArea = 0;
|
|
for (const child of children) {
|
|
if (typeof child.getBoundingClientRect !== 'function') continue;
|
|
const rect = child.getBoundingClientRect();
|
|
const area = rect.width * rect.height;
|
|
if (area > largestArea) {
|
|
largestArea = area;
|
|
bestElement = child;
|
|
}
|
|
}
|
|
return bestElement;
|
|
}
|
|
|
|
function positionArrow(arrowElement, placement, arrowData, content) {
|
|
const { x: arrowX, y: arrowY } = arrowData;
|
|
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]];
|
|
Object.assign(arrowElement.style, { left: arrowX != null ? `${arrowX}px` : '', top: arrowY != null ? `${arrowY}px` : '', right: '', bottom: '', [staticSide]: '-5px' });
|
|
const popoverStyle = window.getComputedStyle(content);
|
|
const popoverBorderColor = popoverStyle.borderColor;
|
|
arrowElement.style.backgroundColor = popoverStyle.backgroundColor;
|
|
arrowElement.style.borderTopColor = popoverBorderColor;
|
|
arrowElement.style.borderRightColor = popoverBorderColor;
|
|
arrowElement.style.borderBottomColor = popoverBorderColor;
|
|
arrowElement.style.borderLeftColor = popoverBorderColor;
|
|
switch (staticSide) {
|
|
case 'top': arrowElement.style.borderBottomColor = 'transparent'; arrowElement.style.borderRightColor = 'transparent'; break;
|
|
case 'bottom': arrowElement.style.borderTopColor = 'transparent'; arrowElement.style.borderLeftColor = 'transparent'; break;
|
|
case 'left': arrowElement.style.borderTopColor = 'transparent'; arrowElement.style.borderRightColor = 'transparent'; break;
|
|
case 'right': arrowElement.style.borderBottomColor = 'transparent'; arrowElement.style.borderLeftColor = 'transparent'; break;
|
|
}
|
|
}
|
|
|
|
function addAnimationStyles() {
|
|
if (document.getElementById('popover-animations')) return;
|
|
const style = document.createElement('style');
|
|
style.id = 'popover-animations';
|
|
style.textContent = `
|
|
@keyframes popover-in { 0% { opacity: 0; transform: scale(0.95); } 100% { opacity: 1; transform: scale(1); } }
|
|
@keyframes popover-out { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.95); } }
|
|
[data-popover-id].popover-animate-in { animation: popover-in 0.15s cubic-bezier(0.16, 1, 0.3, 1); }
|
|
[data-popover-id].popover-animate-out { animation: popover-out 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// --- Core Popover Logic ---
|
|
function updatePosition(state) {
|
|
if (!FloatingUIDOM || !state || !state.trigger || !state.content) return;
|
|
const { computePosition, offset, flip, shift, arrow } = FloatingUIDOM;
|
|
const referenceElement = findReferenceElement(state.trigger);
|
|
const arrowElement = state.content.querySelector('[data-popover-arrow]');
|
|
const placement = state.content.dataset.popoverPlacement || 'bottom';
|
|
const offsetValue = parseInt(state.content.dataset.popoverOffset) || (arrowElement ? 8 : 4);
|
|
const shouldMatchWidth = state.content.dataset.popoverMatchWidth === 'true';
|
|
|
|
const middleware = [offset(offsetValue), flip({ padding: 10 }), shift({ padding: 10 })];
|
|
if (arrowElement) middleware.push(arrow({ element: arrowElement, padding: 5 }));
|
|
|
|
computePosition(referenceElement, state.content, { placement, middleware }).then(({ x, y, placement, middlewareData }) => {
|
|
Object.assign(state.content.style, { left: `${x}px`, top: `${y}px` });
|
|
|
|
if (shouldMatchWidth) {
|
|
const triggerWidth = referenceElement.offsetWidth;
|
|
state.content.style.setProperty('--popover-trigger-width', `${triggerWidth}px`);
|
|
}
|
|
|
|
if (arrowElement && middlewareData.arrow) {
|
|
positionArrow(arrowElement, placement, middlewareData.arrow, state.content);
|
|
}
|
|
});
|
|
}
|
|
|
|
function addGlobalListeners(popoverId, state) {
|
|
removeGlobalListeners(state); // Ensure no duplicates
|
|
if (state.content.dataset.popoverDisableClickaway !== 'true') {
|
|
const handler = (e) => {
|
|
// Close if click is outside trigger and content
|
|
if (!state.trigger.contains(e.target) && !state.content.contains(e.target)) {
|
|
closePopover(popoverId);
|
|
}
|
|
};
|
|
// Use setTimeout to avoid capturing the click that opened the popover
|
|
setTimeout(() => document.addEventListener('click', handler), 0);
|
|
state.eventListeners.clickAway = handler;
|
|
}
|
|
if (state.content.dataset.popoverDisableEsc !== 'true') {
|
|
const handler = (e) => { if (e.key === 'Escape') closePopover(popoverId); };
|
|
document.addEventListener('keydown', handler);
|
|
state.eventListeners.esc = handler;
|
|
}
|
|
}
|
|
|
|
function removeGlobalListeners(state) {
|
|
if (state.eventListeners.clickAway) document.removeEventListener('click', state.eventListeners.clickAway);
|
|
if (state.eventListeners.esc) document.removeEventListener('keydown', state.eventListeners.esc);
|
|
state.eventListeners = {}; // Clear stored handlers
|
|
}
|
|
|
|
function openPopover(popoverId, trigger) {
|
|
if (!FloatingUIDOM) return;
|
|
const { autoUpdate } = FloatingUIDOM;
|
|
const content = document.getElementById(popoverId);
|
|
if (!content) return;
|
|
|
|
let state = window.popoverState.get(popoverId);
|
|
if (!state) { // Should be created by initTrigger, but as a fallback
|
|
state = { trigger, content, isOpen: false, cleanup: null, hoverState: {}, eventListeners: {} };
|
|
window.popoverState.set(popoverId, state);
|
|
} else if (state.isOpen) return;
|
|
|
|
state.trigger = trigger; // Ensure trigger reference is current
|
|
state.content = content; // Ensure content reference is current
|
|
|
|
const portal = document.querySelector('[data-popover-portal-container]');
|
|
if (portal && content.parentNode !== portal) portal.appendChild(content);
|
|
|
|
content.style.display = 'block';
|
|
content.classList.remove('popover-animate-out');
|
|
content.classList.add('popover-animate-in');
|
|
|
|
// Initial position update before autoUpdate starts
|
|
updatePosition(state);
|
|
|
|
if (state.cleanup) state.cleanup();
|
|
state.cleanup = autoUpdate(findReferenceElement(trigger), content, () => updatePosition(state), { animationFrame: true }); // Use animationFrame for smoother updates
|
|
|
|
addGlobalListeners(popoverId, state);
|
|
state.isOpen = true;
|
|
}
|
|
|
|
function closePopover(popoverId, immediate = false) {
|
|
const state = window.popoverState.get(popoverId);
|
|
if (!state || !state.isOpen) return;
|
|
|
|
if (state.cleanup) { state.cleanup(); state.cleanup = null; }
|
|
removeGlobalListeners(state);
|
|
|
|
const content = state.content;
|
|
function hideContent() { content.style.display = 'none'; content.classList.remove('popover-animate-in', 'popover-animate-out'); }
|
|
|
|
if (immediate) hideContent();
|
|
else {
|
|
content.classList.remove('popover-animate-in');
|
|
content.classList.add('popover-animate-out');
|
|
setTimeout(hideContent, 150); // Match animation duration
|
|
}
|
|
state.isOpen = false;
|
|
}
|
|
|
|
// Expose closePopover globally
|
|
window.closePopover = closePopover;
|
|
|
|
// --- Trigger Initialization & Handling ---
|
|
|
|
function attachClickTrigger(trigger, popoverId) {
|
|
const handler = (e) => {
|
|
e.stopPropagation();
|
|
const state = window.popoverState.get(popoverId);
|
|
if (state?.isOpen) closePopover(popoverId);
|
|
else openPopover(popoverId, trigger);
|
|
};
|
|
trigger.addEventListener('click', handler);
|
|
trigger._popoverListener = handler;
|
|
}
|
|
|
|
function attachHoverTrigger(trigger, popoverId) {
|
|
const content = document.getElementById(popoverId);
|
|
if (!content) return;
|
|
let state = window.popoverState.get(popoverId);
|
|
if (!state) return; // State should exist from initTrigger
|
|
|
|
const hoverDelay = parseInt(content.dataset.popoverHoverDelay) || 100;
|
|
const hoverOutDelay = parseInt(content.dataset.popoverHoverOutDelay) || 200;
|
|
|
|
const handleTriggerEnter = () => { clearTimeout(state.hoverState.leaveTimeout); state.hoverState.enterTimeout = setTimeout(() => openPopover(popoverId, trigger), hoverDelay); };
|
|
const handleTriggerLeave = (e) => { clearTimeout(state.hoverState.enterTimeout); state.hoverState.leaveTimeout = setTimeout(() => { if (!content.contains(e.relatedTarget)) closePopover(popoverId); }, hoverOutDelay); };
|
|
const handleContentEnter = () => clearTimeout(state.hoverState.leaveTimeout);
|
|
const handleContentLeave = (e) => { state.hoverState.leaveTimeout = setTimeout(() => { if (!trigger.contains(e.relatedTarget)) closePopover(popoverId); }, hoverOutDelay); };
|
|
|
|
trigger.addEventListener('mouseenter', handleTriggerEnter);
|
|
trigger.addEventListener('mouseleave', handleTriggerLeave);
|
|
content.addEventListener('mouseenter', handleContentEnter);
|
|
content.addEventListener('mouseleave', handleContentLeave);
|
|
|
|
// Store handlers for cleanup
|
|
trigger._popoverHoverListeners = { handleTriggerEnter, handleTriggerLeave };
|
|
content._popoverHoverListeners = { handleContentEnter, handleContentLeave };
|
|
}
|
|
|
|
function initTrigger(trigger) {
|
|
const popoverId = trigger.dataset.popoverFor;
|
|
const content = document.getElementById(popoverId);
|
|
if (!popoverId || !content) return;
|
|
|
|
// Prevent re-attaching listeners to the same DOM element instance
|
|
if (trigger._popoverListenerAttached) return;
|
|
|
|
// Ensure state object exists
|
|
if (!window.popoverState.has(popoverId)) {
|
|
window.popoverState.set(popoverId, {
|
|
trigger, content, isOpen: false, cleanup: null,
|
|
hoverState: {}, eventListeners: {}
|
|
});
|
|
} else {
|
|
// Update refs in existing state if trigger persisted
|
|
const state = window.popoverState.get(popoverId);
|
|
state.trigger = trigger;
|
|
state.content = content;
|
|
// Ensure closed state after potential swap/cleanup
|
|
if (state.isOpen) closePopover(popoverId, true);
|
|
}
|
|
|
|
// Cleanup any stray listeners before attaching new ones
|
|
if (trigger._popoverListener) trigger.removeEventListener('click', trigger._popoverListener);
|
|
if (trigger._popoverHoverListeners) { trigger.removeEventListener('mouseenter', trigger._popoverHoverListeners.handleTriggerEnter); trigger.removeEventListener('mouseleave', trigger._popoverHoverListeners.handleTriggerLeave); }
|
|
if (content._popoverHoverListeners) { content.removeEventListener('mouseenter', content._popoverHoverListeners.handleContentEnter); content.removeEventListener('mouseleave', content._popoverHoverListeners.handleContentLeave); }
|
|
delete trigger._popoverListener;
|
|
delete trigger._popoverHoverListeners;
|
|
if (content) delete content._popoverHoverListeners;
|
|
|
|
// Attach the correct listener type
|
|
const triggerType = trigger.dataset.popoverType || 'click';
|
|
if (triggerType === 'click') {
|
|
attachClickTrigger(trigger, popoverId);
|
|
} else if (triggerType === 'hover') {
|
|
attachHoverTrigger(trigger, popoverId);
|
|
}
|
|
trigger._popoverListenerAttached = true;
|
|
}
|
|
|
|
// --- Cleanup ---
|
|
|
|
function cleanupPopovers(element) {
|
|
const cleanupTrigger = (trigger) => {
|
|
const popoverId = trigger.dataset.popoverFor;
|
|
if (popoverId) {
|
|
closePopover(popoverId, true); // Close popover, remove global listeners, stop Floating UI
|
|
}
|
|
|
|
// Remove listeners directly attached to the trigger
|
|
if (trigger._popoverListener) trigger.removeEventListener('click', trigger._popoverListener);
|
|
if (trigger._popoverHoverListeners) {
|
|
trigger.removeEventListener('mouseenter', trigger._popoverHoverListeners.handleTriggerEnter);
|
|
trigger.removeEventListener('mouseleave', trigger._popoverHoverListeners.handleTriggerLeave);
|
|
}
|
|
|
|
// Remove listeners attached to the content (for hover)
|
|
const content = document.getElementById(popoverId);
|
|
if (content && content._popoverHoverListeners) {
|
|
content.removeEventListener('mouseenter', content._popoverHoverListeners.handleContentEnter);
|
|
content.removeEventListener('mouseleave', content._popoverHoverListeners.handleContentLeave);
|
|
delete content._popoverHoverListeners;
|
|
}
|
|
|
|
// Clean up stored references and flags on the trigger
|
|
delete trigger._popoverListener;
|
|
delete trigger._popoverHoverListeners;
|
|
delete trigger._popoverListenerAttached;
|
|
|
|
// Optionally remove state - might be desired if the element is definitely gone
|
|
// window.popoverState.delete(popoverId);
|
|
};
|
|
|
|
// Cleanup element itself if it's a trigger
|
|
if (element.matches && element.matches('[data-popover-trigger]')) {
|
|
cleanupTrigger(element);
|
|
}
|
|
// Cleanup descendants
|
|
if (element.querySelectorAll) {
|
|
element.querySelectorAll('[data-popover-trigger]').forEach(cleanupTrigger);
|
|
}
|
|
}
|
|
|
|
function initAllComponents(root = document) {
|
|
if (!FloatingUIDOM) return; // Don't init if library isn't ready
|
|
if (root instanceof Element && root.matches('[data-popover-trigger]')) {
|
|
initTrigger(root);
|
|
}
|
|
if (root && typeof root.querySelectorAll === 'function') {
|
|
for (const trigger of root.querySelectorAll('[data-popover-trigger]')) {
|
|
initTrigger(trigger);
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleHtmxSwap = (event) => {
|
|
const target = event.detail.target || event.detail.elt;
|
|
if (target instanceof Element) {
|
|
whenFloatingUiReady(() => initAllComponents(target));
|
|
}
|
|
};
|
|
|
|
initAllComponents();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
whenFloatingUiReady(() => {
|
|
addAnimationStyles();
|
|
initAllComponents();
|
|
});
|
|
});
|
|
|
|
document.body.addEventListener('htmx:beforeSwap', (event) => {
|
|
const target = event.detail.target || event.detail.elt;;
|
|
if (target instanceof Element) {
|
|
cleanupPopovers(target);
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', handleHtmxSwap);
|
|
document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap);
|
|
|
|
window.popoverSystemInitialized = true;
|
|
})(); // IIFE End
|
|
</script>
|
|
}
|
|
}
|