diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5bf008424..6e9cb997d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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` diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index c9d0af624..285f9bc6c 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); diff --git a/frontend-modern/src/components/shared/Tooltip.tsx b/frontend-modern/src/components/shared/Tooltip.tsx index a93a3f215..c03c7113e 100644 --- a/frontend-modern/src/components/shared/Tooltip.tsx +++ b/frontend-modern/src/components/shared/Tooltip.tsx @@ -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, '&') // Encode ampersands - .replace(//g, '>') // Encode greater than - .replace(/"/g, '"') // Encode quotes - .replace(/'/g, '''); // Encode apostrophes -} - -const Tooltip: Component = (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 = (props) => { + const state = useTooltipState(props); return (
); }; -// 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({}); - - 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 () => ( ); } -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; diff --git a/frontend-modern/src/components/shared/__tests__/Tooltip.test.tsx b/frontend-modern/src/components/shared/__tests__/Tooltip.test.tsx new file mode 100644 index 000000000..fefd020ec --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/Tooltip.test.tsx @@ -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(() => "unsafe" & '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('"unsafe" & 'quoted''); + expect(tooltip.innerHTML).not.toContain(''); + }); + }); + + 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(() => ); + + 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(() => ); + + showTooltip('disk', 120, 80, { direction: 'down' }); + expect(await screen.findByText('disk')).toBeInTheDocument(); + + hideTooltip(); + + await waitFor(() => { + expect(screen.queryByText('disk')).toBeNull(); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/tooltipModel.ts b/frontend-modern/src/components/shared/tooltipModel.ts new file mode 100644 index 000000000..0f2725818 --- /dev/null +++ b/frontend-modern/src/components/shared/tooltipModel.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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 }; +} diff --git a/frontend-modern/src/components/shared/useTooltipState.ts b/frontend-modern/src/components/shared/useTooltipState.ts new file mode 100644 index 000000000..6e921ecc7 --- /dev/null +++ b/frontend-modern/src/components/shared/useTooltipState.ts @@ -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; + sanitizedContent: Accessor; + setTooltipRef: (el: HTMLDivElement) => void; +} { + let tooltipRef: HTMLDivElement | undefined; + const [position, setPosition] = createSignal({ 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({}); + + 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(); +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 94b593a9e..4f1dc5356 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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');