Files
michaelschiemer/resources/js/modules/livecomponent/TooltipManager.js
Michael Schiemer 36ef2a1e2c
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
fix: Gitea Traefik routing and connection pool optimization
- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
2025-11-09 14:46:15 +01:00

389 lines
12 KiB
JavaScript

/**
* Tooltip Manager for LiveComponents
*
* Provides tooltip functionality for LiveComponent elements with:
* - Automatic positioning
* - Validation error tooltips
* - Accessibility support
* - Smooth animations
* - Multiple positioning strategies
*/
export class TooltipManager {
constructor() {
this.tooltips = new Map(); // element → tooltip element
this.activeTooltip = null;
this.config = {
delay: 300, // ms before showing tooltip
hideDelay: 100, // ms before hiding tooltip
maxWidth: 300, // px
offset: 8, // px offset from element
animationDuration: 200, // ms
zIndex: 10000
};
}
/**
* Initialize tooltip for element
*
* @param {HTMLElement} element - Element to attach tooltip to
* @param {Object} options - Tooltip options
*/
init(element, options = {}) {
// Get tooltip content from data attributes or options
const content = options.content ||
element.dataset.tooltip ||
element.getAttribute('title') ||
element.getAttribute('aria-label') ||
'';
if (!content) {
return; // No tooltip content
}
// Remove native title attribute to prevent default tooltip
if (element.hasAttribute('title')) {
element.dataset.originalTitle = element.getAttribute('title');
element.removeAttribute('title');
}
// Get positioning
const position = options.position ||
element.dataset.tooltipPosition ||
'top';
// Setup event listeners
this.setupEventListeners(element, content, position, options);
}
/**
* Setup event listeners for tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position (top, bottom, left, right)
* @param {Object} options - Additional options
*/
setupEventListeners(element, content, position, options) {
let showTimeout;
let hideTimeout;
const showTooltip = () => {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
this.show(element, content, position, options);
}, this.config.delay);
};
const hideTooltip = () => {
clearTimeout(showTimeout);
hideTimeout = setTimeout(() => {
this.hide(element);
}, this.config.hideDelay);
};
// Mouse events
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
element.addEventListener('focus', showTooltip);
element.addEventListener('blur', hideTooltip);
// Touch events (for mobile)
element.addEventListener('touchstart', showTooltip, { passive: true });
element.addEventListener('touchend', hideTooltip, { passive: true });
// Store cleanup function
element._tooltipCleanup = () => {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
element.removeEventListener('mouseenter', showTooltip);
element.removeEventListener('mouseleave', hideTooltip);
element.removeEventListener('focus', showTooltip);
element.removeEventListener('blur', hideTooltip);
element.removeEventListener('touchstart', showTooltip);
element.removeEventListener('touchend', hideTooltip);
};
}
/**
* Show tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position
* @param {Object} options - Additional options
*/
show(element, content, position, options = {}) {
// Hide existing tooltip
if (this.activeTooltip) {
this.hide();
}
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.setAttribute('aria-hidden', 'false');
tooltip.textContent = content;
// Apply styles
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #1f2937;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
// Add to DOM
document.body.appendChild(tooltip);
// Position tooltip
this.positionTooltip(tooltip, element, position);
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store references
this.tooltips.set(element, tooltip);
this.activeTooltip = tooltip;
// Set aria-describedby on element
const tooltipId = `tooltip-${Date.now()}`;
tooltip.id = tooltipId;
element.setAttribute('aria-describedby', tooltipId);
}
/**
* Position tooltip relative to element
*
* @param {HTMLElement} tooltip - Tooltip element
* @param {HTMLElement} element - Target element
* @param {string} position - Position (top, bottom, left, right)
*/
positionTooltip(tooltip, element, position) {
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const offset = this.config.offset;
let top, left;
switch (position) {
case 'top':
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'bottom':
top = rect.bottom + scrollY + offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'left':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.left + scrollX - tooltipRect.width - offset;
break;
case 'right':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.right + scrollX + offset;
break;
default:
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
}
// Keep tooltip within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Horizontal adjustment
if (left < scrollX) {
left = scrollX + 8;
} else if (left + tooltipRect.width > scrollX + viewportWidth) {
left = scrollX + viewportWidth - tooltipRect.width - 8;
}
// Vertical adjustment
if (top < scrollY) {
top = scrollY + 8;
} else if (top + tooltipRect.height > scrollY + viewportHeight) {
top = scrollY + viewportHeight - tooltipRect.height - 8;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
/**
* Hide tooltip
*
* @param {HTMLElement} element - Element (optional, hides active tooltip if not provided)
*/
hide(element = null) {
const tooltip = element ? this.tooltips.get(element) : this.activeTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
// Remove after animation
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
// Remove aria-describedby
if (element) {
element.removeAttribute('aria-describedby');
}
// Clean up references
if (element) {
this.tooltips.delete(element);
}
if (this.activeTooltip === tooltip) {
this.activeTooltip = null;
}
}, this.config.animationDuration);
}
/**
* Show validation error tooltip
*
* @param {HTMLElement} element - Form element
* @param {string} message - Error message
*/
showValidationError(element, message) {
// Remove existing error tooltip
this.hideValidationError(element);
// Show error tooltip with error styling
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip livecomponent-tooltip--error';
tooltip.setAttribute('role', 'alert');
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #dc2626;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
document.body.appendChild(tooltip);
this.positionTooltip(tooltip, element, 'top');
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store reference
element._validationTooltip = tooltip;
// Add error class to element
element.classList.add('livecomponent-error');
}
/**
* Hide validation error tooltip
*
* @param {HTMLElement} element - Form element
*/
hideValidationError(element) {
const tooltip = element._validationTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
delete element._validationTooltip;
element.classList.remove('livecomponent-error');
}, this.config.animationDuration);
}
/**
* Initialize all tooltips in component
*
* @param {HTMLElement} container - Component container
*/
initComponent(container) {
// Find all elements with tooltip attributes
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
this.init(element);
});
}
/**
* Cleanup tooltips for component
*
* @param {HTMLElement} container - Component container
*/
cleanupComponent(container) {
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
// Hide tooltip
this.hide(element);
// Cleanup event listeners
if (element._tooltipCleanup) {
element._tooltipCleanup();
delete element._tooltipCleanup;
}
// Restore original title
if (element.dataset.originalTitle) {
element.setAttribute('title', element.dataset.originalTitle);
delete element.dataset.originalTitle;
}
});
}
/**
* Update configuration
*
* @param {Object} newConfig - New configuration
*/
updateConfig(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const tooltipManager = new TooltipManager();