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
- 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
389 lines
12 KiB
JavaScript
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();
|
|
|