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}}
-
-
+
);
}