Popover
Displays content in a window, triggered by a hover or click.
TailwindCSS
Vanilla JS
Dimensions
Set the dimensions for the layer.
package showcase
import "github.com/axzilla/templui/components"
templ PopoverDefault() {
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "default-popover",
}) {
@components.Button(components.ButtonProps{
Variant: components.ButtonVariantOutline,
}) {
Open Popover
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "default-popover",
}) {
@PopoverContent()
}
}
}
templ PopoverContent() {
<div class="p-4 space-y-4 min-w-2xs">
<div>
<h3 class="text-lg font-semibold">Dimensions</h3>
<p>Set the dimensions for the layer.</p>
</div>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-3 items-center gap-2">
@components.Label(components.LabelProps{
For: "width",
}) {
Width
}
@components.Input(components.InputProps{
ID: "width",
Placeholder: "Width",
Value: "100%",
Class: "col-span-2",
})
</div>
<div class="grid grid-cols-3 items-center gap-2">
@components.Label(components.LabelProps{
For: "height",
}) {
Height
}
@components.Input(components.InputProps{
ID: "height",
Placeholder: "Height",
Value: "100%",
Class: "col-span-2",
})
</div>
</div>
</div>
}
package components
import (
"github.com/axzilla/templui/utils"
"strconv"
)
type PopoverPosition string
const (
PopoverTop PopoverPosition = "top"
PopoverTopStart PopoverPosition = "top-start"
PopoverTopEnd PopoverPosition = "top-end"
PopoverRight PopoverPosition = "right"
PopoverRightStart PopoverPosition = "right-start"
PopoverRightEnd PopoverPosition = "right-end"
PopoverBottom PopoverPosition = "bottom"
PopoverBottomStart PopoverPosition = "bottom-start"
PopoverBottomEnd PopoverPosition = "bottom-end"
PopoverLeft PopoverPosition = "left"
PopoverLeftStart PopoverPosition = "left-start"
PopoverLeftEnd PopoverPosition = "left-end"
)
type PopoverTriggerType string
const (
PopoverTriggerTypeHover PopoverTriggerType = "hover"
PopoverTriggerTypeClick PopoverTriggerType = "click"
)
type PopoverProps struct {
Class string
}
type PopoverTriggerProps struct {
ID string
TriggerType PopoverTriggerType
}
type PopoverContentProps struct {
ID string
Class string
Attributes templ.Attributes
Position PopoverPosition
DisableClickAway bool
DisableESC bool
ShowArrow bool
HoverDelay int
HoverOutDelay int
}
templ popoverPortalContainer() {
<div
id="popover-portal-container"
class="fixed inset-0 z-[9999] pointer-events-none"
></div>
}
templ PopoverTrigger(props ...PopoverTriggerProps) {
{{ var p PopoverTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.TriggerType == "" {
{{ p.TriggerType = PopoverTriggerTypeClick }}
}
<span
data-popover-trigger
data-popover-id={ p.ID }
data-popover-type={ string(p.TriggerType) }
>
{ children... }
</span>
}
templ Popover(props ...PopoverProps) {
{{ var p PopoverProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div class={ utils.TwMerge("relative inline-block", p.Class) }>
{ children... }
</div>
@popoverPortalContainer()
}
templ PopoverContent(props ...PopoverContentProps) {
{{ var p PopoverContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Position == "" {
{{ p.Position = PopoverBottom }}
}
<template
data-popover-content-template
data-popover-id={ p.ID }
data-popover-position={ string(p.Position) }
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) }
>
<div
class={ utils.TwMerge(
"bg-background rounded-lg border text-sm shadow-lg pointer-events-auto absolute z-[9999]",
p.Class,
) }
>
<div class="w-full overflow-hidden">
{ children... }
</div>
if p.ShowArrow {
<!-- We generate all arrows with unique data attributes for easier identification -->
<!-- Top arrows -->
<div
data-arrow={ string(PopoverTop) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-1/2 -translate-x-1/2 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-4 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] right-4 border-t border-l hidden"
></div>
<!-- Bottom arrows -->
<div
data-arrow={ string(PopoverBottom) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-1/2 -translate-x-1/2 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-4 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] right-4 border-b border-r hidden"
></div>
<!-- Left arrows -->
<div
data-arrow={ string(PopoverLeft) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-1/2 -translate-y-1/2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] bottom-2 border-b border-l hidden"
></div>
<!-- Right arrows -->
<div
data-arrow={ string(PopoverRight) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-1/2 -translate-y-1/2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] bottom-2 border-t border-r hidden"
></div>
}
</div>
</template>
}
templ PopoverScript() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', () => {
// Minimal CSS-Animation for the rubber effect
const style = document.createElement('style');
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); }
}
.popover-animate-in {
animation: popover-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.popover-animate-out {
animation: popover-out 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
`;
document.head.appendChild(style);
const portalContainer = document.getElementById('popover-portal-container');
const triggers = document.querySelectorAll('[data-popover-trigger]');
const templates = document.querySelectorAll('[data-popover-content-template]');
// Active popovers
const activePopovers = new Map();
// Function to update the arrow based on the position
const updateArrow = (popoverElement, position) => {
if (popoverElement.dataset.popoverShowArrow !== 'true') return;
// Initially hide all arrows
popoverElement.querySelectorAll('[data-arrow]').forEach(arrow => {
arrow.classList.add('hidden');
});
// Arrow direction is always opposite to the position
// e.g., if the popover is at the bottom, the arrow must point up
let arrowPosition;
if (position.startsWith('top')) {
// If the popover is at the top, the arrow must point down
arrowPosition = position.replace('top', 'bottom');
} else if (position.startsWith('bottom')) {
// If the popover is at the bottom, the arrow must point up
arrowPosition = position.replace('bottom', 'top');
} else if (position.startsWith('left')) {
// If the popover is on the left, the arrow must point right
arrowPosition = position.replace('left', 'right');
} else if (position.startsWith('right')) {
// If the popover is on the right, the arrow must point left
arrowPosition = position.replace('right', 'left');
} else {
// Fallback
arrowPosition = position;
}
// Show the correct arrow based on the direction
const arrow = popoverElement.querySelector(`[data-arrow="${arrowPosition}"]`);
if (arrow) {
arrow.classList.remove('hidden');
}
};
// Positioning function
const positionPopover = (trigger, popoverElement) => {
// Find the actual element the popover refers to
let triggerElement = trigger;
let largestArea = 0;
// Check all direct children of the trigger
const children = trigger.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const rect = child.getBoundingClientRect();
const area = rect.width * rect.height;
if (area > largestArea) {
largestArea = area;
triggerElement = child;
}
}
const triggerRect = triggerElement.getBoundingClientRect();
const contentRect = popoverElement.getBoundingClientRect();
const margin = popoverElement.dataset.popoverShowArrow === 'true' ? 8 : 4;
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// Position from the dataset
const requestedPosition = popoverElement.dataset.popoverPosition || 'bottom';
// We store the final position, which can be adjusted based on viewport space
let finalPosition = requestedPosition;
// Viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Get element heights and widths
const triggerHeight = triggerRect.height;
const contentHeight = contentRect.height;
const contentWidth = contentRect.width;
// Define anchor points
const triggerTop = triggerRect.top + scrollY;
const triggerBottom = triggerRect.bottom + scrollY;
const triggerLeft = triggerRect.left + scrollX;
const triggerRight = triggerRect.right + scrollX;
// Calculate available space in each direction
const spaceAbove = triggerRect.top;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = viewportWidth - triggerRect.right;
// Intelligent position adjustment
// We check the opposite position if there is not enough space
if (finalPosition.startsWith('top') && spaceAbove < contentHeight + margin) {
// If there is not enough space above, show below
finalPosition = finalPosition.replace('top', 'bottom');
} else if (finalPosition.startsWith('bottom') && spaceBelow < contentHeight + margin) {
// If there is not enough space below, show above
finalPosition = finalPosition.replace('bottom', 'top');
} else if (finalPosition.startsWith('left') && spaceLeft < contentWidth + margin) {
// If there is not enough space on the left, show on the right
finalPosition = finalPosition.replace('left', 'right');
} else if (finalPosition.startsWith('right') && spaceRight < contentWidth + margin) {
// If there is not enough space on the right, show on the left
finalPosition = finalPosition.replace('right', 'left');
}
// Store the current position in the element for CSS adjustments (e.g., arrow position)
popoverElement.dataset.popoverCurrentPosition = finalPosition;
// Show the correct arrow
updateArrow(popoverElement, finalPosition);
let top, left;
// Positioning logic with the final position
switch (finalPosition) {
case 'top':
top = triggerTop - contentHeight - margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'top-start':
top = triggerTop - contentHeight - margin;
left = triggerLeft;
break;
case 'top-end':
top = triggerTop - contentHeight - margin;
left = triggerRight - contentRect.width;
break;
case 'right':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerRight + margin;
break;
case 'right-start':
top = triggerTop;
left = triggerRight + margin;
break;
case 'right-end':
top = triggerBottom - contentHeight;
left = triggerRight + margin;
break;
case 'bottom':
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'bottom-start':
top = triggerBottom + margin;
left = triggerLeft;
break;
case 'bottom-end':
top = triggerBottom + margin;
left = triggerRight - contentRect.width;
break;
case 'left':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerLeft - contentRect.width - margin;
break;
case 'left-start':
top = triggerTop;
left = triggerLeft - contentRect.width - margin;
break;
case 'left-end':
top = triggerBottom - contentHeight;
left = triggerLeft - contentRect.width - margin;
break;
default:
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
}
// Horizontal boundary - ensures the popover does not overflow the viewport
if (left < 10) {
left = 10; // Minimum distance from the left edge
} else if (left + contentWidth > viewportWidth - 10) {
left = viewportWidth - contentWidth - 10; // Minimum distance from the right edge
}
// Vertical boundary - Optional, can be problematic in some cases
if (top < 10) {
top = 10; // Minimum distance from the top edge
} else if (top + contentHeight > viewportHeight - 10) {
top = viewportHeight - contentHeight - 10; // Minimum distance from the bottom edge
}
popoverElement.style.top = `${top}px`;
popoverElement.style.left = `${left}px`;
};
// Event handler setup
function setupTrigger(trigger) {
const popoverId = trigger.dataset.popoverId;
const template = document.querySelector(`[data-popover-content-template][data-popover-id="${popoverId}"]`);
if (!template) return;
const triggerType = trigger.dataset.popoverType;
// Click handler
if (triggerType === 'click') {
trigger.addEventListener('click', () => {
// If the popover is already active, remove it
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
popover.remove();
activePopovers.delete(popoverId);
return;
}
// Otherwise, create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes from the template
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Set initial position
content.dataset.popoverCurrentPosition = content.dataset.popoverPosition;
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply transition effect
content.classList.remove('popover-transition');
content.classList.remove('show');
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Clickaway handler
if (content.dataset.popoverDisableClickaway !== 'true') {
const clickHandler = (e) => {
if (!trigger.contains(e.target) && !content.contains(e.target)) {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('click', clickHandler);
}
};
// Delay to prevent immediate closing on the current click
setTimeout(() => {
document.addEventListener('click', clickHandler);
}, 0);
}
// ESC handler
if (content.dataset.popoverDisableEsc !== 'true') {
const keyHandler = (e) => {
if (e.key === 'Escape') {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
}
});
} else if (triggerType === 'hover') {
// Hover handlers
let hoverTimeout;
let leaveTimeout;
trigger.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
// Get hover delay from template or use default
const hoverDelay = parseInt(template.dataset.popoverHoverDelay) || 100;
// If the popover is already active, do not recreate it
if (activePopovers.has(popoverId)) return;
// Delay for showing the popover
hoverTimeout = setTimeout(() => {
// Create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply animation
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Hover handler for the content element
content.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
});
content.addEventListener('mouseleave', () => {
// Get hover out delay from template or use default
const hoverOutDelay = parseInt(content.dataset.popoverHoverOutDelay) || 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}, hoverDelay);
});
trigger.addEventListener('mouseleave', (e) => {
// Clear the show timeout if mouse leaves before popover is shown
clearTimeout(hoverTimeout);
// Check if we are hovering over the content
const related = e.relatedTarget;
const content = activePopovers.get(popoverId);
// If we are directly hovering over the content, do not close
if (content && content.contains(related)) {
return;
}
// Get hover out delay from template or use default
const hoverOutDelay = content ?
(parseInt(content.dataset.popoverHoverOutDelay) || 200) : 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}
}
// Set up handlers for each trigger
triggers.forEach(setupTrigger);
// Scroll handler for all popovers
window.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
// Resize handler
window.addEventListener('resize', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
});
// Find all scrollable parent elements and add scroll handlers
function setupScrollHandlers() {
const scrollableElements = new Set();
// Find scrollable parents for each trigger
triggers.forEach(trigger => {
let element = trigger.parentElement;
while (element) {
const style = window.getComputedStyle(element);
const overflow = style.overflow + style.overflowY + style.overflowX;
if (overflow.includes('scroll') || overflow.includes('auto') ||
element.scrollHeight > element.clientHeight) {
scrollableElements.add(element);
}
element = element.parentElement;
}
});
// Scroll handler for each scrollable element
scrollableElements.forEach(element => {
element.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
});
}
setupScrollHandlers();
});
</script>
}
Usage
1. Add the script to your page/layout:
// Option A: All components (recommended)
@utils.ComponentScripts()
// Option B: Just Popover
@components.PopoverScript()
2. Use the component:
@components.Popover(components.PopoverProps{...})
Examples
Trigger & Closing
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
package showcase
import "github.com/axzilla/templui/components"
templ PopoverTriggers() {
<div class="flex gap-2">
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "hover-popover",
TriggerType: components.PopoverTriggerTypeHover,
}) {
@components.Button(components.ButtonProps{Variant: components.ButtonVariantOutline}) {
Hover
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "hover-popover",
HoverDelay: 500,
HoverOutDelay: 500,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "click-popover",
}) {
@components.Button(components.ButtonProps{Variant: components.ButtonVariantOutline}) {
Click
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "click-popover",
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "no-clickaway-popover",
}) {
@components.Button(components.ButtonProps{Variant: components.ButtonVariantOutline}) {
No ClickAway
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "no-clickaway-popover",
DisableClickAway: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "no-clickaway-esc",
}) {
@components.Button(components.ButtonProps{Variant: components.ButtonVariantOutline}) {
No ClickAway-ESC
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "no-clickaway-esc",
DisableClickAway: true,
DisableESC: true,
}) {
@PopoverContent()
}
}
</div>
}
package components
import (
"github.com/axzilla/templui/utils"
"strconv"
)
type PopoverPosition string
const (
PopoverTop PopoverPosition = "top"
PopoverTopStart PopoverPosition = "top-start"
PopoverTopEnd PopoverPosition = "top-end"
PopoverRight PopoverPosition = "right"
PopoverRightStart PopoverPosition = "right-start"
PopoverRightEnd PopoverPosition = "right-end"
PopoverBottom PopoverPosition = "bottom"
PopoverBottomStart PopoverPosition = "bottom-start"
PopoverBottomEnd PopoverPosition = "bottom-end"
PopoverLeft PopoverPosition = "left"
PopoverLeftStart PopoverPosition = "left-start"
PopoverLeftEnd PopoverPosition = "left-end"
)
type PopoverTriggerType string
const (
PopoverTriggerTypeHover PopoverTriggerType = "hover"
PopoverTriggerTypeClick PopoverTriggerType = "click"
)
type PopoverProps struct {
Class string
}
type PopoverTriggerProps struct {
ID string
TriggerType PopoverTriggerType
}
type PopoverContentProps struct {
ID string
Class string
Attributes templ.Attributes
Position PopoverPosition
DisableClickAway bool
DisableESC bool
ShowArrow bool
HoverDelay int
HoverOutDelay int
}
templ popoverPortalContainer() {
<div
id="popover-portal-container"
class="fixed inset-0 z-[9999] pointer-events-none"
></div>
}
templ PopoverTrigger(props ...PopoverTriggerProps) {
{{ var p PopoverTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.TriggerType == "" {
{{ p.TriggerType = PopoverTriggerTypeClick }}
}
<span
data-popover-trigger
data-popover-id={ p.ID }
data-popover-type={ string(p.TriggerType) }
>
{ children... }
</span>
}
templ Popover(props ...PopoverProps) {
{{ var p PopoverProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div class={ utils.TwMerge("relative inline-block", p.Class) }>
{ children... }
</div>
@popoverPortalContainer()
}
templ PopoverContent(props ...PopoverContentProps) {
{{ var p PopoverContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Position == "" {
{{ p.Position = PopoverBottom }}
}
<template
data-popover-content-template
data-popover-id={ p.ID }
data-popover-position={ string(p.Position) }
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) }
>
<div
class={ utils.TwMerge(
"bg-background rounded-lg border text-sm shadow-lg pointer-events-auto absolute z-[9999]",
p.Class,
) }
>
<div class="w-full overflow-hidden">
{ children... }
</div>
if p.ShowArrow {
<!-- We generate all arrows with unique data attributes for easier identification -->
<!-- Top arrows -->
<div
data-arrow={ string(PopoverTop) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-1/2 -translate-x-1/2 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-4 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] right-4 border-t border-l hidden"
></div>
<!-- Bottom arrows -->
<div
data-arrow={ string(PopoverBottom) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-1/2 -translate-x-1/2 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-4 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] right-4 border-b border-r hidden"
></div>
<!-- Left arrows -->
<div
data-arrow={ string(PopoverLeft) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-1/2 -translate-y-1/2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] bottom-2 border-b border-l hidden"
></div>
<!-- Right arrows -->
<div
data-arrow={ string(PopoverRight) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-1/2 -translate-y-1/2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] bottom-2 border-t border-r hidden"
></div>
}
</div>
</template>
}
templ PopoverScript() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', () => {
// Minimal CSS-Animation for the rubber effect
const style = document.createElement('style');
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); }
}
.popover-animate-in {
animation: popover-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.popover-animate-out {
animation: popover-out 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
`;
document.head.appendChild(style);
const portalContainer = document.getElementById('popover-portal-container');
const triggers = document.querySelectorAll('[data-popover-trigger]');
const templates = document.querySelectorAll('[data-popover-content-template]');
// Active popovers
const activePopovers = new Map();
// Function to update the arrow based on the position
const updateArrow = (popoverElement, position) => {
if (popoverElement.dataset.popoverShowArrow !== 'true') return;
// Initially hide all arrows
popoverElement.querySelectorAll('[data-arrow]').forEach(arrow => {
arrow.classList.add('hidden');
});
// Arrow direction is always opposite to the position
// e.g., if the popover is at the bottom, the arrow must point up
let arrowPosition;
if (position.startsWith('top')) {
// If the popover is at the top, the arrow must point down
arrowPosition = position.replace('top', 'bottom');
} else if (position.startsWith('bottom')) {
// If the popover is at the bottom, the arrow must point up
arrowPosition = position.replace('bottom', 'top');
} else if (position.startsWith('left')) {
// If the popover is on the left, the arrow must point right
arrowPosition = position.replace('left', 'right');
} else if (position.startsWith('right')) {
// If the popover is on the right, the arrow must point left
arrowPosition = position.replace('right', 'left');
} else {
// Fallback
arrowPosition = position;
}
// Show the correct arrow based on the direction
const arrow = popoverElement.querySelector(`[data-arrow="${arrowPosition}"]`);
if (arrow) {
arrow.classList.remove('hidden');
}
};
// Positioning function
const positionPopover = (trigger, popoverElement) => {
// Find the actual element the popover refers to
let triggerElement = trigger;
let largestArea = 0;
// Check all direct children of the trigger
const children = trigger.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const rect = child.getBoundingClientRect();
const area = rect.width * rect.height;
if (area > largestArea) {
largestArea = area;
triggerElement = child;
}
}
const triggerRect = triggerElement.getBoundingClientRect();
const contentRect = popoverElement.getBoundingClientRect();
const margin = popoverElement.dataset.popoverShowArrow === 'true' ? 8 : 4;
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// Position from the dataset
const requestedPosition = popoverElement.dataset.popoverPosition || 'bottom';
// We store the final position, which can be adjusted based on viewport space
let finalPosition = requestedPosition;
// Viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Get element heights and widths
const triggerHeight = triggerRect.height;
const contentHeight = contentRect.height;
const contentWidth = contentRect.width;
// Define anchor points
const triggerTop = triggerRect.top + scrollY;
const triggerBottom = triggerRect.bottom + scrollY;
const triggerLeft = triggerRect.left + scrollX;
const triggerRight = triggerRect.right + scrollX;
// Calculate available space in each direction
const spaceAbove = triggerRect.top;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = viewportWidth - triggerRect.right;
// Intelligent position adjustment
// We check the opposite position if there is not enough space
if (finalPosition.startsWith('top') && spaceAbove < contentHeight + margin) {
// If there is not enough space above, show below
finalPosition = finalPosition.replace('top', 'bottom');
} else if (finalPosition.startsWith('bottom') && spaceBelow < contentHeight + margin) {
// If there is not enough space below, show above
finalPosition = finalPosition.replace('bottom', 'top');
} else if (finalPosition.startsWith('left') && spaceLeft < contentWidth + margin) {
// If there is not enough space on the left, show on the right
finalPosition = finalPosition.replace('left', 'right');
} else if (finalPosition.startsWith('right') && spaceRight < contentWidth + margin) {
// If there is not enough space on the right, show on the left
finalPosition = finalPosition.replace('right', 'left');
}
// Store the current position in the element for CSS adjustments (e.g., arrow position)
popoverElement.dataset.popoverCurrentPosition = finalPosition;
// Show the correct arrow
updateArrow(popoverElement, finalPosition);
let top, left;
// Positioning logic with the final position
switch (finalPosition) {
case 'top':
top = triggerTop - contentHeight - margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'top-start':
top = triggerTop - contentHeight - margin;
left = triggerLeft;
break;
case 'top-end':
top = triggerTop - contentHeight - margin;
left = triggerRight - contentRect.width;
break;
case 'right':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerRight + margin;
break;
case 'right-start':
top = triggerTop;
left = triggerRight + margin;
break;
case 'right-end':
top = triggerBottom - contentHeight;
left = triggerRight + margin;
break;
case 'bottom':
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'bottom-start':
top = triggerBottom + margin;
left = triggerLeft;
break;
case 'bottom-end':
top = triggerBottom + margin;
left = triggerRight - contentRect.width;
break;
case 'left':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerLeft - contentRect.width - margin;
break;
case 'left-start':
top = triggerTop;
left = triggerLeft - contentRect.width - margin;
break;
case 'left-end':
top = triggerBottom - contentHeight;
left = triggerLeft - contentRect.width - margin;
break;
default:
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
}
// Horizontal boundary - ensures the popover does not overflow the viewport
if (left < 10) {
left = 10; // Minimum distance from the left edge
} else if (left + contentWidth > viewportWidth - 10) {
left = viewportWidth - contentWidth - 10; // Minimum distance from the right edge
}
// Vertical boundary - Optional, can be problematic in some cases
if (top < 10) {
top = 10; // Minimum distance from the top edge
} else if (top + contentHeight > viewportHeight - 10) {
top = viewportHeight - contentHeight - 10; // Minimum distance from the bottom edge
}
popoverElement.style.top = `${top}px`;
popoverElement.style.left = `${left}px`;
};
// Event handler setup
function setupTrigger(trigger) {
const popoverId = trigger.dataset.popoverId;
const template = document.querySelector(`[data-popover-content-template][data-popover-id="${popoverId}"]`);
if (!template) return;
const triggerType = trigger.dataset.popoverType;
// Click handler
if (triggerType === 'click') {
trigger.addEventListener('click', () => {
// If the popover is already active, remove it
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
popover.remove();
activePopovers.delete(popoverId);
return;
}
// Otherwise, create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes from the template
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Set initial position
content.dataset.popoverCurrentPosition = content.dataset.popoverPosition;
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply transition effect
content.classList.remove('popover-transition');
content.classList.remove('show');
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Clickaway handler
if (content.dataset.popoverDisableClickaway !== 'true') {
const clickHandler = (e) => {
if (!trigger.contains(e.target) && !content.contains(e.target)) {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('click', clickHandler);
}
};
// Delay to prevent immediate closing on the current click
setTimeout(() => {
document.addEventListener('click', clickHandler);
}, 0);
}
// ESC handler
if (content.dataset.popoverDisableEsc !== 'true') {
const keyHandler = (e) => {
if (e.key === 'Escape') {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
}
});
} else if (triggerType === 'hover') {
// Hover handlers
let hoverTimeout;
let leaveTimeout;
trigger.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
// Get hover delay from template or use default
const hoverDelay = parseInt(template.dataset.popoverHoverDelay) || 100;
// If the popover is already active, do not recreate it
if (activePopovers.has(popoverId)) return;
// Delay for showing the popover
hoverTimeout = setTimeout(() => {
// Create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply animation
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Hover handler for the content element
content.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
});
content.addEventListener('mouseleave', () => {
// Get hover out delay from template or use default
const hoverOutDelay = parseInt(content.dataset.popoverHoverOutDelay) || 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}, hoverDelay);
});
trigger.addEventListener('mouseleave', (e) => {
// Clear the show timeout if mouse leaves before popover is shown
clearTimeout(hoverTimeout);
// Check if we are hovering over the content
const related = e.relatedTarget;
const content = activePopovers.get(popoverId);
// If we are directly hovering over the content, do not close
if (content && content.contains(related)) {
return;
}
// Get hover out delay from template or use default
const hoverOutDelay = content ?
(parseInt(content.dataset.popoverHoverOutDelay) || 200) : 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}
}
// Set up handlers for each trigger
triggers.forEach(setupTrigger);
// Scroll handler for all popovers
window.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
// Resize handler
window.addEventListener('resize', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
});
// Find all scrollable parent elements and add scroll handlers
function setupScrollHandlers() {
const scrollableElements = new Set();
// Find scrollable parents for each trigger
triggers.forEach(trigger => {
let element = trigger.parentElement;
while (element) {
const style = window.getComputedStyle(element);
const overflow = style.overflow + style.overflowY + style.overflowX;
if (overflow.includes('scroll') || overflow.includes('auto') ||
element.scrollHeight > element.clientHeight) {
scrollableElements.add(element);
}
element = element.parentElement;
}
});
// Scroll handler for each scrollable element
scrollableElements.forEach(element => {
element.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
});
}
setupScrollHandlers();
});
</script>
}
Positions
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
Dimensions
Set the dimensions for the layer.
package showcase
import "github.com/axzilla/templui/components"
templ PopoverPositions() {
<div class="flex flex-col w-full max-w-md">
<div class="grid grid-cols-3 gap-2">
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "top-start-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Top Start
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "top-start-popover",
Position: components.PopoverTopStart,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "top-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Top
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "top-popover",
Position: components.PopoverTop,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "top-end-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Top End
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "top-end-popover",
Position: components.PopoverTopEnd,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "right-start-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Right Start
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "right-start-popover",
Position: components.PopoverRightStart,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "right-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Right
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "right-popover",
Position: components.PopoverRight,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "right-end-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Right End
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "right-end-popover",
Position: components.PopoverRightEnd,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "bottom-start-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Bottom Start
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "bottom-start-popover",
Position: components.PopoverBottomStart,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "bottom-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Bottom
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "bottom-popover",
Position: components.PopoverBottom,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "bottom-end-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Bottom End
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "bottom-end-popover",
Position: components.PopoverBottomEnd,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "left-start-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Left Start
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "left-start-popover",
Position: components.PopoverLeftStart,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "left-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Left
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "left-popover",
Position: components.PopoverLeft,
ShowArrow: true,
}) {
@PopoverContent()
}
}
@components.Popover() {
@components.PopoverTrigger(components.PopoverTriggerProps{
ID: "left-end-popover",
}) {
@components.Button(components.ButtonProps{
Class: "w-full",
Variant: components.ButtonVariantOutline,
}) {
Left End
}
}
@components.PopoverContent(components.PopoverContentProps{
ID: "left-end-popover",
Position: components.PopoverLeftEnd,
ShowArrow: true,
}) {
@PopoverContent()
}
}
</div>
</div>
}
package components
import (
"github.com/axzilla/templui/utils"
"strconv"
)
type PopoverPosition string
const (
PopoverTop PopoverPosition = "top"
PopoverTopStart PopoverPosition = "top-start"
PopoverTopEnd PopoverPosition = "top-end"
PopoverRight PopoverPosition = "right"
PopoverRightStart PopoverPosition = "right-start"
PopoverRightEnd PopoverPosition = "right-end"
PopoverBottom PopoverPosition = "bottom"
PopoverBottomStart PopoverPosition = "bottom-start"
PopoverBottomEnd PopoverPosition = "bottom-end"
PopoverLeft PopoverPosition = "left"
PopoverLeftStart PopoverPosition = "left-start"
PopoverLeftEnd PopoverPosition = "left-end"
)
type PopoverTriggerType string
const (
PopoverTriggerTypeHover PopoverTriggerType = "hover"
PopoverTriggerTypeClick PopoverTriggerType = "click"
)
type PopoverProps struct {
Class string
}
type PopoverTriggerProps struct {
ID string
TriggerType PopoverTriggerType
}
type PopoverContentProps struct {
ID string
Class string
Attributes templ.Attributes
Position PopoverPosition
DisableClickAway bool
DisableESC bool
ShowArrow bool
HoverDelay int
HoverOutDelay int
}
templ popoverPortalContainer() {
<div
id="popover-portal-container"
class="fixed inset-0 z-[9999] pointer-events-none"
></div>
}
templ PopoverTrigger(props ...PopoverTriggerProps) {
{{ var p PopoverTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.TriggerType == "" {
{{ p.TriggerType = PopoverTriggerTypeClick }}
}
<span
data-popover-trigger
data-popover-id={ p.ID }
data-popover-type={ string(p.TriggerType) }
>
{ children... }
</span>
}
templ Popover(props ...PopoverProps) {
{{ var p PopoverProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div class={ utils.TwMerge("relative inline-block", p.Class) }>
{ children... }
</div>
@popoverPortalContainer()
}
templ PopoverContent(props ...PopoverContentProps) {
{{ var p PopoverContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Position == "" {
{{ p.Position = PopoverBottom }}
}
<template
data-popover-content-template
data-popover-id={ p.ID }
data-popover-position={ string(p.Position) }
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) }
>
<div
class={ utils.TwMerge(
"bg-background rounded-lg border text-sm shadow-lg pointer-events-auto absolute z-[9999]",
p.Class,
) }
>
<div class="w-full overflow-hidden">
{ children... }
</div>
if p.ShowArrow {
<!-- We generate all arrows with unique data attributes for easier identification -->
<!-- Top arrows -->
<div
data-arrow={ string(PopoverTop) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-1/2 -translate-x-1/2 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] left-4 border-t border-l hidden"
></div>
<div
data-arrow={ string(PopoverTopEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background top-[-5px] right-4 border-t border-l hidden"
></div>
<!-- Bottom arrows -->
<div
data-arrow={ string(PopoverBottom) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-1/2 -translate-x-1/2 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] left-4 border-b border-r hidden"
></div>
<div
data-arrow={ string(PopoverBottomEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background bottom-[-5px] right-4 border-b border-r hidden"
></div>
<!-- Left arrows -->
<div
data-arrow={ string(PopoverLeft) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-1/2 -translate-y-1/2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] top-2 border-b border-l hidden"
></div>
<div
data-arrow={ string(PopoverLeftEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background left-[-5px] bottom-2 border-b border-l hidden"
></div>
<!-- Right arrows -->
<div
data-arrow={ string(PopoverRight) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-1/2 -translate-y-1/2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightStart) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] top-2 border-t border-r hidden"
></div>
<div
data-arrow={ string(PopoverRightEnd) }
class="absolute h-2.5 w-2.5 rotate-45 bg-background right-[-5px] bottom-2 border-t border-r hidden"
></div>
}
</div>
</template>
}
templ PopoverScript() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', () => {
// Minimal CSS-Animation for the rubber effect
const style = document.createElement('style');
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); }
}
.popover-animate-in {
animation: popover-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.popover-animate-out {
animation: popover-out 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
`;
document.head.appendChild(style);
const portalContainer = document.getElementById('popover-portal-container');
const triggers = document.querySelectorAll('[data-popover-trigger]');
const templates = document.querySelectorAll('[data-popover-content-template]');
// Active popovers
const activePopovers = new Map();
// Function to update the arrow based on the position
const updateArrow = (popoverElement, position) => {
if (popoverElement.dataset.popoverShowArrow !== 'true') return;
// Initially hide all arrows
popoverElement.querySelectorAll('[data-arrow]').forEach(arrow => {
arrow.classList.add('hidden');
});
// Arrow direction is always opposite to the position
// e.g., if the popover is at the bottom, the arrow must point up
let arrowPosition;
if (position.startsWith('top')) {
// If the popover is at the top, the arrow must point down
arrowPosition = position.replace('top', 'bottom');
} else if (position.startsWith('bottom')) {
// If the popover is at the bottom, the arrow must point up
arrowPosition = position.replace('bottom', 'top');
} else if (position.startsWith('left')) {
// If the popover is on the left, the arrow must point right
arrowPosition = position.replace('left', 'right');
} else if (position.startsWith('right')) {
// If the popover is on the right, the arrow must point left
arrowPosition = position.replace('right', 'left');
} else {
// Fallback
arrowPosition = position;
}
// Show the correct arrow based on the direction
const arrow = popoverElement.querySelector(`[data-arrow="${arrowPosition}"]`);
if (arrow) {
arrow.classList.remove('hidden');
}
};
// Positioning function
const positionPopover = (trigger, popoverElement) => {
// Find the actual element the popover refers to
let triggerElement = trigger;
let largestArea = 0;
// Check all direct children of the trigger
const children = trigger.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
const rect = child.getBoundingClientRect();
const area = rect.width * rect.height;
if (area > largestArea) {
largestArea = area;
triggerElement = child;
}
}
const triggerRect = triggerElement.getBoundingClientRect();
const contentRect = popoverElement.getBoundingClientRect();
const margin = popoverElement.dataset.popoverShowArrow === 'true' ? 8 : 4;
const scrollY = window.scrollY || window.pageYOffset;
const scrollX = window.scrollX || window.pageXOffset;
// Position from the dataset
const requestedPosition = popoverElement.dataset.popoverPosition || 'bottom';
// We store the final position, which can be adjusted based on viewport space
let finalPosition = requestedPosition;
// Viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Get element heights and widths
const triggerHeight = triggerRect.height;
const contentHeight = contentRect.height;
const contentWidth = contentRect.width;
// Define anchor points
const triggerTop = triggerRect.top + scrollY;
const triggerBottom = triggerRect.bottom + scrollY;
const triggerLeft = triggerRect.left + scrollX;
const triggerRight = triggerRect.right + scrollX;
// Calculate available space in each direction
const spaceAbove = triggerRect.top;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = viewportWidth - triggerRect.right;
// Intelligent position adjustment
// We check the opposite position if there is not enough space
if (finalPosition.startsWith('top') && spaceAbove < contentHeight + margin) {
// If there is not enough space above, show below
finalPosition = finalPosition.replace('top', 'bottom');
} else if (finalPosition.startsWith('bottom') && spaceBelow < contentHeight + margin) {
// If there is not enough space below, show above
finalPosition = finalPosition.replace('bottom', 'top');
} else if (finalPosition.startsWith('left') && spaceLeft < contentWidth + margin) {
// If there is not enough space on the left, show on the right
finalPosition = finalPosition.replace('left', 'right');
} else if (finalPosition.startsWith('right') && spaceRight < contentWidth + margin) {
// If there is not enough space on the right, show on the left
finalPosition = finalPosition.replace('right', 'left');
}
// Store the current position in the element for CSS adjustments (e.g., arrow position)
popoverElement.dataset.popoverCurrentPosition = finalPosition;
// Show the correct arrow
updateArrow(popoverElement, finalPosition);
let top, left;
// Positioning logic with the final position
switch (finalPosition) {
case 'top':
top = triggerTop - contentHeight - margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'top-start':
top = triggerTop - contentHeight - margin;
left = triggerLeft;
break;
case 'top-end':
top = triggerTop - contentHeight - margin;
left = triggerRight - contentRect.width;
break;
case 'right':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerRight + margin;
break;
case 'right-start':
top = triggerTop;
left = triggerRight + margin;
break;
case 'right-end':
top = triggerBottom - contentHeight;
left = triggerRight + margin;
break;
case 'bottom':
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
break;
case 'bottom-start':
top = triggerBottom + margin;
left = triggerLeft;
break;
case 'bottom-end':
top = triggerBottom + margin;
left = triggerRight - contentRect.width;
break;
case 'left':
top = triggerTop + (triggerHeight / 2) - (contentHeight / 2);
left = triggerLeft - contentRect.width - margin;
break;
case 'left-start':
top = triggerTop;
left = triggerLeft - contentRect.width - margin;
break;
case 'left-end':
top = triggerBottom - contentHeight;
left = triggerLeft - contentRect.width - margin;
break;
default:
top = triggerBottom + margin;
left = triggerLeft + (triggerRect.width / 2) - (contentRect.width / 2);
}
// Horizontal boundary - ensures the popover does not overflow the viewport
if (left < 10) {
left = 10; // Minimum distance from the left edge
} else if (left + contentWidth > viewportWidth - 10) {
left = viewportWidth - contentWidth - 10; // Minimum distance from the right edge
}
// Vertical boundary - Optional, can be problematic in some cases
if (top < 10) {
top = 10; // Minimum distance from the top edge
} else if (top + contentHeight > viewportHeight - 10) {
top = viewportHeight - contentHeight - 10; // Minimum distance from the bottom edge
}
popoverElement.style.top = `${top}px`;
popoverElement.style.left = `${left}px`;
};
// Event handler setup
function setupTrigger(trigger) {
const popoverId = trigger.dataset.popoverId;
const template = document.querySelector(`[data-popover-content-template][data-popover-id="${popoverId}"]`);
if (!template) return;
const triggerType = trigger.dataset.popoverType;
// Click handler
if (triggerType === 'click') {
trigger.addEventListener('click', () => {
// If the popover is already active, remove it
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
popover.remove();
activePopovers.delete(popoverId);
return;
}
// Otherwise, create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes from the template
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Set initial position
content.dataset.popoverCurrentPosition = content.dataset.popoverPosition;
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply transition effect
content.classList.remove('popover-transition');
content.classList.remove('show');
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Clickaway handler
if (content.dataset.popoverDisableClickaway !== 'true') {
const clickHandler = (e) => {
if (!trigger.contains(e.target) && !content.contains(e.target)) {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('click', clickHandler);
}
};
// Delay to prevent immediate closing on the current click
setTimeout(() => {
document.addEventListener('click', clickHandler);
}, 0);
}
// ESC handler
if (content.dataset.popoverDisableEsc !== 'true') {
const keyHandler = (e) => {
if (e.key === 'Escape') {
// Apply exit animation
content.classList.remove('popover-animate-in');
content.classList.add('popover-animate-out');
// Wait for transition to complete before removing
setTimeout(() => {
content.remove();
activePopovers.delete(popoverId);
}, 100);
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
}
});
} else if (triggerType === 'hover') {
// Hover handlers
let hoverTimeout;
let leaveTimeout;
trigger.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
// Get hover delay from template or use default
const hoverDelay = parseInt(template.dataset.popoverHoverDelay) || 100;
// If the popover is already active, do not recreate it
if (activePopovers.has(popoverId)) return;
// Delay for showing the popover
hoverTimeout = setTimeout(() => {
// Create a new popover
const content = template.content.cloneNode(true).firstElementChild;
// Transfer attributes
Object.keys(template.dataset).forEach(key => {
if (key.startsWith('popover')) {
content.dataset[key] = template.dataset[key];
}
});
// Add to the portal container
portalContainer.appendChild(content);
// Position it
positionPopover(trigger, content);
// Apply animation
content.classList.add('popover-animate-in');
// Add to active popovers
activePopovers.set(popoverId, content);
// Hover handler for the content element
content.addEventListener('mouseenter', () => {
clearTimeout(leaveTimeout);
});
content.addEventListener('mouseleave', () => {
// Get hover out delay from template or use default
const hoverOutDelay = parseInt(content.dataset.popoverHoverOutDelay) || 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}, hoverDelay);
});
trigger.addEventListener('mouseleave', (e) => {
// Clear the show timeout if mouse leaves before popover is shown
clearTimeout(hoverTimeout);
// Check if we are hovering over the content
const related = e.relatedTarget;
const content = activePopovers.get(popoverId);
// If we are directly hovering over the content, do not close
if (content && content.contains(related)) {
return;
}
// Get hover out delay from template or use default
const hoverOutDelay = content ?
(parseInt(content.dataset.popoverHoverOutDelay) || 200) : 200;
leaveTimeout = setTimeout(() => {
if (activePopovers.has(popoverId)) {
const popover = activePopovers.get(popoverId);
// Apply exit animation
popover.classList.remove('popover-animate-in');
popover.classList.add('popover-animate-out');
// Wait for animation to complete before removing
setTimeout(() => {
popover.remove();
activePopovers.delete(popoverId);
}, 100);
}
}, hoverOutDelay);
});
}
}
// Set up handlers for each trigger
triggers.forEach(setupTrigger);
// Scroll handler for all popovers
window.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
// Resize handler
window.addEventListener('resize', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
});
// Find all scrollable parent elements and add scroll handlers
function setupScrollHandlers() {
const scrollableElements = new Set();
// Find scrollable parents for each trigger
triggers.forEach(trigger => {
let element = trigger.parentElement;
while (element) {
const style = window.getComputedStyle(element);
const overflow = style.overflow + style.overflowY + style.overflowX;
if (overflow.includes('scroll') || overflow.includes('auto') ||
element.scrollHeight > element.clientHeight) {
scrollableElements.add(element);
}
element = element.parentElement;
}
});
// Scroll handler for each scrollable element
scrollableElements.forEach(element => {
element.addEventListener('scroll', () => {
activePopovers.forEach((content, popoverId) => {
const trigger = document.querySelector(`[data-popover-trigger][data-popover-id="${popoverId}"]`);
if (trigger) {
positionPopover(trigger, content);
}
});
}, { passive: true });
});
}
setupScrollHandlers();
});
</script>
}