141 lines
4.1 KiB
TypeScript
141 lines
4.1 KiB
TypeScript
import { Component, createSignal, createEffect, Show } from 'solid-js';
|
|
import { Portal } from 'solid-js/web';
|
|
|
|
type TooltipAlignment = 'left' | 'center';
|
|
type TooltipDirection = 'up' | 'down';
|
|
|
|
export interface TooltipOptions {
|
|
align?: TooltipAlignment;
|
|
direction?: TooltipDirection;
|
|
maxWidth?: number;
|
|
}
|
|
|
|
interface TooltipProps extends TooltipOptions {
|
|
content: string;
|
|
x: number;
|
|
y: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
// Sanitize tooltip content to prevent XSS
|
|
function sanitizeContent(content: string): string {
|
|
// Remove any HTML tags and encode special characters
|
|
return content
|
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
|
.replace(/&/g, '&') // Encode ampersands
|
|
.replace(/</g, '<') // Encode less than
|
|
.replace(/>/g, '>') // Encode greater than
|
|
.replace(/"/g, '"') // Encode quotes
|
|
.replace(/'/g, '''); // Encode apostrophes
|
|
}
|
|
|
|
const Tooltip: Component<TooltipProps> = (props) => {
|
|
let tooltipRef: HTMLDivElement | undefined;
|
|
const [position, setPosition] = createSignal({ left: 0, top: 0 });
|
|
|
|
createEffect(() => {
|
|
if (!props.visible) {
|
|
setPosition({ left: props.x, top: props.y });
|
|
return;
|
|
}
|
|
|
|
// Use requestAnimationFrame to ensure DOM is updated
|
|
requestAnimationFrame(() => {
|
|
if (!tooltipRef) return;
|
|
|
|
const rect = tooltipRef.getBoundingClientRect();
|
|
const padding = 8;
|
|
let left = props.x;
|
|
let top = props.y;
|
|
|
|
const align = props.align ?? 'center';
|
|
const direction = props.direction ?? 'up';
|
|
|
|
if (align === 'center') {
|
|
left = props.x - rect.width / 2;
|
|
}
|
|
|
|
if (direction === 'up') {
|
|
top = props.y - rect.height - padding;
|
|
} else {
|
|
top = props.y + padding;
|
|
}
|
|
|
|
// Clamp to viewport bounds with small offset to avoid touching edges
|
|
const viewportPadding = 4;
|
|
const maxLeft = window.innerWidth - rect.width - viewportPadding;
|
|
const maxTop = window.innerHeight - rect.height - viewportPadding;
|
|
|
|
left = Math.min(Math.max(left, viewportPadding), Math.max(maxLeft, viewportPadding));
|
|
top = Math.min(Math.max(top, viewportPadding), Math.max(maxTop, viewportPadding));
|
|
|
|
setPosition({ left, top });
|
|
});
|
|
});
|
|
|
|
return (
|
|
<Show when={props.visible}>
|
|
<Portal mount={document.body}>
|
|
<div
|
|
ref={tooltipRef}
|
|
class="fixed z-[9999] px-3 py-2 text-xs whitespace-pre-line rounded-lg border shadow-xl pointer-events-none bg-white/95 text-gray-900 border-gray-200 backdrop-blur-sm leading-tight dark:bg-gray-900/95 dark:text-gray-100 dark:border-gray-700"
|
|
style={{
|
|
left: `${position().left}px`,
|
|
top: `${position().top}px`,
|
|
'max-width': `${props.maxWidth ?? 240}px`,
|
|
opacity: props.visible ? '1' : '0',
|
|
transition: 'opacity 120ms ease-out',
|
|
}}
|
|
textContent={sanitizeContent(props.content)}
|
|
/>
|
|
</Portal>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
// Global tooltip singleton
|
|
let tooltipInstance: {
|
|
show: (content: string, x: number, y: number, options?: TooltipOptions) => void;
|
|
hide: () => void;
|
|
} | null = null;
|
|
|
|
export function createTooltipSystem() {
|
|
const [visible, setVisible] = createSignal(false);
|
|
const [content, setContent] = createSignal('');
|
|
const [position, setPosition] = createSignal({ x: 0, y: 0 });
|
|
const [options, setOptions] = createSignal<TooltipOptions>({});
|
|
|
|
tooltipInstance = {
|
|
show: (content: string, x: number, y: number, opts?: TooltipOptions) => {
|
|
setContent(content);
|
|
setPosition({ x, y });
|
|
setOptions(opts || {});
|
|
setVisible(true);
|
|
},
|
|
hide: () => {
|
|
setVisible(false);
|
|
},
|
|
};
|
|
|
|
return () => (
|
|
<Tooltip
|
|
content={content()}
|
|
x={position().x}
|
|
y={position().y}
|
|
visible={visible()}
|
|
align={options().align}
|
|
direction={options().direction}
|
|
maxWidth={options().maxWidth}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function showTooltip(content: string, x: number, y: number, options?: TooltipOptions) {
|
|
tooltipInstance?.show(content, x, y, options);
|
|
}
|
|
|
|
export function hideTooltip() {
|
|
tooltipInstance?.hide();
|
|
}
|
|
|
|
export default Tooltip;
|