Split shared tooltip runtime owners

This commit is contained in:
rcourtman 2026-03-23 02:38:21 +00:00
parent bc235e02b2
commit e9643b3249
7 changed files with 332 additions and 105 deletions

View file

@ -286,6 +286,14 @@ and `frontend-modern/src/components/shared/searchInputModel.ts` owns the shared
search-input contract plus shortcut-hint and trailing-control policy. Future
search-input work should extend those owners instead of pushing type-to-search
or enhancement wiring back into the shared shell.
The shared tooltip now follows that same owner split.
`frontend-modern/src/components/shared/Tooltip.tsx` stays the render shell and
singleton API boundary, `frontend-modern/src/components/shared/useTooltipState.ts`
owns tooltip positioning lifecycle, RAF scheduling, and singleton visibility
state, and `frontend-modern/src/components/shared/tooltipModel.ts` owns tooltip
sanitization plus viewport-clamped positioning math. Future tooltip work should
extend those owners instead of pushing singleton state, DOM measurement, or
sanitization logic back into the shared shell.
The shared collapsible search input now follows that same owner split.
`frontend-modern/src/components/shared/CollapsibleSearchInput.tsx` stays the
render shell, `frontend-modern/src/components/shared/useCollapsibleSearchInputState.ts`

View file

@ -27,6 +27,8 @@ import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
@ -49,6 +51,7 @@ import infrastructureSelectorStateSource from '@/components/shared/useInfrastruc
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
@ -458,6 +461,28 @@ describe('shared primitive guardrails', () => {
expect(searchInputModelSource).toContain('export interface SearchInputProps');
});
it('keeps tooltip on shell, runtime, and model owners', () => {
expect(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');
expect(tooltipSource).not.toContain('createSignal');
expect(tooltipSource).not.toContain('requestAnimationFrame');
expect(tooltipSource).not.toContain('sanitizeTooltipContent');
expect(tooltipSource).not.toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('export function useTooltipState');
expect(tooltipStateSource).toContain('export function createTooltipSystemState');
expect(tooltipStateSource).toContain('createSignal');
expect(tooltipStateSource).toContain('requestAnimationFrame');
expect(tooltipStateSource).toContain('tooltipInstance');
expect(tooltipStateSource).toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('sanitizeTooltipContent');
expect(tooltipModelSource).toContain('export function sanitizeTooltipContent');
expect(tooltipModelSource).toContain('export function resolveTooltipPosition');
expect(tooltipModelSource).toContain("export type TooltipAlignment = 'left' | 'center'");
expect(tooltipModelSource).toContain("export type TooltipDirection = 'up' | 'down'");
});
it('keeps collapsible search input on shell, runtime, and model owners', () => {
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');

View file

@ -1,14 +1,10 @@
import { Component, createSignal, createEffect, Show } from 'solid-js';
import { Component, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { createTooltipSystemState, useTooltipState } from './useTooltipState';
import type { TooltipOptions } from './tooltipModel';
type TooltipAlignment = 'left' | 'center';
type TooltipDirection = 'up' | 'down';
export interface TooltipOptions {
align?: TooltipAlignment;
direction?: TooltipDirection;
maxWidth?: number;
}
export { hideTooltip, showTooltip } from './useTooltipState';
export type { TooltipOptions } from './tooltipModel';
interface TooltipProps extends TooltipOptions {
content: string;
@ -17,125 +13,43 @@ interface TooltipProps extends TooltipOptions {
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, '&amp;') // Encode ampersands
.replace(/</g, '&lt;') // Encode less than
.replace(/>/g, '&gt;') // Encode greater than
.replace(/"/g, '&quot;') // Encode quotes
.replace(/'/g, '&#x27;'); // 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 });
});
});
export const Tooltip: Component<TooltipProps> = (props) => {
const state = useTooltipState(props);
return (
<Show when={props.visible}>
<Portal mount={document.body}>
<div
ref={tooltipRef}
ref={state.setTooltipRef}
class="fixed z-[9999] px-3 py-2 text-xs whitespace-pre-line rounded-md border shadow-sm pointer-events-none bg-surface text-base-content border-border leading-tight"
style={{
left: `${position().left}px`,
top: `${position().top}px`,
left: `${state.position().left}px`,
top: `${state.position().top}px`,
'max-width': `${props.maxWidth ?? 240}px`,
opacity: props.visible ? '1' : '0',
transition: 'opacity 120ms ease-out',
}}
textContent={sanitizeContent(props.content)}
textContent={state.sanitizedContent()}
/>
</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);
},
};
const state = createTooltipSystemState();
return () => (
<Tooltip
content={content()}
x={position().x}
y={position().y}
visible={visible()}
align={options().align}
direction={options().direction}
maxWidth={options().maxWidth}
content={state.content()}
x={state.position().x}
y={state.position().y}
visible={state.visible()}
align={state.options().align}
direction={state.options().direction}
maxWidth={state.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;

View file

@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import {
Tooltip,
createTooltipSystem,
showTooltip,
hideTooltip,
} from '@/components/shared/Tooltip';
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
describe('Tooltip', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('keeps tooltip on shell, runtime, and model owners', () => {
expect(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');
expect(tooltipSource).not.toContain('createSignal');
expect(tooltipSource).not.toContain('requestAnimationFrame');
expect(tooltipSource).not.toContain('sanitizeTooltipContent');
expect(tooltipSource).not.toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('export function useTooltipState');
expect(tooltipStateSource).toContain('export function createTooltipSystemState');
expect(tooltipStateSource).toContain('createSignal');
expect(tooltipStateSource).toContain('requestAnimationFrame');
expect(tooltipStateSource).toContain('tooltipInstance');
expect(tooltipStateSource).toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('sanitizeTooltipContent');
expect(tooltipModelSource).toContain('export function sanitizeTooltipContent');
expect(tooltipModelSource).toContain('export function resolveTooltipPosition');
expect(tooltipModelSource).toContain("export type TooltipAlignment = 'left' | 'center'");
expect(tooltipModelSource).toContain("export type TooltipDirection = 'up' | 'down'");
});
it('sanitizes tooltip content through the model owner', async () => {
render(() => <Tooltip content={`<b>"unsafe"</b> & 'quoted'`} x={24} y={24} visible />);
const tooltip = document.body.querySelector('div[style*="opacity: 1"]') as HTMLDivElement | null;
expect(tooltip).not.toBeNull();
if (!tooltip) return;
await waitFor(() => {
expect(tooltip.textContent).toBe('&quot;unsafe&quot; &amp; &#x27;quoted&#x27;');
expect(tooltip.innerHTML).not.toContain('<b>');
});
});
it('clamps tooltip position inside the viewport', async () => {
const getBoundingClientRect = vi
.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect')
.mockReturnValue({
width: 180,
height: 60,
left: 0,
top: 0,
right: 180,
bottom: 60,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
const raf = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
render(() => <Tooltip content="CPU" x={2} y={2} visible />);
const tooltip = document.body.querySelector('div[style*="opacity: 1"]') as HTMLDivElement | null;
expect(tooltip).not.toBeNull();
if (!tooltip) return;
await waitFor(() => {
expect(Number.parseFloat(tooltip.style.left)).toBeGreaterThanOrEqual(4);
expect(Number.parseFloat(tooltip.style.top)).toBeGreaterThanOrEqual(4);
});
expect(raf).toHaveBeenCalled();
expect(getBoundingClientRect).toHaveBeenCalled();
});
it('preserves the singleton tooltip API', async () => {
const TooltipRoot = createTooltipSystem();
render(() => <TooltipRoot />);
showTooltip('disk', 120, 80, { direction: 'down' });
expect(await screen.findByText('disk')).toBeInTheDocument();
hideTooltip();
await waitFor(() => {
expect(screen.queryByText('disk')).toBeNull();
});
});
});

View file

@ -0,0 +1,64 @@
export type TooltipAlignment = 'left' | 'center';
export type TooltipDirection = 'up' | 'down';
export interface TooltipOptions {
align?: TooltipAlignment;
direction?: TooltipDirection;
maxWidth?: number;
}
export interface TooltipViewportRect {
height: number;
width: number;
}
export interface TooltipPosition {
left: number;
top: number;
}
interface ResolveTooltipPositionOptions extends TooltipOptions {
rect: TooltipViewportRect;
viewportHeight: number;
viewportWidth: number;
x: number;
y: number;
}
export function sanitizeTooltipContent(content: string): string {
return content
.replace(/<[^>]*>/g, '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
export function resolveTooltipPosition(options: ResolveTooltipPositionOptions): TooltipPosition {
const padding = 8;
const viewportPadding = 4;
const align = options.align ?? 'center';
const direction = options.direction ?? 'up';
let left = options.x;
let top = options.y;
if (align === 'center') {
left = options.x - options.rect.width / 2;
}
if (direction === 'up') {
top = options.y - options.rect.height - padding;
} else {
top = options.y + padding;
}
const maxLeft = options.viewportWidth - options.rect.width - viewportPadding;
const maxTop = options.viewportHeight - options.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));
return { left, top };
}

View file

@ -0,0 +1,97 @@
import { createEffect, createSignal } from 'solid-js';
import type { Accessor } from 'solid-js';
import {
resolveTooltipPosition,
sanitizeTooltipContent,
type TooltipOptions,
type TooltipPosition,
} from './tooltipModel';
interface TooltipStateOptions extends TooltipOptions {
content: string;
visible: boolean;
x: number;
y: number;
}
interface TooltipInstance {
hide: () => void;
show: (content: string, x: number, y: number, options?: TooltipOptions) => void;
}
let tooltipInstance: TooltipInstance | null = null;
export function useTooltipState(options: TooltipStateOptions): {
position: Accessor<TooltipPosition>;
sanitizedContent: Accessor<string>;
setTooltipRef: (el: HTMLDivElement) => void;
} {
let tooltipRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal<TooltipPosition>({ left: 0, top: 0 });
createEffect(() => {
if (!options.visible) {
setPosition({ left: options.x, top: options.y });
return;
}
requestAnimationFrame(() => {
if (!tooltipRef) return;
const rect = tooltipRef.getBoundingClientRect();
setPosition(
resolveTooltipPosition({
align: options.align,
direction: options.direction,
rect,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
x: options.x,
y: options.y,
}),
);
});
});
return {
position,
sanitizedContent: () => sanitizeTooltipContent(options.content),
setTooltipRef: (el) => {
tooltipRef = el;
},
};
}
export function createTooltipSystemState() {
const [visible, setVisible] = createSignal(false);
const [content, setContent] = createSignal('');
const [position, setPosition] = createSignal({ x: 0, y: 0 });
const [options, setOptions] = createSignal<TooltipOptions>({});
tooltipInstance = {
show: (contentValue, x, y, nextOptions) => {
setContent(contentValue);
setPosition({ x, y });
setOptions(nextOptions ?? {});
setVisible(true);
},
hide: () => {
setVisible(false);
},
};
return {
content,
options,
position,
visible,
};
}
export function showTooltip(content: string, x: number, y: number, options?: TooltipOptions) {
tooltipInstance?.show(content, x, y, options);
}
export function hideTooltip() {
tooltipInstance?.hide();
}

View file

@ -32,6 +32,8 @@ import searchFieldSource from '@/components/shared/SearchField.tsx?raw';
import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
@ -48,6 +50,7 @@ import infrastructureSelectorStateSource from '@/components/shared/useInfrastruc
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
@ -2684,6 +2687,18 @@ describe('frontend resource type boundaries', () => {
expect(searchInputStateSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('getSearchInputShortcutHint');
expect(searchInputModelSource).toContain('shouldSearchInputShowTrailingControls');
expect(tooltipSource).toContain('useTooltipState');
expect(tooltipSource).toContain('createTooltipSystemState');
expect(tooltipSource).not.toContain('createSignal');
expect(tooltipSource).not.toContain('requestAnimationFrame');
expect(tooltipSource).not.toContain('sanitizeTooltipContent');
expect(tooltipSource).not.toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('createSignal');
expect(tooltipStateSource).toContain('requestAnimationFrame');
expect(tooltipStateSource).toContain('tooltipInstance');
expect(tooltipStateSource).toContain('resolveTooltipPosition');
expect(tooltipModelSource).toContain('sanitizeTooltipContent');
expect(tooltipModelSource).toContain('resolveTooltipPosition');
expect(collapsibleSearchInputSource).toContain('useCollapsibleSearchInputState');
expect(collapsibleSearchInputSource).not.toContain('createSignal');
expect(collapsibleSearchInputSource).not.toContain('useTypeToSearch');