126 lines
5.5 KiB
Markdown
126 lines
5.5 KiB
Markdown
# 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:**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
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/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.
|