mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
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:
@@ -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 {
|
||||
|
||||
56
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal file
56
web/src/lib/components/ui/tooltip/Tooltip.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
27
web/src/lib/components/ui/tooltip/positioning.ts
Normal file
27
web/src/lib/components/ui/tooltip/positioning.ts
Normal 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;`;
|
||||
}
|
||||
Reference in New Issue
Block a user