5.5 KiB
Info Popover — unified metric-help affordance
Date: 2026-07-22 Status: Approved (design)
Problem
Two different "info affordance" patterns explain metrics across the app, and both are broken:
-
MetricTooltip(ⓘ) — used on the two detail pages (SchoolDetailView,SecondarySchoolDetailView). Its bubble isposition: absolutewith a fixed220pxwidth and no viewport-collision detection. Near a screen edge on mobile the bubble renders partly or wholly off-viewport with no way to scroll to it — effectively unusable. Aleft: -12pxmobile hack only shifts the problem, it doesn't solve it. -
Compare-section
?help — every compare section funnels throughRowLabelincomponents/compare/sectionShared.tsx, which uses the nativetitle=attribute. On desktop the native tooltip has a long, unconfigurable hover delay; on touch it barely surfaces at all.
We want a single component that positions itself correctly in any viewport and shows a custom (non-native) tooltip on desktop.
Decisions
- Positioning:
@floating-ui/react(industry standard, ~10KB gzipped, React 19 compatible). Chosen over a hand-rolled portal + JS positioning because the current hand-rolled approach is exactly what failed, and Floating UI already solves flip/shift/portal/interactions/ARIA. - Mobile presentation: repositioning popover (not a bottom-sheet). Same small
bubble as desktop; Floating UI's
shift/flipkeep it fully on-screen. One presentation to build and maintain, consistent across platforms. - Glyph: standardise on the circled
?everywhere (replaces ⓘ on the detail pages). Matches the common "help" convention. - Bundle: adding
@floating-ui/reactas a runtime dependency is accepted.
Architecture
One shared engine, two thin adapters — no call-site churn.
InfoPopover (new — components/InfoPopover.tsx)
Owns all behaviour via @floating-ui/react.
- Trigger: a real
<button>(min 24px tap target — WCAG 2.5.8 — keyboard-focusable) showing the circled?glyph. Muted colour; hover / focus / open → accent colour. - Positioning middleware:
offset(6)+flip()+shift({ padding: 8 })so the bubble flips above/below and slides sideways to always stay fully on-screen (the direct fix for the off-viewport bug). Anarrow()element tracks the trigger. - Portal: rendered inside a
FloatingPortalso it escapes the compare grid'soverflow/transformclipping contexts. - Interactions (merged via
useInteractions, one code path for both platforms):useHover(context, { delay: { open: 100, close: 0 } })— short open delay fixes the slow native-titlehover; Floating UI disables hover on touch devices so it doesn't double-fire with tap.useFocus— keyboard focus reveals it.useClick— tap toggles on touch.useDismiss— outside-press andEscapeclose it (opening another popover closes the first, since the outside-press lands on the new trigger).useRole(context, { role: 'tooltip' })— ARIA wiring.
- Content: optional
label(bold),plain(body),detail(muted) — same content shape as today.
Props:
interface InfoPopoverProps {
label?: string; // bold heading
plain?: string; // body text (the primary explanation)
detail?: string; // muted supplementary line
/** Accessible name for the trigger button, e.g. the metric label. */
ariaLabel?: string;
}
Renders null when there is no plain content (mirrors current
MetricTooltip behaviour).
Adapter 1 — MetricTooltip (public API unchanged)
Keeps its existing props (metricKey, label, plain, detail), still
resolves metricKey → METRIC_EXPLANATIONS, and renders InfoPopover with the
resolved content. All ~24 detail-page call sites are untouched. The
hand-rolled positioning/dismiss useEffect and the .tooltip absolute-position
CSS (including the left: -12px mobile hack) are deleted.
Adapter 2 — RowLabel (in sectionShared.tsx)
Replaces the native-title ? span with InfoPopover, passing the tip
string as plain and the row label as ariaLabel. All ~14 compare tip=
call sites are untouched (they pass through Measure → RowLabel).
Removed / replaced
MetricTooltip's hand-rolled open/dismissuseEffect.MetricTooltip.module.css.tooltipabsolute positioning + arrow + the@media (max-width: 640px)left: -12pxhack (superseded by Floating UI).- Native
title=on the compare?help span.
Testing
-
Unit (jest + jsdom) —
InfoPopover:- trigger button renders with the correct
aria-labelandaria-expanded; - click opens and closes it;
label/plain/detailrender when open;Escapecloses it. Positioning (flip/shift) is Floating UI's own tested concern, not re-tested here.
- trigger button renders with the correct
-
E2E (Playwright, mobile viewport) — the regression guard for the reported bug. On a narrow viewport, open a help popover near the right edge of a compare section and assert its bounding box is fully within the viewport (
rect.right <= innerWidth && rect.left >= 0). This is the check that would have caught the original overflow. Satisfies the CLAUDE.md rule to extende2e/when user-facing behaviour changes.
Out of scope
- Other
title=usages (button/link hints, empty-state props) — these are not metric explainers and keep nativetitle. - No bottom-sheet / alternate mobile presentation.