Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot]
d5f84224eb chore(release): Update version to v1.4.362 2025-12-25 05:08:47 +00:00
Kayvan Sylvan
14ab79835e Merge pull request #1904 from majiayu000/fix/webui-tooltips-rendering-1790
fix: resolve WebUI tooltips not rendering due to overflow clipping
2025-12-24 21:05:29 -08:00
Changelog Bot
4d0e1e7201 - Add incoming 1904 changelog entry
- Extract positioning calculations into dedicated `positioning.ts` module
- Add reactive tooltip position updates on scroll/resize
- Improve accessibility with `aria-describedby` and unique IDs
- Add SSR safety with `isBrowser` flag check
- Replace inline position calculation with reactive statement
- Add window event listeners for position tracking
- Update unit tests to use extracted functions
- Add test coverage for style formatting function
2025-12-24 21:01:08 -08:00
majiayu000
257721280f 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>
2025-12-25 11:35:37 +08:00
7 changed files with 153 additions and 25 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## v1.4.362 (2025-12-25)
### PR [#1904](https://github.com/danielmiessler/Fabric/pull/1904) by [majiayu000](https://github.com/majiayu000): fix: resolve WebUI tooltips not rendering due to overflow clipping
- Fix: resolve WebUI tooltips not rendering due to overflow clipping by using position: fixed and getBoundingClientRect() to calculate tooltip position dynamically, preventing tooltips from being clipped by parent containers with overflow: hidden
- Refactor: extract tooltip positioning logic into separate positioning.ts module for better code organization and maintainability
- Improve accessibility with aria-describedby attributes and unique IDs for better screen reader support
- Add reactive tooltip position updates on scroll and resize events for dynamic positioning
- Add SSR safety with isBrowser flag check and comprehensive unit test coverage for the positioning functions
## v1.4.361 (2025-12-25)
### PR [#1905](https://github.com/danielmiessler/Fabric/pull/1905) by [majiayu000](https://github.com/majiayu000): fix: optimize oversized logo images reducing package size by 93%

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.361"
var version = "v1.4.362"

Binary file not shown.

View File

@@ -1 +1 @@
"1.4.361"
"1.4.362"

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;`;
}