// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Floater component for Insight UI (tooltips and popovers).
*
* Creates floating UI elements that appear on hover or click, with support for
* multiple positions (top, bottom, left, right), arrows, auto-close behavior,
* and mouse-following tooltips.
*
* @example
* // Tooltip
* Hover me
*
* // Popover
*
*
Popover content
*/
export class Floater {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/** @type {Floater|null} Currently open floater instance */
static currentOpen = null;
/**
* Creates a new Floater instance (tooltip or popover).
*
* @param {HTMLElement} trigger - The trigger element
* @param {string} [type='popover'] - The floater type: "tooltip" or "popover"
*/
constructor(trigger, type = 'popover') {
// If an instance for this element already exists, return it
if (Floater.instances.has(trigger)) {
return Floater.instances.get(trigger);
}
this.trigger = trigger;
this.type = type; // 'tooltip' or 'popover'
if (this.trigger.getAttribute("data-show-arrow")) {
this.arrow = document.createElement('div');
this.arrow.classList.add("absolute", "left-1/2", "-translate-x-1/2", "rotate-45", "size-4", "bg-insight-surface", "border-r", "border-b", "border-insight-surface");
}
if (type === "tooltip") {
this.target = document.createElement('span');
this.target.classList.add("text-insight-headline", "bg-insight-surface", "px-3", "py-2", "border", "border-insight-surface", "rounded-insight-overlay", "shadow-insight-overlay", "max-w-xs", "text-sm");
this.target.textContent = this.trigger.getAttribute("data-insight-tooltip");
// Generate unique ID for tooltip and set ARIA attributes
this.targetId = `tooltip-${Math.random().toString(36).substring(2, 9)}`;
this.target.id = this.targetId;
this.target.setAttribute("role", "tooltip");
this.trigger.setAttribute("aria-describedby", this.targetId);
}
else {
this.targetId = trigger.getAttribute("data-insight-popover");
this.target = document.getElementById(this.targetId);
// Set ARIA attributes for popover
if (this.target) {
this.target.setAttribute("role", "dialog");
this.trigger.setAttribute("aria-expanded", "false");
this.trigger.setAttribute("aria-controls", this.targetId);
}
}
if (!this.target)
{
debugLog("No floater target for: ", this.trigger, " found!");
return;
}
if (this.arrow) { this.target.appendChild(this.arrow); }
this.target.classList.add("absolute", "hidden", "z-50");
this.trigger.parentNode.appendChild(this.target);
this.triggerType = trigger.dataset.trigger || 'hover'; // hover or click
this.autoClose = trigger.dataset.autoClose === "true"; // optional for click
this.followMouse = trigger.dataset.followMouse === "true"; // follow mouse position horizontally
this.hideTimeout = null;
this.attributeObserver = null;
// Bind handlers for proper cleanup
this.boundTriggerMouseover = this.handleTriggerMouseover.bind(this);
this.boundTriggerMouseout = this.handleTriggerMouseout.bind(this);
this.boundTargetMouseover = this.handleTargetMouseover.bind(this);
this.boundTargetMouseout = this.handleTargetMouseout.bind(this);
this.boundTriggerClick = this.handleTriggerClick.bind(this);
this.boundDocumentClick = this.handleDocumentClick.bind(this);
this.boundWindowScroll = this.handleWindowScroll.bind(this);
this.boundTriggerMousemove = this.handleTriggerMousemove.bind(this);
this.boundTriggerFocus = this.handleTriggerFocus.bind(this);
this.boundTriggerBlur = this.handleTriggerBlur.bind(this);
this.boundTargetBlur = this.handleTargetBlur.bind(this);
this.boundKeyDown = this.handleKeyDown.bind(this);
this.bindEvents();
this.observeAttributeChanges();
this.trigger.__insightInstance = this;
Floater.instances.set(trigger, this);
if (type === "popover") debugLog("New popover created: ", this.trigger, this.target);
else debugLog("New tooltip created: ", this.trigger, this.target);
}
/**
* Handles mouseover on the trigger element.
*
* @param {MouseEvent} e - The mouseover event
*/
handleTriggerMouseover(e) { e.stopPropagation(); this.show(); }
/**
* Handles mouseout from the trigger element.
*
* @param {MouseEvent} e - The mouseout event
*/
handleTriggerMouseout(e) { e.stopPropagation(); this.hideWithDelay(); }
/**
* Handles mouseover on the target element.
*
* @param {MouseEvent} e - The mouseover event
*/
handleTargetMouseover(e) { e.stopPropagation(); this.show(); }
/**
* Handles mouseout from the target element.
*
* @param {MouseEvent} e - The mouseout event
*/
handleTargetMouseout(e) { e.stopPropagation(); this.hideWithDelay(); }
/**
* Handles mouse movement for follow-mouse mode.
*
* @param {MouseEvent} e - The mousemove event
*/
handleTriggerMousemove(e) {
if (this.followMouse) {
this.updatePositionFollowMouse(e);
}
}
/**
* Observe changes to the data-insight-tooltip attribute for dynamic updates.
*/
observeAttributeChanges() {
if (this.type !== "tooltip") return;
this.attributeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-insight-tooltip') {
this.updateTooltipText();
}
});
});
this.attributeObserver.observe(this.trigger, { attributes: true, attributeFilter: ['data-insight-tooltip'] });
}
/**
* Update the tooltip text from the trigger's data-insight-tooltip attribute.
*/
updateTooltipText() {
if (this.type !== "tooltip" || !this.target) return;
const newText = this.trigger.getAttribute("data-insight-tooltip");
if (this.target.textContent !== newText) {
this.target.textContent = newText;
if (this.arrow) {
this.target.appendChild(this.arrow);
}
}
}
/**
* Handles click on the trigger for click-triggered floaters.
*
* @param {MouseEvent} e - The click event
*/
handleTriggerClick(e) {
e.stopPropagation();
this.target.classList.contains("hidden") ? this.show() : this.hide();
}
/**
* Handles clicks outside the floater to close it (for auto-close mode).
*
* @param {MouseEvent} e - The click event
*/
handleDocumentClick(e) {
if (!this.target.contains(e.target) && !this.trigger.contains(e.target)) {
this.hide();
}
}
/** Updates position on scroll. */
handleWindowScroll() { this.updatePosition(); }
/**
* Handles focus on the trigger element (for keyboard accessibility).
*/
handleTriggerFocus() { this.show(); }
/**
* Handles blur from the trigger element.
* Only hides if focus moves outside both trigger and target.
*
* @param {FocusEvent} e - The blur event
*/
handleTriggerBlur(e) {
// Check if focus is moving to the target (popover/tooltip content)
const relatedTarget = e.relatedTarget;
if (relatedTarget && (this.target.contains(relatedTarget) || this.trigger.contains(relatedTarget))) {
return; // Don't hide if focus is moving within the floater
}
this.hideWithDelay();
}
/**
* Handles blur from the target element (popover/tooltip content).
* Only hides if focus moves outside both trigger and target.
*
* @param {FocusEvent} e - The blur event
*/
handleTargetBlur(e) {
const relatedTarget = e.relatedTarget;
if (relatedTarget && (this.target.contains(relatedTarget) || this.trigger.contains(relatedTarget))) {
return; // Don't hide if focus is moving within the floater
}
this.hideWithDelay();
}
/**
* Handles keyboard events for accessibility.
*
* @param {KeyboardEvent} e - The keyboard event
*/
handleKeyDown(e) {
// Escape closes the floater
if (e.key === 'Escape' && !this.target.classList.contains("hidden")) {
e.preventDefault();
this.hide();
return;
}
// For click-triggered floaters, Enter/Space toggles
if (this.triggerType === 'click' && (e.key === 'Enter' || e.key === ' ')) {
if (document.activeElement === this.trigger) {
e.preventDefault();
this.target.classList.contains("hidden") ? this.show() : this.hide();
}
}
}
/**
* Binds event listeners based on trigger type (hover or click).
*/
bindEvents() {
if (this.triggerType === 'hover') {
this.trigger.addEventListener("mouseover", this.boundTriggerMouseover);
this.trigger.addEventListener("mouseout", this.boundTriggerMouseout);
this.target.addEventListener("mouseover", this.boundTargetMouseover);
this.target.addEventListener("mouseout", this.boundTargetMouseout);
// Keyboard accessibility: show on focus, hide on blur
this.trigger.addEventListener("focus", this.boundTriggerFocus);
this.trigger.addEventListener("blur", this.boundTriggerBlur);
this.target.addEventListener("focusout", this.boundTargetBlur);
if (this.followMouse) {
this.trigger.addEventListener("mousemove", this.boundTriggerMousemove);
}
} else if (this.triggerType === 'click') {
this.trigger.addEventListener("click", this.boundTriggerClick);
if (this.autoClose) {
document.addEventListener("click", this.boundDocumentClick);
}
}
// Keyboard support for both hover and click
this.trigger.addEventListener("keydown", this.boundKeyDown);
window.addEventListener("scroll", this.boundWindowScroll);
}
/**
* Shows the floater and updates its position.
* Closes any other open floater first.
*/
show() {
// Close currently open popover or tooltip
if (Floater.currentOpen && Floater.currentOpen !== this) {
Floater.currentOpen.hide();
}
clearTimeout(this.hideTimeout);
// Check if already visible (e.g., mouse moved from trigger to tooltip)
const wasVisible = !this.target.classList.contains("hidden");
// Re-read tooltip text from attribute to support dynamic updates
if (this.type === "tooltip") {
this.target.textContent = this.trigger.getAttribute("data-insight-tooltip");
if (this.arrow) {
this.target.appendChild(this.arrow);
}
}
this.target.classList.remove("hidden");
// Update aria-expanded for popovers
if (this.type === "popover") {
this.trigger.setAttribute("aria-expanded", "true");
}
// Only update position if tooltip was not already visible
// For followMouse tooltips that are already visible, keep current position
if (!wasVisible) {
this.updatePosition();
}
Floater.currentOpen = this;
}
/**
* Hides the floater after a short delay (for hover mode).
*/
hideWithDelay() {
this.hideTimeout = setTimeout(() => this.hide(), 100);
}
/**
* Immediately hides the floater.
*/
hide() {
this.target.classList.add("hidden");
// Update aria-expanded for popovers
if (this.type === "popover") {
this.trigger.setAttribute("aria-expanded", "false");
}
if (Floater.currentOpen === this) {
Floater.currentOpen = null;
}
}
/**
* Updates the floater position based on trigger location and configured position.
* Handles top, bottom, left, and right positions with arrow placement.
*/
updatePosition() {
const position = this.trigger.getAttribute('data-position') || "top";
const rect = this.trigger.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
// Get the offset parent's position to account for containing blocks with position: relative
const offsetParent = this.target.offsetParent || document.body;
const offsetRect = offsetParent.getBoundingClientRect();
const offsetTop = offsetRect.top + scrollY;
const offsetLeft = offsetRect.left + scrollX;
const distanceToTarget = 12;
const arrowSize = 8;
// Reset arrow position and rotation classes before applying new ones
if (this.arrow) {
this.arrow.classList.remove('rotate-135', 'rotate-225', 'rotate-315');
this.arrow.style.top = '';
this.arrow.style.left = '';
}
// Some directions need slight adjustments, like + or - 1px.
switch (position) {
case 'top':
this.target.style.top = `${rect.top + scrollY - this.target.offsetHeight - distanceToTarget - offsetTop}px`;
this.target.style.left = `${rect.left + scrollX + rect.width / 2 - this.target.offsetWidth / 2 - offsetLeft}px`;
if (this.arrow) { this.arrow.style.top = `${this.target.offsetHeight - (arrowSize + 1)}px`; }
break;
case 'bottom':
this.target.style.top = `${rect.bottom + scrollY + distanceToTarget - offsetTop}px`;
this.target.style.left = `${rect.left + scrollX + rect.width / 2 - this.target.offsetWidth / 2 - offsetLeft}px`;
if (this.arrow) { this.arrow.classList.add('rotate-225'); this.arrow.style.top = `${-(arrowSize + 1)}px`; }
break;
case 'left':
this.target.style.top = `${rect.top + rect.height / 2 - this.target.offsetHeight / 2 + scrollY - offsetTop}px`;
this.target.style.left = `${rect.left + scrollX - this.target.offsetWidth - distanceToTarget - offsetLeft}px`;
if (this.arrow) { this.arrow.classList.add('rotate-315'); this.arrow.style.top = `${this.target.offsetHeight / 2 - arrowSize}px`; this.arrow.style.left = `${this.target.offsetWidth - 1}px`; }
break;
case 'right':
this.target.style.top = `${rect.top + rect.height / 2 - this.target.offsetHeight / 2 + scrollY - offsetTop}px`;
this.target.style.left = `${rect.right + scrollX + distanceToTarget - offsetLeft}px`;
if (this.arrow) { this.arrow.classList.add('rotate-135'); this.arrow.style.top = `${this.target.offsetHeight / 2 - arrowSize}px`; this.arrow.style.left = `-1px`; }
break;
}
}
/**
* Update tooltip position to follow mouse cursor horizontally.
* Vertical position remains relative to the trigger element.
* Tooltip stays within viewport bounds and "sticks" to edges until
* the cursor moves far enough for the tooltip to be centered again.
* @param {MouseEvent} event - The mousemove event
*/
updatePositionFollowMouse(event) {
const position = this.trigger.getAttribute('data-position') || "top";
const triggerRect = this.trigger.getBoundingClientRect();
const parentRect = this.target.parentNode.getBoundingClientRect();
const tooltipWidth = this.target.offsetWidth;
const tooltipHalfWidth = tooltipWidth / 2;
// Calculate horizontal position relative to parent, following mouse
const mouseX = Math.max(triggerRect.left, Math.min(event.clientX, triggerRect.right));
let left = mouseX - parentRect.left - tooltipHalfWidth;
// Clamp to viewport bounds (tooltip sticks to edge until cursor is far enough for centering)
const minLeft = -parentRect.left; // Left edge of viewport relative to parent
const maxLeft = window.innerWidth - parentRect.left - tooltipWidth; // Right edge
left = Math.max(minLeft, Math.min(left, maxLeft));
this.target.style.left = `${left}px`;
// Reset arrow classes before applying new ones
if (this.arrow) {
this.arrow.classList.remove('rotate-180');
this.arrow.style.top = '';
this.arrow.style.left = '';
}
// Vertical position relative to parent
switch (position) {
case 'top':
this.target.style.top = `${triggerRect.top - parentRect.top - this.target.offsetHeight - 8}px`;
if (this.arrow) {
this.arrow.classList.add('rotate-180');
this.arrow.style.top = `${this.target.offsetHeight - 2}px`;
this.arrow.style.left = '50%';
}
break;
case 'bottom':
this.target.style.top = `${triggerRect.bottom - parentRect.top + 8}px`;
if (this.arrow) {
this.arrow.style.left = '50%';
}
break;
}
}
/**
* Destroys the floater instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy floater: ", this.trigger, this.target);
// Clear any pending timeout
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// Remove event listeners based on trigger type
if (this.triggerType === 'hover') {
this.trigger.removeEventListener("mouseover", this.boundTriggerMouseover);
this.trigger.removeEventListener("mouseout", this.boundTriggerMouseout);
this.target.removeEventListener("mouseover", this.boundTargetMouseover);
this.target.removeEventListener("mouseout", this.boundTargetMouseout);
this.trigger.removeEventListener("focus", this.boundTriggerFocus);
this.trigger.removeEventListener("blur", this.boundTriggerBlur);
this.target.removeEventListener("focusout", this.boundTargetBlur);
if (this.followMouse) {
this.trigger.removeEventListener("mousemove", this.boundTriggerMousemove);
}
} else if (this.triggerType === 'click') {
this.trigger.removeEventListener("click", this.boundTriggerClick);
if (this.autoClose) {
document.removeEventListener("click", this.boundDocumentClick);
}
}
this.trigger.removeEventListener("keydown", this.boundKeyDown);
window.removeEventListener("scroll", this.boundWindowScroll);
// Disconnect attribute observer
if (this.attributeObserver) {
this.attributeObserver.disconnect();
this.attributeObserver = null;
}
if (Floater.currentOpen === this) {
Floater.currentOpen = null;
}
// Remove the target element from DOM to prevent orphaned floaters
// when HTMX swaps out the trigger element.
if (this.target && this.target.parentNode) {
this.target.remove();
}
Floater.instances.delete(this.trigger);
delete this.trigger.__insightInstance;
this.trigger = null;
this.target = null;
}
/**
* Initializes all popover and tooltip instances on the page.
*
* @static
*/
static initAll() {
document.querySelectorAll("[data-insight-popover]").forEach(trigger => new Floater(trigger, 'popover'));
document.querySelectorAll("[data-insight-tooltip]").forEach(trigger => new Floater(trigger, 'tooltip'));
}
}