mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-11 03:52:13 -03:00
fix(settings): stop clipping help tooltips in the settings pane
Help tooltips were CSS pseudo-elements on the info icon, so they were descendants of the scrollable settings pane (.settings-content uses overflow-y: auto) and got clipped at its top edge. The Active Library deprecation notice made this obvious: the text grew to ~10 lines while only ~5 fit above the icon, so the tooltip started mid-sentence at "page. Deprecated:". Render the tooltip once into <body> with position: fixed instead, so no overflow ancestor can clip it, and position it from JS: above the trigger when it fits, below otherwise, clamped to the viewport. It follows the trigger on scroll/resize and hides on mouseout, Escape or touch. The delegated listeners also cover help icons rendered later by the settings manager, and the box may now use 320px instead of 220px, which keeps long help texts to fewer lines.
This commit is contained in:
@@ -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 <body> 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 {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Floating tooltip for [data-tooltip] triggers.
|
||||
Rendered into <body> 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `<body>` 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user