Files
school_compare/docs/superpowers/specs/2026-07-22-info-popover-design.md

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:

  1. MetricTooltip (ⓘ) — used on the two detail pages (SchoolDetailView, SecondarySchoolDetailView). Its bubble is position: absolute with a fixed 220px width 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. A left: -12px mobile hack only shifts the problem, it doesn't solve it.

  2. Compare-section ? help — every compare section funnels through RowLabel in components/compare/sectionShared.tsx, which uses the native title= 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/flip keep 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/react as 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). An arrow() element tracks the trigger.
  • Portal: rendered inside a FloatingPortal so it escapes the compare grid's overflow/transform clipping 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-title hover; 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 and Escape close 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 metricKeyMETRIC_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 MeasureRowLabel).

Removed / replaced

  • MetricTooltip's hand-rolled open/dismiss useEffect.
  • MetricTooltip.module.css .tooltip absolute positioning + arrow + the @media (max-width: 640px) left: -12px hack (superseded by Floating UI).
  • Native title= on the compare ? help span.

Testing

  1. Unit (jest + jsdom)InfoPopover:

    • trigger button renders with the correct aria-label and aria-expanded;
    • click opens and closes it;
    • label / plain / detail render when open;
    • Escape closes it. Positioning (flip/shift) is Floating UI's own tested concern, not re-tested here.
  2. 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 extend e2e/ when user-facing behaviour changes.

Out of scope

  • Other title= usages (button/link hints, empty-state props) — these are not metric explainers and keep native title.
  • No bottom-sheet / alternate mobile presentation.