/** * @sc4rfurryx/proteusjs/anchor * CSS Anchor Positioning utilities with robust JS fallback * * @version 2.0.0 * @author sc4rfurry * @license MIT */ export interface TetherOptions { anchor: Element | string; placement?: 'top' | 'bottom' | 'left' | 'right' | 'auto'; align?: 'start' | 'center' | 'end'; offset?: number; strategy?: 'absolute' | 'fixed'; } export interface TetherController { update(): void; destroy(): void; } /** * Declarative tethers (tooltips, callouts) via CSS Anchor Positioning when available; * robust JS fallback with flip/collision detection */ export function tether( floating: Element | string, opts: TetherOptions ): TetherController { const floatingEl = typeof floating === 'string' ? document.querySelector(floating) : floating; const anchorEl = typeof opts.anchor === 'string' ? document.querySelector(opts.anchor) : opts.anchor; if (!floatingEl || !anchorEl) { throw new Error('Both floating and anchor elements must exist'); } const { placement = 'bottom', align = 'center', offset = 8, strategy = 'absolute' } = opts; // Check for CSS Anchor Positioning support const hasAnchorPositioning = CSS.supports('anchor-name', 'test'); let isDestroyed = false; let resizeObserver: ResizeObserver | null = null; const setupCSSAnchorPositioning = () => { if (!hasAnchorPositioning) return false; // Generate unique anchor name const anchorName = `anchor-${Math.random().toString(36).substring(2, 11)}`; // Set anchor name on anchor element (anchorEl as HTMLElement).style.setProperty('anchor-name', anchorName); // Position floating element using CSS anchor positioning const floatingStyle = floatingEl as HTMLElement; floatingStyle.style.position = strategy; floatingStyle.style.setProperty('position-anchor', anchorName); // Set position based on placement switch (placement) { case 'top': floatingStyle.style.bottom = `anchor(bottom, ${offset}px)`; break; case 'bottom': floatingStyle.style.top = `anchor(bottom, ${offset}px)`; break; case 'left': floatingStyle.style.right = `anchor(left, ${offset}px)`; break; case 'right': floatingStyle.style.left = `anchor(right, ${offset}px)`; break; } // Set alignment if (placement === 'top' || placement === 'bottom') { switch (align) { case 'start': floatingStyle.style.left = 'anchor(left)'; break; case 'center': floatingStyle.style.left = 'anchor(center)'; floatingStyle.style.transform = 'translateX(-50%)'; break; case 'end': floatingStyle.style.right = 'anchor(right)'; break; } } else { switch (align) { case 'start': floatingStyle.style.top = 'anchor(top)'; break; case 'center': floatingStyle.style.top = 'anchor(center)'; floatingStyle.style.transform = 'translateY(-50%)'; break; case 'end': floatingStyle.style.bottom = 'anchor(bottom)'; break; } } return true; }; const calculatePosition = () => { const anchorRect = anchorEl.getBoundingClientRect(); const floatingRect = floatingEl.getBoundingClientRect(); const viewport = { width: window.innerWidth, height: window.innerHeight }; let finalPlacement = placement; let x = 0; let y = 0; // Calculate base position switch (finalPlacement) { case 'top': x = anchorRect.left; y = anchorRect.top - floatingRect.height - offset; break; case 'bottom': x = anchorRect.left; y = anchorRect.bottom + offset; break; case 'left': x = anchorRect.left - floatingRect.width - offset; y = anchorRect.top; break; case 'right': x = anchorRect.right + offset; y = anchorRect.top; break; case 'auto': { // Choose best placement based on available space const spaces = { top: anchorRect.top, bottom: viewport.height - anchorRect.bottom, left: anchorRect.left, right: viewport.width - anchorRect.right }; const bestPlacement = Object.entries(spaces).reduce((a, b) => spaces[a[0] as keyof typeof spaces] > spaces[b[0] as keyof typeof spaces] ? a : b )[0] as typeof finalPlacement; finalPlacement = bestPlacement; return calculatePosition(); // Recursive call with determined placement } } // Apply alignment if (finalPlacement === 'top' || finalPlacement === 'bottom') { switch (align) { case 'start': // x already set correctly break; case 'center': x = anchorRect.left + (anchorRect.width - floatingRect.width) / 2; break; case 'end': x = anchorRect.right - floatingRect.width; break; } } else { switch (align) { case 'start': // y already set correctly break; case 'center': y = anchorRect.top + (anchorRect.height - floatingRect.height) / 2; break; case 'end': y = anchorRect.bottom - floatingRect.height; break; } } // Collision detection and adjustment if (x < 0) x = 8; if (y < 0) y = 8; if (x + floatingRect.width > viewport.width) { x = viewport.width - floatingRect.width - 8; } if (y + floatingRect.height > viewport.height) { y = viewport.height - floatingRect.height - 8; } return { x, y }; }; const updatePosition = () => { if (isDestroyed) return; if (!hasAnchorPositioning) { const { x, y } = calculatePosition(); const floatingStyle = floatingEl as HTMLElement; floatingStyle.style.position = strategy; floatingStyle.style.left = `${x}px`; floatingStyle.style.top = `${y}px`; } }; const setupJSFallback = () => { updatePosition(); // Set up observers for position updates resizeObserver = new ResizeObserver(updatePosition); resizeObserver.observe(anchorEl); resizeObserver.observe(floatingEl); window.addEventListener('scroll', updatePosition, { passive: true }); window.addEventListener('resize', updatePosition, { passive: true }); }; const destroy = () => { isDestroyed = true; if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } window.removeEventListener('scroll', updatePosition); window.removeEventListener('resize', updatePosition); // Clean up CSS anchor positioning if (hasAnchorPositioning) { (anchorEl as HTMLElement).style.removeProperty('anchor-name'); const floatingStyle = floatingEl as HTMLElement; floatingStyle.style.removeProperty('position-anchor'); floatingStyle.style.position = ''; } }; // Initialize if (!setupCSSAnchorPositioning()) { setupJSFallback(); } return { update: updatePosition, destroy }; } // Export default object for convenience export default { tether };