diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 8c50202c..cfc64401 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -1565,55 +1565,9 @@ input:checked + .toggle-slider:before { opacity: 1; } -/* Tooltip using data-tooltip attribute */ -.info-icon[data-tooltip] { - position: relative; -} - -.info-icon[data-tooltip]::after { - content: attr(data-tooltip); - position: absolute; - bottom: calc(100% + 8px); - background: rgba(0, 0, 0, 0.9); - color: white; - padding: 8px 12px; - border-radius: 6px; - font-size: 14px; - font-weight: normal; - font-family: var(--font-body); - white-space: normal; - max-width: 220px; - width: max-content; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - pointer-events: none; - z-index: 10000; - line-height: 1.4; - box-shadow: var(--shadow-elevated); - text-transform: none; -} - -.info-icon[data-tooltip]:hover::after { - opacity: 1; - visibility: visible; -} - -/* Fix tooltip overflow on left edge - when icon is near left side of modal */ -.settings-subsection-header .info-icon[data-tooltip]::after { - left: 0; - transform: translateX(0); -} - -.settings-subsection-header .info-icon[data-tooltip]::before { - left: 12px; -} - -/* Dark theme adjustments for tooltip - Fully opaque */ -[data-theme="dark"] .info-icon[data-tooltip]::after { - background: rgba(40, 40, 40, 0.95); - box-shadow: var(--shadow-dark-lg); -} +/* Help tooltips are rendered by static/js/utils/tooltip.js into instead + of a pseudo-element, so the scrollable settings pane cannot clip them - see + static/css/components/tooltip.css. */ /* Extra Folder Paths - Single input layout */ .extra-folder-path-row { diff --git a/static/css/components/tooltip.css b/static/css/components/tooltip.css new file mode 100644 index 00000000..fcdebbad --- /dev/null +++ b/static/css/components/tooltip.css @@ -0,0 +1,41 @@ +/* Floating tooltip for [data-tooltip] triggers. + Rendered into by static/js/utils/tooltip.js and positioned with + `position: fixed`, so scrollable ancestors (e.g. .settings-content) cannot + clip it. */ +.lm-tooltip { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-tooltip); + box-sizing: border-box; + width: max-content; + max-width: min(320px, calc(100vw - 16px)); + padding: 8px 12px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.9); + color: white; + font-family: var(--font-body); + font-size: 14px; + font-weight: normal; + line-height: 1.4; + text-align: left; + text-transform: none; + white-space: normal; + overflow-wrap: break-word; + box-shadow: var(--shadow-elevated); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + pointer-events: none; +} + +.lm-tooltip.visible { + opacity: 1; + visibility: visible; +} + +/* Dark theme adjustments for tooltip - Fully opaque */ +[data-theme="dark"] .lm-tooltip { + background: rgba(40, 40, 40, 0.95); + box-shadow: var(--shadow-dark-lg); +} diff --git a/static/css/style.css b/static/css/style.css index a08747a3..78828eba 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -20,6 +20,7 @@ @import 'components/modal/download-modal.css'; @import 'components/modal/directory-picker-modal.css'; @import 'components/toast.css'; +@import 'components/tooltip.css'; @import 'components/loading.css'; @import 'components/menu.css'; @import 'components/lora-modal/lora-modal.css'; diff --git a/static/js/core.js b/static/js/core.js index 866a136a..9a649179 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -13,6 +13,7 @@ import { helpManager } from './managers/HelpManager.js'; import { doctorManager } from './managers/DoctorManager.js'; import { bannerService } from './managers/BannerService.js'; import { initTheme, initBackToTop } from './utils/uiHelpers.js'; +import { initTooltips } from './utils/tooltip.js'; import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { i18n } from './i18n/index.js'; @@ -75,6 +76,7 @@ export class AppCore { window.headerManager = new HeaderManager(); initTheme(); initBackToTop(); + initTooltips(); // Initialize the bulk manager and context menu bulkManager.initialize(); diff --git a/static/js/utils/tooltip.js b/static/js/utils/tooltip.js new file mode 100644 index 00000000..efc7cbf5 --- /dev/null +++ b/static/js/utils/tooltip.js @@ -0,0 +1,192 @@ +/** + * Floating tooltip for `[data-tooltip]` triggers. + * + * A CSS pseudo-element tooltip is a descendant of the element that owns it, so + * any scrollable ancestor clips it: the settings modal scrolls + * (`.settings-content` uses `overflow-y: auto`), which chopped the first lines + * off help texts whose trigger sits near the top of the pane (the Active + * Library deprecation notice was the visible symptom). + * + * The tooltip is therefore rendered once into `` and positioned with + * `position: fixed`: no ancestor can clip it. It prefers the space above the + * trigger and flips below when it does not fit, and it is clamped to the + * viewport so it never spills off screen. + */ + +const TOOLTIP_ID = 'lm-tooltip'; +const TRIGGER_SELECTOR = '[data-tooltip]'; +const VISIBLE_CLASS = 'visible'; + +// Distance kept between the trigger and the tooltip. +const TRIGGER_GAP = 8; +// Distance kept between the tooltip and the viewport edges. +const VIEWPORT_MARGIN = 8; + +let tooltipElement = null; +let activeTrigger = null; +let initialized = false; + +function viewportWidth() { + return window.innerWidth || document.documentElement.clientWidth || 0; +} + +function viewportHeight() { + return window.innerHeight || document.documentElement.clientHeight || 0; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), Math.max(min, max)); +} + +function getTooltipElement() { + // The element can be dropped by a DOM reset, so re-create it when detached. + if (tooltipElement && tooltipElement.isConnected) { + return tooltipElement; + } + + tooltipElement = document.getElementById(TOOLTIP_ID); + if (!tooltipElement) { + tooltipElement = document.createElement('div'); + tooltipElement.id = TOOLTIP_ID; + tooltipElement.className = 'lm-tooltip'; + tooltipElement.setAttribute('role', 'tooltip'); + document.body.appendChild(tooltipElement); + } + + return tooltipElement; +} + +function positionTooltip(trigger) { + const element = getTooltipElement(); + const triggerRect = trigger.getBoundingClientRect(); + const tooltipRect = element.getBoundingClientRect(); + + // Prefer above the trigger, flip below when there is not enough room. + const fitsAbove = triggerRect.top - TRIGGER_GAP - tooltipRect.height >= VIEWPORT_MARGIN; + const preferredTop = fitsAbove + ? triggerRect.top - tooltipRect.height - TRIGGER_GAP + : triggerRect.bottom + TRIGGER_GAP; + + const maxTop = viewportHeight() - tooltipRect.height - VIEWPORT_MARGIN; + const maxLeft = viewportWidth() - tooltipRect.width - VIEWPORT_MARGIN; + + element.style.top = `${Math.round(clamp(preferredTop, VIEWPORT_MARGIN, maxTop))}px`; + // Center on the trigger, then keep the box inside the viewport. + element.style.left = `${Math.round( + clamp(triggerRect.left + (triggerRect.width - tooltipRect.width) / 2, VIEWPORT_MARGIN, maxLeft) + )}px`; +} + +function showTooltip(trigger) { + const text = trigger.getAttribute('data-tooltip'); + if (!text) return; + + const element = getTooltipElement(); + activeTrigger = trigger; + element.textContent = text; + + // Measured while hidden: `visibility: hidden` still lays the box out, which + // keeps the tooltip from flashing at the previous trigger's position. + element.classList.remove(VISIBLE_CLASS); + positionTooltip(trigger); + element.classList.add(VISIBLE_CLASS); + + trigger.setAttribute('aria-describedby', TOOLTIP_ID); +} + +/** + * Hide the tooltip if one is showing. + */ +export function hideTooltip() { + if (activeTrigger) { + activeTrigger.removeAttribute('aria-describedby'); + activeTrigger = null; + } + + if (tooltipElement) { + tooltipElement.classList.remove(VISIBLE_CLASS); + } +} + +function triggerFromEvent(event) { + const target = event.target; + if (!target || typeof target.closest !== 'function') return null; + return target.closest(TRIGGER_SELECTOR); +} + +function handlePointerOver(event) { + const trigger = triggerFromEvent(event); + // Moving between children of the same trigger must not restart the tooltip. + if (!trigger || trigger === activeTrigger) return; + showTooltip(trigger); +} + +function handlePointerOut(event) { + if (!activeTrigger) return; + + const related = event.relatedTarget; + if (related && activeTrigger.contains(related)) return; + + if (triggerFromEvent(event) === activeTrigger) { + hideTooltip(); + } +} + +function handleFocusIn(event) { + const trigger = triggerFromEvent(event); + if (trigger && trigger !== activeTrigger) { + showTooltip(trigger); + } +} + +function handleFocusOut(event) { + if (!activeTrigger) return; + + const related = event.relatedTarget; + if (related && activeTrigger.contains(related)) return; + + if (triggerFromEvent(event) === activeTrigger) { + hideTooltip(); + } +} + +function handleKeyDown(event) { + if (event.key === 'Escape') { + hideTooltip(); + } +} + +function handleViewportChange() { + if (!activeTrigger) return; + + // The trigger can be removed while its tooltip is open (settings sections + // are re-rendered on search). + if (!activeTrigger.isConnected) { + hideTooltip(); + return; + } + + positionTooltip(activeTrigger); +} + +/** + * Start listening for tooltip triggers. Delegated, so triggers added later + * (settings rows are re-rendered) work without extra wiring. Idempotent. + */ +export function initTooltips() { + if (initialized) return; + initialized = true; + + document.addEventListener('mouseover', handlePointerOver); + document.addEventListener('mouseout', handlePointerOut); + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); + document.addEventListener('keydown', handleKeyDown); + // Tapping a trigger on touch devices leaves no mouseout behind. + document.addEventListener('touchstart', hideTooltip, { passive: true }); + + // Capture phase so scrolling any container (the settings pane included) + // keeps the tooltip attached to its trigger. + window.addEventListener('scroll', handleViewportChange, true); + window.addEventListener('resize', handleViewportChange); +} diff --git a/tests/frontend/utils/tooltip.test.js b/tests/frontend/utils/tooltip.test.js new file mode 100644 index 00000000..8e9dddd0 --- /dev/null +++ b/tests/frontend/utils/tooltip.test.js @@ -0,0 +1,159 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { initTooltips, hideTooltip } from '../../../static/js/utils/tooltip.js'; + +const TOOLTIP_WIDTH = 200; +const TOOLTIP_HEIGHT = 60; + +function makeRect({ top = 0, left = 0, width = 0, height = 0 } = {}) { + return { + top, + left, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON() { return this; } + }; +} + +/** + * Mount a trigger inside a scrollable container so the tooltip can be asserted + * to escape the clipping ancestor. + */ +function mountTrigger({ id = 'helpIcon', tooltip = 'Help text', rect } = {}) { + const scroller = document.createElement('div'); + scroller.className = 'settings-content'; + scroller.style.overflowY = 'auto'; + + const icon = document.createElement('i'); + icon.id = id; + icon.className = 'fas fa-info-circle info-icon'; + if (tooltip !== null) { + icon.setAttribute('data-tooltip', tooltip); + } + // Own-property override so a test can move the trigger (scroll) later. + icon.getBoundingClientRect = () => rectByElement.get(icon) || makeRect(); + rectByElement.set(icon, rect); + + scroller.appendChild(icon); + document.body.appendChild(scroller); + return icon; +} + +let rectByElement; + +beforeEach(() => { + rectByElement = new Map(); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.id === 'lm-tooltip') { + return makeRect({ width: TOOLTIP_WIDTH, height: TOOLTIP_HEIGHT }); + } + return rectByElement.get(this) || makeRect(); + }); + initTooltips(); +}); + +afterEach(() => { + hideTooltip(); + vi.restoreAllMocks(); +}); + +describe('tooltip', () => { + it('renders the tooltip in the body, outside the scrollable container', () => { + const icon = mountTrigger({ + tooltip: 'First line. Second line.', + rect: makeRect({ top: 400, left: 100, width: 16, height: 16 }) + }); + + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip).not.toBeNull(); + expect(tooltip.parentElement).toBe(document.body); + expect(tooltip.closest('.settings-content')).toBeNull(); + expect(tooltip.textContent).toBe('First line. Second line.'); + expect(tooltip.classList.contains('visible')).toBe(true); + expect(icon.getAttribute('aria-describedby')).toBe('lm-tooltip'); + }); + + it('places the tooltip above the trigger when it fits', () => { + const icon = mountTrigger({ rect: makeRect({ top: 500, left: 100, width: 16, height: 16 }) }); + + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip.style.top).toBe(`${500 - TOOLTIP_HEIGHT - 8}px`); + }); + + it('flips below the trigger when there is no room above', () => { + const icon = mountTrigger({ rect: makeRect({ top: 20, left: 100, width: 16, height: 16 }) }); + + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip.style.top).toBe(`${20 + 16 + 8}px`); + }); + + it('clamps the tooltip inside the viewport', () => { + const icon = mountTrigger({ rect: makeRect({ top: 400, left: 0, width: 16, height: 16 }) }); + + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(parseFloat(tooltip.style.left)).toBe(8); + }); + + it('hides the tooltip when the pointer leaves the trigger', () => { + const icon = mountTrigger({ rect: makeRect({ top: 400, left: 100, width: 16, height: 16 }) }); + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + icon.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip.classList.contains('visible')).toBe(false); + expect(icon.hasAttribute('aria-describedby')).toBe(false); + }); + + it('keeps the tooltip open while moving between children of the trigger', () => { + const icon = mountTrigger({ rect: makeRect({ top: 400, left: 100, width: 16, height: 16 }) }); + const child = document.createElement('span'); + icon.appendChild(child); + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + icon.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: child })); + + expect(document.getElementById('lm-tooltip').classList.contains('visible')).toBe(true); + }); + + it('hides the tooltip on Escape', () => { + const icon = mountTrigger({ rect: makeRect({ top: 400, left: 100, width: 16, height: 16 }) }); + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(document.getElementById('lm-tooltip').classList.contains('visible')).toBe(false); + }); + + it('follows the trigger while the settings pane scrolls', () => { + const icon = mountTrigger({ rect: makeRect({ top: 500, left: 100, width: 16, height: 16 }) }); + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + rectByElement.set(icon, makeRect({ top: 20, left: 100, width: 16, height: 16 })); + window.dispatchEvent(new Event('scroll')); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip.style.top).toBe(`${20 + 16 + 8}px`); + expect(tooltip.classList.contains('visible')).toBe(true); + }); + + it('ignores triggers without tooltip text', () => { + const icon = mountTrigger({ tooltip: null, rect: makeRect({ top: 400, left: 100, width: 16, height: 16 }) }); + + icon.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + + const tooltip = document.getElementById('lm-tooltip'); + expect(tooltip === null || !tooltip.classList.contains('visible')).toBe(true); + }); +});