mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
fix: resolve WebUI tooltips not rendering due to overflow clipping
Use position: fixed and getBoundingClientRect() to calculate tooltip position dynamically. This prevents tooltips from being clipped by parent containers with overflow: hidden (such as slide transitions). Closes #1790 Signed-off-by: majiayu000 <majiayu000@users.noreply.github.com>
This commit is contained in:
@@ -3,9 +3,42 @@
|
||||
export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';
|
||||
|
||||
let tooltipVisible = false;
|
||||
let tooltipElement: HTMLDivElement;
|
||||
let triggerElement: HTMLDivElement;
|
||||
let tooltipStyle = '';
|
||||
|
||||
function calculatePosition() {
|
||||
if (!triggerElement) return;
|
||||
|
||||
const rect = triggerElement.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top - gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - gap;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + gap;
|
||||
break;
|
||||
}
|
||||
|
||||
tooltipStyle = `top: ${top}px; left: ${left}px;`;
|
||||
}
|
||||
|
||||
function showTooltip() {
|
||||
calculatePosition();
|
||||
tooltipVisible = true;
|
||||
}
|
||||
|
||||
@@ -16,7 +49,8 @@
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions a11y-mouse-events-have-key-events -->
|
||||
<div class="tooltip-container">
|
||||
<div
|
||||
<div
|
||||
bind:this={triggerElement}
|
||||
class="tooltip-trigger"
|
||||
on:mouseenter={showTooltip}
|
||||
on:mouseleave={hideTooltip}
|
||||
@@ -27,15 +61,15 @@
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
|
||||
{#if tooltipVisible}
|
||||
<div
|
||||
bind:this={tooltipElement}
|
||||
class="tooltip absolute z-[9999] px-2 py-1 text-xs rounded bg-gray-900/90 text-white whitespace-nowrap shadow-lg backdrop-blur-sm"
|
||||
class="tooltip fixed z-[9999] px-2 py-1 text-xs rounded bg-gray-900/90 text-white whitespace-nowrap shadow-lg backdrop-blur-sm"
|
||||
class:top="{position === 'top'}"
|
||||
class:bottom="{position === 'bottom'}"
|
||||
class:left="{position === 'left'}"
|
||||
class:right="{position === 'right'}"
|
||||
style={tooltipStyle}
|
||||
role="tooltip"
|
||||
aria-label={text}
|
||||
>
|
||||
@@ -57,32 +91,24 @@
|
||||
|
||||
.tooltip {
|
||||
pointer-events: none;
|
||||
transition: all 150ms ease-in-out;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip.top {
|
||||
bottom: calc(100% + 5px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.tooltip.bottom {
|
||||
top: calc(100% + 5px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.tooltip.left {
|
||||
right: calc(100% + 5px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translate(-100%, -50%);
|
||||
}
|
||||
|
||||
.tooltip.right {
|
||||
left: calc(100% + 5px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
|
||||
67
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal file
67
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Tooltip positioning logic', () => {
|
||||
const gap = 8;
|
||||
|
||||
function calculatePosition(
|
||||
rect: { top: number; bottom: number; left: number; right: number; width: number; height: number },
|
||||
position: 'top' | 'bottom' | 'left' | 'right'
|
||||
) {
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top - gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - gap;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + gap;
|
||||
break;
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
const mockRect = {
|
||||
top: 100,
|
||||
bottom: 130,
|
||||
left: 200,
|
||||
right: 300,
|
||||
width: 100,
|
||||
height: 30
|
||||
};
|
||||
|
||||
it('calculates top position correctly', () => {
|
||||
const result = calculatePosition(mockRect, 'top');
|
||||
expect(result.top).toBe(92); // 100 - 8
|
||||
expect(result.left).toBe(250); // 200 + 100/2
|
||||
});
|
||||
|
||||
it('calculates bottom position correctly', () => {
|
||||
const result = calculatePosition(mockRect, 'bottom');
|
||||
expect(result.top).toBe(138); // 130 + 8
|
||||
expect(result.left).toBe(250); // 200 + 100/2
|
||||
});
|
||||
|
||||
it('calculates left position correctly', () => {
|
||||
const result = calculatePosition(mockRect, 'left');
|
||||
expect(result.top).toBe(115); // 100 + 30/2
|
||||
expect(result.left).toBe(192); // 200 - 8
|
||||
});
|
||||
|
||||
it('calculates right position correctly', () => {
|
||||
const result = calculatePosition(mockRect, 'right');
|
||||
expect(result.top).toBe(115); // 100 + 30/2
|
||||
expect(result.left).toBe(308); // 300 + 8
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user