diff --git a/packages/junior-dashboard/e2e/conversations.spec.ts b/packages/junior-dashboard/e2e/conversations.spec.ts index 363b8d95e..365a3a510 100644 --- a/packages/junior-dashboard/e2e/conversations.spec.ts +++ b/packages/junior-dashboard/e2e/conversations.spec.ts @@ -61,6 +61,23 @@ test("opens a conversation in the built dashboard", async ({ page }) => { await expect( page.getByRole("heading", { name: "Checkout latency triage" }), ).toBeVisible(); + + const costMetric = page + .getByRole("main") + .getByText("$0.03", { exact: true }) + .first(); + await costMetric.hover(); + const costTooltip = page.getByRole("tooltip"); + await expect(costTooltip).toBeVisible(); + await costTooltip + .getByText("$0.0332", { exact: true }) + .click({ clickCount: 3 }); + await page.waitForTimeout(200); + await expect(costTooltip).toBeVisible(); + expect( + await page.evaluate(() => window.getSelection()?.toString()), + ).toContain("$0.0332"); + const containerBounds = () => page.locator("main > div").evaluate((element) => { const bounds = element.getBoundingClientRect(); diff --git a/packages/junior-dashboard/src/client/components/Metric.tsx b/packages/junior-dashboard/src/client/components/Metric.tsx index 2b0990ce0..eb2ae8975 100644 --- a/packages/junior-dashboard/src/client/components/Metric.tsx +++ b/packages/junior-dashboard/src/client/components/Metric.tsx @@ -1,4 +1,5 @@ import { + useEffect, useId, useRef, useState, @@ -26,6 +27,8 @@ type TooltipPosition = { width: number; }; +const TOOLTIP_CLOSE_DELAY_MS = 150; + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -105,16 +108,23 @@ export function MetricValue(props: { }) { const tooltipId = useId(); const triggerRef = useRef(null); + const closeTimerRef = useRef | null>(null); + const triggerFocusedRef = useRef(false); + const triggerHoveredRef = useRef(false); + const tooltipHoveredRef = useRef(false); const [position, setPosition] = useState(null); const tooltip = props.tooltip?.filter((line) => line.value.trim()); const tooltipColumns = props.tooltipColumns ?.map((column) => column.filter((line) => line.value.trim())) .filter((column) => column.length); - if (!tooltip?.length && !tooltipColumns?.length) { - return {props.children}; - } + const clearPendingClose = () => { + if (closeTimerRef.current === null) return; + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + }; const showTooltip = () => { + clearPendingClose(); if (!triggerRef.current) return; setPosition( tooltipPosition( @@ -125,7 +135,34 @@ export function MetricValue(props: { ), ); }; - const hideTooltip = () => setPosition(null); + const scheduleTooltipClose = () => { + clearPendingClose(); + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + if ( + triggerFocusedRef.current || + triggerHoveredRef.current || + tooltipHoveredRef.current + ) { + return; + } + setPosition(null); + }, TOOLTIP_CLOSE_DELAY_MS); + }; + + useEffect( + () => () => { + if (closeTimerRef.current !== null) { + clearTimeout(closeTimerRef.current); + } + }, + [], + ); + + if (!tooltip?.length && !tooltipColumns?.length) { + return {props.children}; + } + const tooltipStyle: CSSProperties | undefined = position ? { left: position.left, @@ -139,10 +176,22 @@ export function MetricValue(props: { { + triggerFocusedRef.current = false; + scheduleTooltipClose(); + }} + onFocus={() => { + triggerFocusedRef.current = true; + showTooltip(); + }} + onMouseEnter={() => { + triggerHoveredRef.current = true; + showTooltip(); + }} + onMouseLeave={() => { + triggerHoveredRef.current = false; + scheduleTooltipClose(); + }} ref={triggerRef} tabIndex={0} > @@ -150,8 +199,16 @@ export function MetricValue(props: { {position ? ( { + tooltipHoveredRef.current = true; + clearPendingClose(); + }} + onMouseLeave={() => { + tooltipHoveredRef.current = false; + scheduleTooltipClose(); + }} role="tooltip" style={tooltipStyle} > diff --git a/packages/junior-dashboard/src/client/components/Tooltip.tsx b/packages/junior-dashboard/src/client/components/Tooltip.tsx index 7893527f6..ef21482fc 100644 --- a/packages/junior-dashboard/src/client/components/Tooltip.tsx +++ b/packages/junior-dashboard/src/client/components/Tooltip.tsx @@ -5,6 +5,7 @@ import { type ReactElement, type ReactNode, useCallback, + useEffect, useId, useLayoutEffect, useRef, @@ -25,15 +26,55 @@ type Position = { const VIEWPORT_GAP = 8; const ANCHOR_GAP = 10; +const CLOSE_DELAY_MS = 150; /** Show dashboard details beside an element while keeping the surface on-screen. */ export function Tooltip({ children, content, label }: TooltipProps) { const anchorRef = useRef(null); const tooltipRef = useRef(null); + const closeTimerRef = useRef | null>(null); + const anchorFocusedRef = useRef(false); + const anchorHoveredRef = useRef(false); + const tooltipHoveredRef = useRef(false); const tooltipId = useId(); const [open, setOpen] = useState(false); const [position, setPosition] = useState(null); + const clearPendingClose = () => { + if (closeTimerRef.current === null) return; + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + }; + + const showTooltip = () => { + clearPendingClose(); + setOpen(true); + }; + + const scheduleTooltipClose = () => { + clearPendingClose(); + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + if ( + anchorFocusedRef.current || + anchorHoveredRef.current || + tooltipHoveredRef.current + ) { + return; + } + setOpen(false); + }, CLOSE_DELAY_MS); + }; + + useEffect( + () => () => { + if (closeTimerRef.current !== null) { + clearTimeout(closeTimerRef.current); + } + }, + [], + ); + const updatePosition = useCallback(() => { const anchor = anchorRef.current; const tooltip = tooltipRef.current; @@ -86,13 +127,15 @@ export function Tooltip({ children, content, label }: TooltipProps) { ( childProps.onBlur as ((event: FocusEvent) => void) | undefined )?.(event); - setOpen(false); + anchorFocusedRef.current = false; + scheduleTooltipClose(); }, onFocus(event: FocusEvent) { ( childProps.onFocus as ((event: FocusEvent) => void) | undefined )?.(event); - setOpen(true); + anchorFocusedRef.current = true; + showTooltip(); }, onPointerEnter(event: PointerEvent) { ( @@ -100,7 +143,8 @@ export function Tooltip({ children, content, label }: TooltipProps) { | ((event: PointerEvent) => void) | undefined )?.(event); - setOpen(true); + anchorHoveredRef.current = true; + showTooltip(); }, onPointerLeave(event: PointerEvent) { ( @@ -108,7 +152,8 @@ export function Tooltip({ children, content, label }: TooltipProps) { | ((event: PointerEvent) => void) | undefined )?.(event); - setOpen(false); + anchorHoveredRef.current = false; + scheduleTooltipClose(); }, ref(node: Element | null) { anchorRef.current = node; @@ -121,8 +166,16 @@ export function Tooltip({ children, content, label }: TooltipProps) { {open && typeof document !== "undefined" ? createPortal(
{ + tooltipHoveredRef.current = true; + clearPendingClose(); + }} + onPointerLeave={() => { + tooltipHoveredRef.current = false; + scheduleTooltipClose(); + }} ref={tooltipRef} role="tooltip" style={{