Merge pull request #1904 from majiayu000/fix/webui-tooltips-rendering-1790

fix: resolve WebUI tooltips not rendering due to overflow clipping
This commit is contained in:
Kayvan Sylvan
2025-12-24 21:05:29 -08:00
committed by GitHub
4 changed files with 148 additions and 23 deletions

View File

@@ -1,43 +1,86 @@
<script lang="ts">
import { onMount } from 'svelte';
import { calculateTooltipPosition, formatPositionStyle, type TooltipPosition } from './positioning';
export let text: string;
export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';
// biome-ignore lint/style/useConst: Svelte props must use 'let' even when not reassigned
export let position: TooltipPosition = 'top';
let tooltipVisible = false;
let tooltipElement: HTMLDivElement;
// eslint-disable-next-line no-unassigned-vars -- Assigned via bind:this in template
let triggerElement: HTMLDivElement;
let isBrowser = false;
// biome-ignore lint/correctness/noUnusedVariables: Used in template for aria-describedby and id
const tooltipId = `tooltip-${Math.random().toString(36).substring(2, 9)}`;
// Reactive tooltip positioning - recalculates when position or element changes
$: tooltipStyle = triggerElement && tooltipVisible
? formatPositionStyle(calculateTooltipPosition(triggerElement.getBoundingClientRect(), position))
: '';
function updatePosition() {
if (triggerElement && tooltipVisible) {
tooltipStyle = formatPositionStyle(calculateTooltipPosition(triggerElement.getBoundingClientRect(), position));
}
}
// biome-ignore lint/correctness/noUnusedVariables: Used in template event handlers
function showTooltip() {
tooltipVisible = true;
}
// biome-ignore lint/correctness/noUnusedVariables: Used in template event handlers
function hideTooltip() {
tooltipVisible = false;
}
// Handle window scroll and resize to keep tooltip positioned correctly
// Only runs in browser (not during SSR)
onMount(() => {
isBrowser = true;
return () => {
if (isBrowser) {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
}
};
});
// Add/remove event listeners reactively when tooltip visibility changes
$: if (isBrowser && tooltipVisible) {
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
} else if (isBrowser && !tooltipVisible) {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
}
</script>
<!-- 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}
on:focusin={showTooltip}
on:focusout={hideTooltip}
role="tooltip"
aria-label="Tooltip trigger"
aria-describedby={tooltipVisible ? tooltipId : undefined}
role="button"
tabindex="0"
>
<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"
id={tooltipId}
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}
>
{text}
<div class="tooltip-arrow" role="presentation" />
@@ -57,32 +100,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 {

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { calculateTooltipPosition, formatPositionStyle, TOOLTIP_GAP } from './positioning';
describe('Tooltip positioning logic', () => {
const mockRect = {
top: 100,
bottom: 130,
left: 200,
right: 300,
width: 100,
height: 30,
x: 200,
y: 100,
toJSON: () => ({})
} as DOMRect;
it('calculates top position correctly', () => {
const result = calculateTooltipPosition(mockRect, 'top');
expect(result.top).toBe(92); // 100 - 8
expect(result.left).toBe(250); // 200 + 100/2
});
it('calculates bottom position correctly', () => {
const result = calculateTooltipPosition(mockRect, 'bottom');
expect(result.top).toBe(138); // 130 + 8
expect(result.left).toBe(250); // 200 + 100/2
});
it('calculates left position correctly', () => {
const result = calculateTooltipPosition(mockRect, 'left');
expect(result.top).toBe(115); // 100 + 30/2
expect(result.left).toBe(192); // 200 - 8
});
it('calculates right position correctly', () => {
const result = calculateTooltipPosition(mockRect, 'right');
expect(result.top).toBe(115); // 100 + 30/2
expect(result.left).toBe(308); // 300 + 8
});
it('uses the correct gap value', () => {
expect(TOOLTIP_GAP).toBe(8);
});
it('formats position style correctly', () => {
const position = { top: 100, left: 200 };
const style = formatPositionStyle(position);
expect(style).toBe('top: 100px; left: 200px;');
});
it('respects custom gap parameter', () => {
const customGap = 16;
const result = calculateTooltipPosition(mockRect, 'top', customGap);
expect(result.top).toBe(84); // 100 - 16
});
});

View File

@@ -0,0 +1,27 @@
export const TOOLTIP_GAP = 8;
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export interface Position {
top: number;
left: number;
}
export function calculateTooltipPosition(
rect: DOMRect,
position: TooltipPosition,
gap: number = TOOLTIP_GAP
): Position {
const positions: Record<TooltipPosition, Position> = {
top: { top: rect.top - gap, left: rect.left + rect.width / 2 },
bottom: { top: rect.bottom + gap, left: rect.left + rect.width / 2 },
left: { top: rect.top + rect.height / 2, left: rect.left - gap },
right: { top: rect.top + rect.height / 2, left: rect.right + gap }
};
return positions[position];
}
export function formatPositionStyle(position: Position): string {
return `top: ${position.top}px; left: ${position.left}px;`;
}