diff --git a/docs/styleguide/scripts/tooltip-overlays.js b/docs/styleguide/scripts/tooltip-overlays.js index 8bd2e64..2de1bc4 100644 --- a/docs/styleguide/scripts/tooltip-overlays.js +++ b/docs/styleguide/scripts/tooltip-overlays.js @@ -1,10 +1,11 @@ (function initTooltipOverlayModule() { - const getViewportWidth = () => { - if (window.visualViewport && typeof window.visualViewport.width === 'number') { - return window.visualViewport.width; - } - return window.innerWidth; - }; + let tooltipId = 0; + const states = new WeakMap(); + + const getViewport = () => ({ + width: window.visualViewport?.width || window.innerWidth, + height: window.visualViewport?.height || window.innerHeight, + }); const getPixelValue = (propertyName) => { const rootStyles = getComputedStyle(document.documentElement); @@ -21,144 +22,323 @@ return value; }; - const setTooltipState = (wrap, isOpen) => { - const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]'); - const panel = wrap.querySelector('[data-component-part="tooltip-panel"]'); - if (!trigger || !panel) { - return; + const getElements = (wrap) => ({ + trigger: wrap.querySelector('[data-component-part="tooltip-trigger"]'), + panel: wrap.querySelector('[data-component-part="tooltip-panel"]'), + }); + + const ensureAccessibility = (state) => { + const { trigger, panel } = state; + panel.setAttribute('role', 'tooltip'); + + if (!panel.id) { + tooltipId += 1; + panel.id = `sg-tooltip-panel-${tooltipId}`; } + const describedBy = (trigger.getAttribute('aria-describedby') || '') + .split(/\s+/) + .filter(Boolean); + if (!describedBy.includes(panel.id)) { + trigger.setAttribute('aria-describedby', [...describedBy, panel.id].join(' ')); + } + + const hasAccessibleName = Boolean( + trigger.getAttribute('aria-label') + || trigger.getAttribute('aria-labelledby') + || trigger.getAttribute('title') + || trigger.textContent.trim(), + ); + if (!hasAccessibleName && panel.textContent.trim()) { + trigger.setAttribute('aria-label', panel.textContent.trim()); + } + + if (!trigger.matches('a,button,input,select,textarea,[tabindex]')) { + trigger.setAttribute('tabindex', '0'); + } + }; + + const setOpenState = (state, isOpen) => { + const { wrap, panel, trigger } = state; wrap.dataset.tooltipOpen = String(isOpen); panel.hidden = !isOpen; - trigger.setAttribute('aria-expanded', String(isOpen)); + + if (trigger.matches('button,[role="button"],[aria-expanded]')) { + trigger.setAttribute('aria-expanded', String(isOpen)); + } }; - const positionTooltip = (wrap) => { - const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]'); - const panel = wrap.querySelector('[data-component-part="tooltip-panel"]'); - if (!trigger || !panel || panel.hidden) { + const restorePanel = (state) => { + const { panel, placeholder } = state; + panel.hidden = true; + panel.removeAttribute('data-tooltip-portal'); + panel.removeAttribute('data-tooltip-placement'); + panel.style.removeProperty('left'); + panel.style.removeProperty('top'); + panel.style.removeProperty('visibility'); + panel.style.removeProperty('--tooltip-arrow-offset'); + + if (placeholder.parentNode) { + placeholder.parentNode.insertBefore(panel, placeholder.nextSibling); + } + }; + + const movePanelToPortal = (state) => { + const { panel } = state; + if (panel.parentNode !== document.body) { + document.body.appendChild(panel); + } + panel.setAttribute('data-tooltip-portal', 'true'); + panel.hidden = false; + panel.style.visibility = 'hidden'; + }; + + const setArrowOffset = (state, placement, panelLeft, panelTop, panelWidth, panelHeight) => { + const { trigger, panel } = state; + const triggerRect = trigger.getBoundingClientRect(); + const offset = placement === 'left' || placement === 'right' + ? triggerRect.top + (triggerRect.height / 2) - panelTop + : triggerRect.left + (triggerRect.width / 2) - panelLeft; + const maxOffset = placement === 'left' || placement === 'right' ? panelHeight : panelWidth; + panel.style.setProperty('--tooltip-arrow-offset', `${Math.max(0, Math.min(offset, maxOffset))}px`); + }; + + const positionTooltip = (state) => { + const { trigger, panel } = state; + if (panel.hidden || panel.parentNode !== document.body) { return; } - const viewportWidth = getViewportWidth(); + const viewport = getViewport(); const safeInset = getPixelValue('--spacing-large'); - const triggerRect = trigger.getBoundingClientRect(); - const panelRect = panel.getBoundingClientRect(); const gap = getPixelValue('--spacing-small'); + const triggerRect = trigger.getBoundingClientRect(); + const panelWidth = panel.offsetWidth; + const panelHeight = panel.offsetHeight; + const centeredLeft = triggerRect.left + (triggerRect.width / 2) - (panelWidth / 2); + const centeredRight = centeredLeft + panelWidth; + const hasBottomSpace = triggerRect.bottom + gap + panelHeight <= viewport.height - safeInset; + const hasTopSpace = triggerRect.top - gap - panelHeight >= safeInset; + const hasLeftSpace = triggerRect.left - gap - panelWidth >= safeInset; + const hasRightSpace = triggerRect.right + gap + panelWidth <= viewport.width - safeInset; let placement = 'bottom'; - if (triggerRect.right + (panelRect.width / 2) > viewportWidth - safeInset) { + if (centeredRight > viewport.width - safeInset && hasLeftSpace) { placement = 'left'; - } else if (triggerRect.left - (panelRect.width / 2) < safeInset) { + } else if (centeredLeft < safeInset && hasRightSpace) { placement = 'right'; + } else if (!hasBottomSpace && hasTopSpace) { + placement = 'top'; } - wrap.dataset.tooltipPlacement = placement; + let panelLeft = centeredLeft; + let panelTop = triggerRect.bottom + gap; - if (placement === 'bottom') { - const nextPanelRect = panel.getBoundingClientRect(); - if (nextPanelRect.bottom > window.innerHeight - safeInset - && triggerRect.top - panelRect.height - gap >= safeInset) { - wrap.dataset.tooltipPlacement = 'top'; - } + if (placement === 'top') { + panelTop = triggerRect.top - gap - panelHeight; + } else if (placement === 'left') { + panelLeft = triggerRect.left - gap - panelWidth; + panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2); + } else if (placement === 'right') { + panelLeft = triggerRect.right + gap; + panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2); + } + + panelLeft = Math.max(safeInset, Math.min(panelLeft, viewport.width - safeInset - panelWidth)); + panelTop = Math.max(safeInset, Math.min(panelTop, viewport.height - safeInset - panelHeight)); + + panel.dataset.tooltipPlacement = placement; + state.wrap.dataset.tooltipPlacement = placement; + panel.style.left = `${Math.round(panelLeft)}px`; + panel.style.top = `${Math.round(panelTop)}px`; + setArrowOffset(state, placement, panelLeft, panelTop, panelWidth, panelHeight); + panel.style.visibility = 'visible'; + }; + + const openTooltip = (state) => { + movePanelToPortal(state); + setOpenState(state, true); + positionTooltip(state); + }; + + const closeTooltip = (state) => { + state.pinned = false; + state.hoverTrigger = false; + state.hoverPanel = false; + state.focused = false; + state.dismissed = false; + setOpenState(state, false); + restorePanel(state); + }; + + const closeIfInactive = (state) => { + if (!state.pinned && !state.hoverTrigger && !state.hoverPanel && !state.focused) { + setOpenState(state, false); + restorePanel(state); } }; - const closeTooltip = (wrap) => { - wrap.dataset.tooltipPinned = 'false'; - wrap.dataset.tooltipHover = 'false'; - wrap.dataset.tooltipFocus = 'false'; - wrap.dataset.tooltipDismissed = 'false'; - setTooltipState(wrap, false); - }; + const togglePinned = (state) => { + if (state.pinned) { + state.pinned = false; + state.dismissed = true; + setOpenState(state, false); + restorePanel(state); + return; + } - const openTooltip = (wrap) => { - setTooltipState(wrap, true); - positionTooltip(wrap); + state.pinned = true; + state.dismissed = false; + openTooltip(state); }; const initTooltip = (wrap) => { - if (wrap.dataset.tooltipInit === 'true') { + if (states.has(wrap)) { return; } + + const elements = getElements(wrap); + if (!elements.trigger || !elements.panel || !elements.panel.parentNode) { + return; + } + + const placeholder = document.createComment('sg-tooltip-panel-placeholder'); + elements.panel.parentNode.insertBefore(placeholder, elements.panel); + + const state = { + wrap, + trigger: elements.trigger, + panel: elements.panel, + placeholder, + pinned: false, + hoverTrigger: false, + hoverPanel: false, + focused: false, + dismissed: false, + }; wrap.dataset.tooltipInit = 'true'; wrap.dataset.tooltipPlacement = 'bottom'; - wrap.dataset.tooltipPinned = 'false'; - wrap.dataset.tooltipHover = 'false'; - wrap.dataset.tooltipFocus = 'false'; - wrap.dataset.tooltipDismissed = 'false'; - setTooltipState(wrap, false); - - const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]'); - if (!trigger) { - return; - } + states.set(wrap, state); + ensureAccessibility(state); + setOpenState(state, false); wrap.addEventListener('pointerenter', () => { - if (wrap.dataset.tooltipDismissed === 'true') { + if (state.dismissed) { return; } - wrap.dataset.tooltipHover = 'true'; - openTooltip(wrap); + state.hoverTrigger = true; + openTooltip(state); }); - wrap.addEventListener('pointerleave', () => { - wrap.dataset.tooltipHover = 'false'; - wrap.dataset.tooltipDismissed = 'false'; - if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipFocus !== 'true') { - setTooltipState(wrap, false); + wrap.addEventListener('pointerleave', (event) => { + state.hoverTrigger = false; + state.dismissed = false; + if (!state.panel.contains(event.relatedTarget)) { + closeIfInactive(state); } }); + state.panel.addEventListener('pointerenter', () => { + if (!state.dismissed) { + state.hoverPanel = true; + openTooltip(state); + } + }); + + state.panel.addEventListener('pointerleave', () => { + state.hoverPanel = false; + state.dismissed = false; + closeIfInactive(state); + }); + wrap.addEventListener('focusin', () => { - if (wrap.dataset.tooltipDismissed === 'true') { - return; + if (!state.dismissed) { + state.focused = true; + openTooltip(state); } - wrap.dataset.tooltipFocus = 'true'; - openTooltip(wrap); }); wrap.addEventListener('focusout', (event) => { - if (wrap.contains(event.relatedTarget)) { + if (wrap.contains(event.relatedTarget) || state.panel.contains(event.relatedTarget)) { return; } - wrap.dataset.tooltipFocus = 'false'; - wrap.dataset.tooltipDismissed = 'false'; - if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipHover !== 'true') { - setTooltipState(wrap, false); - } + state.focused = false; + state.dismissed = false; + closeIfInactive(state); }); - trigger.addEventListener('click', (event) => { + state.trigger.addEventListener('click', (event) => { event.stopPropagation(); - const isPinned = wrap.dataset.tooltipPinned === 'true'; - wrap.dataset.tooltipPinned = String(!isPinned); + togglePinned(state); + }); - if (isPinned) { - wrap.dataset.tooltipDismissed = 'true'; - setTooltipState(wrap, false); - return; + state.trigger.addEventListener('keydown', (event) => { + const isNativeInteractive = state.trigger.matches('a,button,input,select,textarea'); + if (!isNativeInteractive && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + togglePinned(state); } - - wrap.dataset.tooltipDismissed = 'false'; - openTooltip(wrap); }); }; - document.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip); - - document.addEventListener('click', (event) => { - document.querySelectorAll('.sg-tooltip-wrap').forEach((wrap) => { - if (!wrap.contains(event.target)) { - closeTooltip(wrap); - } - }); - }); - - const repositionOpenTooltips = () => { - document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach(positionTooltip); + const initializeWithin = (root) => { + if (root.nodeType === Node.DOCUMENT_NODE) { + root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip); + return; + } + if (root.nodeType !== Node.ELEMENT_NODE) { + return; + } + const owningWrap = root.closest('.sg-tooltip-wrap'); + if (owningWrap) { + initTooltip(owningWrap); + } + if (root.matches('.sg-tooltip-wrap')) { + initTooltip(root); + } + root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip); }; - window.addEventListener('resize', repositionOpenTooltips); - window.addEventListener('scroll', repositionOpenTooltips, true); + const bootstrap = () => { + window.sgInitTooltips = (options = {}) => { + const root = options.root || document.body; + initializeWithin(root); + }; + + window.sgInitTooltips(); + + const observer = new MutationObserver((records) => { + records.forEach((record) => { + record.addedNodes.forEach(initializeWithin); + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + document.addEventListener('click', (event) => { + document.querySelectorAll('.sg-tooltip-wrap').forEach((wrap) => { + const state = states.get(wrap); + if (state && !wrap.contains(event.target) && !state.panel.contains(event.target)) { + closeTooltip(state); + } + }); + }); + + const repositionOpenTooltips = () => { + document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach((wrap) => { + const state = states.get(wrap); + if (state) { + positionTooltip(state); + } + }); + }; + + window.addEventListener('resize', repositionOpenTooltips); + window.addEventListener('scroll', repositionOpenTooltips, true); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); + } else { + bootstrap(); + } })(); diff --git a/docs/styleguide/styles/10-components-interactive-elements.css b/docs/styleguide/styles/10-components-interactive-elements.css index 29f655b..1776b76 100644 --- a/docs/styleguide/styles/10-components-interactive-elements.css +++ b/docs/styleguide/styles/10-components-interactive-elements.css @@ -1240,9 +1240,7 @@ } .sg-tooltip-wrap { - position: relative; - display: inline-flex; - vertical-align: middle; + display: contents; } .sg-tooltip-panel { @@ -1328,6 +1326,47 @@ transform: translateY(-50%); } + .sg-tooltip-panel[data-tooltip-portal="true"] { + position: fixed; + top: 0; + left: 0; + z-index: var(--layer-pulldown-panel); + max-width: calc(100vw - (2 * var(--spacing-large))); + transform: none; + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before { + top: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent var(--surface-tooltip); + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before { + bottom: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: var(--spacing-small) var(--spacing-small) 0; + border-color: var(--surface-tooltip) transparent transparent; + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before { + top: var(--tooltip-arrow-offset); + right: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent transparent var(--surface-tooltip); + transform: translateY(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before { + top: var(--tooltip-arrow-offset); + left: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0; + border-color: transparent var(--surface-tooltip) transparent transparent; + transform: translateY(-50%); + } + .sg-pulldown-panel { position: absolute; top: calc(100% + var(--spacing-small)); diff --git a/public/assets/styleguide.upstream.css b/public/assets/styleguide.upstream.css index adb9bbb..044623f 100644 --- a/public/assets/styleguide.upstream.css +++ b/public/assets/styleguide.upstream.css @@ -1765,9 +1765,7 @@ section + section { } .sg-tooltip-wrap { - position: relative; - display: inline-flex; - vertical-align: middle; + display: contents; } .sg-tooltip-panel { @@ -1853,6 +1851,47 @@ section + section { transform: translateY(-50%); } + .sg-tooltip-panel[data-tooltip-portal="true"] { + position: fixed; + top: 0; + left: 0; + z-index: var(--layer-pulldown-panel); + max-width: calc(100vw - (2 * var(--spacing-large))); + transform: none; + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before { + top: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent var(--surface-tooltip); + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before { + bottom: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: var(--spacing-small) var(--spacing-small) 0; + border-color: var(--surface-tooltip) transparent transparent; + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before { + top: var(--tooltip-arrow-offset); + right: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent transparent var(--surface-tooltip); + transform: translateY(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before { + top: var(--tooltip-arrow-offset); + left: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0; + border-color: transparent var(--surface-tooltip) transparent transparent; + transform: translateY(-50%); + } + .sg-pulldown-panel { position: absolute; top: calc(100% + var(--spacing-small)); diff --git a/public/assets/styleguide.upstream.meta.json b/public/assets/styleguide.upstream.meta.json index a851b7c..2989ac9 100644 --- a/public/assets/styleguide.upstream.meta.json +++ b/public/assets/styleguide.upstream.meta.json @@ -1,7 +1,7 @@ { "styleguideVersion": "2026.05.18.1", - "styleguideCommit": "944d791", - "syncedAtUtc": "2026-09-21T12:42:55Z", + "styleguideCommit": "060eb86", + "syncedAtUtc": "2026-09-21T14:06:04Z", "sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide", "mirroredDocsPath": "docs/styleguide" } diff --git a/public/assets/styles.css b/public/assets/styles.css index adb9bbb..044623f 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -1765,9 +1765,7 @@ section + section { } .sg-tooltip-wrap { - position: relative; - display: inline-flex; - vertical-align: middle; + display: contents; } .sg-tooltip-panel { @@ -1853,6 +1851,47 @@ section + section { transform: translateY(-50%); } + .sg-tooltip-panel[data-tooltip-portal="true"] { + position: fixed; + top: 0; + left: 0; + z-index: var(--layer-pulldown-panel); + max-width: calc(100vw - (2 * var(--spacing-large))); + transform: none; + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before { + top: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent var(--surface-tooltip); + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before { + bottom: calc(var(--spacing-small) * -1); + left: var(--tooltip-arrow-offset); + border-width: var(--spacing-small) var(--spacing-small) 0; + border-color: var(--surface-tooltip) transparent transparent; + transform: translateX(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before { + top: var(--tooltip-arrow-offset); + right: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small); + border-color: transparent transparent transparent var(--surface-tooltip); + transform: translateY(-50%); + } + + .sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before { + top: var(--tooltip-arrow-offset); + left: calc(var(--spacing-small) * -1); + border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0; + border-color: transparent var(--surface-tooltip) transparent transparent; + transform: translateY(-50%); + } + .sg-pulldown-panel { position: absolute; top: calc(100% + var(--spacing-small));