From 15b7493b85548f2daef33fa80f31e1db77f9f59e Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 22 Jul 2026 15:37:33 +0100 Subject: [PATCH] refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph) Co-Authored-By: Claude Opus 4.8 --- .../components/MetricTooltip.test.tsx | 32 +++++ .../components/MetricTooltip.module.css | 114 ------------------ nextjs-app/components/MetricTooltip.tsx | 55 ++------- 3 files changed, 40 insertions(+), 161 deletions(-) create mode 100644 nextjs-app/__tests__/components/MetricTooltip.test.tsx delete mode 100644 nextjs-app/components/MetricTooltip.module.css diff --git a/nextjs-app/__tests__/components/MetricTooltip.test.tsx b/nextjs-app/__tests__/components/MetricTooltip.test.tsx new file mode 100644 index 0000000..7c6c191 --- /dev/null +++ b/nextjs-app/__tests__/components/MetricTooltip.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MetricTooltip } from '@/components/MetricTooltip'; +import { METRIC_EXPLANATIONS } from '@/lib/metrics'; + +describe('MetricTooltip', () => { + it('resolves content from a metricKey', async () => { + const key = Object.keys(METRIC_EXPLANATIONS)[0]; + const exp = METRIC_EXPLANATIONS[key]; + const user = userEvent.setup(); + render(); + // Collapsed by default — the popover content is not in the DOM until opened + // (the old component left an always-present role="tooltip" span behind). + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: `What does ${exp.label} mean?` })); + const tip = await screen.findByRole('tooltip'); + expect(tip).toHaveTextContent(exp.plain); + }); + + it('renders nothing for an unknown metricKey with no explicit content', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('lets explicit props override the looked-up explanation', async () => { + const key = Object.keys(METRIC_EXPLANATIONS)[0]; + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text'); + }); +}); diff --git a/nextjs-app/components/MetricTooltip.module.css b/nextjs-app/components/MetricTooltip.module.css deleted file mode 100644 index ccf0801..0000000 --- a/nextjs-app/components/MetricTooltip.module.css +++ /dev/null @@ -1,114 +0,0 @@ -.wrapper { - position: relative; - display: inline-flex; - align-items: center; - margin-left: 0.3em; -} - -.icon { - /* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */ - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 24px; - min-height: 24px; - margin: -6px 0; - padding: 0; - border: none; - background: none; - font-size: 0.9em; - color: var(--text-muted, #8a7a72); - cursor: help; - line-height: 1; - user-select: none; - transition: color 0.15s ease; -} - -.wrapper:hover .icon, -.icon[aria-expanded="true"] { - color: var(--accent-coral-dark, #b04a2e); -} - -.tooltip { - visibility: hidden; - opacity: 0; - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - z-index: 9999; - width: 220px; - background: var(--bg-primary, #faf7f2); - border: 1px solid var(--border-color, #e8ddd4); - border-radius: 10px; - box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15); - padding: 0.6rem 0.75rem; - display: flex; - flex-direction: column; - gap: 0.3rem; - pointer-events: none; - transition: opacity 0.15s ease, visibility 0.15s ease; -} - -/* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */ -.wrapper:hover .tooltip, -.wrapper:focus-within .tooltip, -.tooltipOpen { - visibility: visible; - opacity: 1; -} - -.tooltipOpen { - pointer-events: auto; -} - -/* Small arrow pointing down */ -.tooltip::after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 5px solid transparent; - border-top-color: var(--border-color, #e8ddd4); -} - -.tooltipLabel { - font-weight: 600; - font-size: 0.75rem; - color: var(--text-primary, #2c2420); -} - -.tooltipPlain { - font-size: 0.75rem; - color: var(--text-secondary, #5a4a44); - line-height: 1.4; -} - -.tooltipDetail { - font-size: 0.7rem; - color: var(--text-muted, #8a7a72); - line-height: 1.4; - margin-top: 0.1rem; -} - -@media (max-width: 480px) { - .tooltip { - width: 180px; - } -} - -/* Anchor the bubble to open rightward on phones — icons follow their labels, - which start at the left edge, so centring pushed the bubble off-screen. */ -@media (max-width: 640px) { - .tooltip { - left: -12px; - right: auto; - transform: none; - } - - .tooltip::after { - left: 16px; - transform: none; - } -} diff --git a/nextjs-app/components/MetricTooltip.tsx b/nextjs-app/components/MetricTooltip.tsx index 6bef6f4..d27fc30 100644 --- a/nextjs-app/components/MetricTooltip.tsx +++ b/nextjs-app/components/MetricTooltip.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; import { METRIC_EXPLANATIONS } from '@/lib/metrics'; -import styles from './MetricTooltip.module.css'; +import { InfoPopover } from './InfoPopover'; interface MetricTooltipProps { metricKey?: string; @@ -13,51 +12,13 @@ interface MetricTooltipProps { export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) { const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined; - const tooltipLabel = label ?? explanation?.label; - const tooltipPlain = plain ?? explanation?.plain; - const tooltipDetail = detail ?? explanation?.detail; - - // Tap/click/keyboard toggle so the definition is reachable on touch devices - // and by keyboard, not just mouse hover (hover still works on desktop). - const [open, setOpen] = useState(false); - const wrapperRef = useRef(null); - - useEffect(() => { - if (!open) return; - const dismiss = (e: Event) => { - if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) { - setOpen(false); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - document.addEventListener('click', dismiss); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('click', dismiss); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - - if (!tooltipPlain) return null; - + const resolvedLabel = label ?? explanation?.label; return ( - - - - {tooltipLabel && {tooltipLabel}} - {tooltipPlain} - {tooltipDetail && {tooltipDetail}} - - + ); }