Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 4.8 8e763e39d1 test(e2e): compare help popover stays within the mobile viewport
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m33s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:56 +01:00
TudorandClaude Opus 4.8 84bca53c7e refactor(compare): row-label help uses InfoPopover, not native title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:25 +01:00
TudorandClaude Opus 4.8 15b7493b85 refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:37:33 +01:00
TudorandClaude Opus 4.8 1d9d2eb5ae feat(ui): add InfoPopover — viewport-aware metric help via Floating UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:36:23 +01:00
TudorandClaude Opus 4.8 ddb42badb6 build(deps): add @floating-ui/react for the info popover
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:35:21 +01:00
TudorandClaude Opus 4.8 702408d6a4 docs(compare): implementation plan for InfoPopover metric help
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:34:38 +01:00
TudorandClaude Opus 4.8 09d94e513e docs(compare): design for unified InfoPopover metric-help affordance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:30:40 +01:00
tudor d02a323cdc Merge pull request 'fix(detail): give Results Over Time chart more height on desktop' (#78) from fix/detail-trend-chart-taller into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 58s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m13s
Reviewed-on: #78
2026-07-21 21:08:34 +00:00
TudorandClaude Opus 4.8 32f8a02862 fix(detail): give Results Over Time chart more height on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The trend chart was capped at 280px tall on desktop, squishing the
lines together and making them hard to read. Raise the base
.chartContainer height to 380px in both the primary and secondary
detail views.

Mobile is unaffected: the base value is already overridden to
height:auto by the existing max-width:768px query, where
PerformanceChart's own .chartWrapper carries the canvas height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 22:05:10 +01:00
tudor 7e3096c881 Merge pull request 'feat(analytics): load Umami heatmap recorder' (#77) from feat/umami-heatmap-recorder into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 47s
Reviewed-on: #77
2026-07-21 15:06:04 +00:00
TudorandClaude Opus 4.8 1f80571b1f feat(analytics): load Umami heatmap recorder
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 42s
Adds the Umami session recorder (recorder.js) that powers heatmaps,
alongside the existing analytics tracker. Loaded via next/script with
the same data-domains guard as the main tracker so it only fires on the
production hostnames — staging runs the same image under a different
host and must not feed the heatmap.

Cookieless: recorder.js uses Umami's server-side session model (no
cookie or local storage on the device), so it adds no new PECR
consent trigger.

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 16:03:18 +01:00
17 changed files with 1274 additions and 182 deletions
@@ -0,0 +1,739 @@
# Info Popover Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the two broken metric-help affordances (the off-viewport `MetricTooltip` bubble and the native-`title` compare `?` help) with one `@floating-ui/react`-backed `InfoPopover` that stays on-screen in any viewport and shows a custom (non-native) tooltip on desktop.
**Architecture:** One shared engine (`InfoPopover`) owns all Floating UI positioning and interactions. Two existing components become thin adapters over it: `MetricTooltip` keeps its public API (so ~24 detail-page call sites are untouched), and `RowLabel` in `sectionShared.tsx` swaps its native-`title` span for `InfoPopover` (so ~14 compare `tip=` call sites are untouched).
**Tech Stack:** Next.js 16, React 19, TypeScript, `@floating-ui/react`, CSS Modules, Jest + jsdom + Testing Library, Playwright (e2e).
## Global Constraints
- All work happens in `nextjs-app/` unless a path says otherwise. Commands below assume the working directory is `nextjs-app/`.
- Path alias: `@/``nextjs-app/` root (jest `moduleNameMapper` and tsconfig).
- Jest test locations: files under `__tests__/**` or named `*.test.tsx`. Env is `jest-environment-jsdom`.
- Trigger glyph is the circled **?** everywhere (replaces ⓘ on detail pages).
- Trigger is a real `<button>` with a min 24px tap target (WCAG 2.5.8) and an accessible name.
- `InfoPopover` renders `null` when it has no `plain` content (preserves current `MetricTooltip` behaviour).
- Positioning/interaction is delegated to Floating UI — do not hand-roll positioning, outside-click, or Escape handling.
- Run `npx tsc --noEmit` and `npx jest` green before every commit that changes code.
- Never push to `main`; this plan's branch is `feat/info-popover-tooltip`.
---
### Task 1: Add the `@floating-ui/react` dependency
**Files:**
- Modify: `nextjs-app/package.json` (dependencies)
- Modify: `nextjs-app/package-lock.json` (generated)
**Interfaces:**
- Consumes: nothing.
- Produces: `@floating-ui/react` importable — the hooks `useFloating`, `autoUpdate`, `offset`, `flip`, `shift`, `arrow`, `useHover`, `useFocus`, `useClick`, `useDismiss`, `useRole`, `useInteractions`, and the component `FloatingPortal`, `FloatingArrow`.
- [ ] **Step 1: Install the package**
Run:
```bash
npm install @floating-ui/react@^0.27.0
```
Expected: `package.json` gains `"@floating-ui/react": "^0.27.0"` under `dependencies`; `package-lock.json` updates; exit 0.
- [ ] **Step 2: Verify it resolves**
Run:
```bash
node -e "const f=require('@floating-ui/react'); console.log(typeof f.useFloating, typeof f.FloatingPortal, typeof f.useInteractions)"
```
Expected: `function function function`
- [ ] **Step 3: Typecheck still passes**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "build(deps): add @floating-ui/react for the info popover"
```
---
### Task 2: Build `InfoPopover` (the shared engine) with unit tests
**Files:**
- Create: `nextjs-app/components/InfoPopover.tsx`
- Create: `nextjs-app/components/InfoPopover.module.css`
- Test: `nextjs-app/__tests__/components/InfoPopover.test.tsx`
**Interfaces:**
- Consumes: `@floating-ui/react` (Task 1).
- Produces:
```ts
export interface InfoPopoverProps {
label?: string; // bold heading
plain?: string; // body text (primary explanation)
detail?: string; // muted supplementary line
ariaLabel?: string; // accessible name for the trigger button
}
export function InfoPopover(props: InfoPopoverProps): JSX.Element | null;
```
Behaviour: renders `null` if `plain` is falsy. Otherwise renders a `<button>`
(the circled `?`) with `aria-label={ariaLabel ?? 'More information'}` and
`aria-expanded`. When open, a portalled `role="tooltip"` container shows
`label` (if any), `plain`, and `detail` (if any). Opens on hover (100ms
open / 0ms close) and focus on desktop, on click for touch; closes on
outside-press and Escape.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/InfoPopover.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfoPopover } from '@/components/InfoPopover';
describe('InfoPopover', () => {
it('renders nothing when there is no plain content', () => {
const { container } = render(<InfoPopover label="X" />);
expect(container).toBeEmptyDOMElement();
});
it('renders a labelled, collapsed trigger button', () => {
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
const btn = screen.getByRole('button', { name: 'Reading score' });
expect(btn).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('opens on click and shows label, plain and detail', async () => {
const user = userEvent.setup();
render(
<InfoPopover
label="Reading, Writing & Maths"
plain="% reaching the expected standard"
detail="National average ~60%"
ariaLabel="RWM"
/>,
);
await user.click(screen.getByRole('button', { name: 'RWM' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Reading, Writing & Maths');
expect(tip).toHaveTextContent('% reaching the expected standard');
expect(tip).toHaveTextContent('National average ~60%');
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
it('closes again on a second click', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
const btn = screen.getByRole('button', { name: 'Info' });
await user.click(btn);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.click(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('closes on Escape', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
await user.click(screen.getByRole('button', { name: 'Info' }));
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('defaults the accessible name when no ariaLabel is given', () => {
render(<InfoPopover plain="body" />);
expect(
screen.getByRole('button', { name: 'More information' }),
).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/InfoPopover.test.tsx
```
Expected: FAIL — `Cannot find module '@/components/InfoPopover'`.
- [ ] **Step 3: Write the component**
Create `nextjs-app/components/InfoPopover.tsx`:
```tsx
'use client';
import { useRef, useState } from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingArrow,
} from '@floating-ui/react';
import styles from './InfoPopover.module.css';
export interface InfoPopoverProps {
label?: string;
plain?: string;
detail?: string;
ariaLabel?: string;
}
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef, padding: 8 }),
],
});
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
// so tap and hover never double-fire.
const hover = useHover(context, { delay: { open: 100, close: 0 } });
const focus = useFocus(context);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
click,
dismiss,
role,
]);
if (!plain) return null;
return (
<>
<button
type="button"
ref={refs.setReference}
className={styles.icon}
aria-label={ariaLabel ?? 'More information'}
aria-expanded={open}
{...getReferenceProps()}
>
?
</button>
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
className={styles.tooltip}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
{label && <span className={styles.label}>{label}</span>}
<span className={styles.plain}>{plain}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</div>
</FloatingPortal>
)}
</>
);
}
```
- [ ] **Step 4: Write the styles**
Create `nextjs-app/components/InfoPopover.module.css`:
```css
.icon {
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 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
/* The visible affordance: a small circled "?" centred in the 24px target. */
.icon::before {
content: '?';
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 0.65rem;
}
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
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;
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/InfoPopover.test.tsx
```
Expected: PASS (6 tests).
- [ ] **Step 6: Typecheck**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 7: Commit**
```bash
git add components/InfoPopover.tsx components/InfoPopover.module.css __tests__/components/InfoPopover.test.tsx
git commit -m "feat(ui): add InfoPopover — viewport-aware metric help via Floating UI"
```
---
### Task 3: Reduce `MetricTooltip` to a thin adapter over `InfoPopover`
**Files:**
- Modify: `nextjs-app/components/MetricTooltip.tsx` (full rewrite of body)
- Delete: `nextjs-app/components/MetricTooltip.module.css`
- Test: `nextjs-app/__tests__/components/MetricTooltip.test.tsx` (create)
**Interfaces:**
- Consumes: `InfoPopover` (Task 2), `METRIC_EXPLANATIONS` from `@/lib/metrics`.
- Produces: `MetricTooltip` with unchanged public props
`{ metricKey?: string; label?: string; plain?: string; detail?: string }`.
Resolves `metricKey` → `METRIC_EXPLANATIONS[metricKey]`, with explicit
`label`/`plain`/`detail` props overriding the looked-up values. Passes the
resolved label as `InfoPopover`'s `ariaLabel`.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/MetricTooltip.test.tsx`:
```tsx
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(<MetricTooltip metricKey={key} />);
await user.click(screen.getByRole('button', { name: exp.label }));
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(<MetricTooltip metricKey="__nope__" />);
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(<MetricTooltip metricKey={key} plain="custom text" />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/MetricTooltip.test.tsx
```
Expected: FAIL — the old `MetricTooltip` renders a `.tooltip` span even when
collapsed (no `role="tooltip"` gating on open) / different structure, so
`findByRole('tooltip')` after click and the empty-render assertion diverge from
the new contract. (If any assertion happens to pass against the old component,
it still must fail overall before Step 3.)
- [ ] **Step 3: Rewrite the component**
Replace the entire contents of `nextjs-app/components/MetricTooltip.tsx` with:
```tsx
'use client';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import { InfoPopover } from './InfoPopover';
interface MetricTooltipProps {
metricKey?: string;
label?: string;
plain?: string;
detail?: string;
}
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const resolvedLabel = label ?? explanation?.label;
return (
<InfoPopover
label={resolvedLabel}
plain={plain ?? explanation?.plain}
detail={detail ?? explanation?.detail}
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
/>
);
}
```
- [ ] **Step 4: Delete the now-unused stylesheet**
Run:
```bash
git rm components/MetricTooltip.module.css
```
Expected: file staged for deletion. (The old component was its only importer;
`InfoPopover.module.css` supersedes it.)
- [ ] **Step 5: Verify nothing else imports the deleted CSS**
Run:
```bash
grep -rn "MetricTooltip.module.css" components app lib || echo "no importers"
```
Expected: `no importers`.
- [ ] **Step 6: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/MetricTooltip.test.tsx
```
Expected: PASS (3 tests).
- [ ] **Step 7: Typecheck**
Run:
```bash
npx tsc --noEmit
```
Expected: no output (exit 0).
- [ ] **Step 8: Commit**
```bash
git add components/MetricTooltip.tsx __tests__/components/MetricTooltip.test.tsx
git commit -m "refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)"
```
---
### Task 4: Swap the compare `?` help (`RowLabel`) to `InfoPopover`
**Files:**
- Modify: `nextjs-app/components/compare/sectionShared.tsx` (`RowLabel`)
- Modify: `nextjs-app/components/compare/compareSections.module.css` (remove `.help`)
- Test: `nextjs-app/__tests__/components/sectionShared.test.tsx` (create)
**Interfaces:**
- Consumes: `InfoPopover` (Task 2).
- Produces: `RowLabel({ children, tip })` renders the label text plus, when
`tip` is set, an `InfoPopover` with `plain={tip}`. `ariaLabel` is omitted, so
the trigger uses `InfoPopover`'s default accessible name `"More information"`
(the row label is arbitrary `ReactNode`, so there is no clean string to derive
a per-row name from). `Measure` and all compare `tip=` call sites are
unchanged.
- [ ] **Step 1: Write the failing test**
Create `nextjs-app/__tests__/components/sectionShared.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RowLabel } from '@/components/compare/sectionShared';
describe('RowLabel', () => {
it('renders its label text', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
});
it('shows no help affordance when no tip is given', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('opens the tip in a popover on click', async () => {
const user = userEvent.setup();
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'Average GCSE score across 8 subjects',
);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx jest __tests__/components/sectionShared.test.tsx
```
Expected: FAIL — the current `RowLabel` renders a `<span title=...>` (not a
`button`/`role="tooltip"`), so the click test fails.
- [ ] **Step 3: Update `RowLabel`**
In `nextjs-app/components/compare/sectionShared.tsx`, add the import near the
other imports:
```tsx
import { InfoPopover } from '@/components/InfoPopover';
```
Then replace the `RowLabel` function:
```tsx
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
return (
<div className={styles.rowLabel}>
{children}
{tip && <InfoPopover plain={tip} />}
</div>
);
}
```
- [ ] **Step 4: Remove the dead `.help` style**
In `nextjs-app/components/compare/compareSections.module.css`, delete the entire
`.help { … }` rule (the `display: inline-flex; width: 15px; … flex: none;`
block — the circled-`?` styling now lives in `InfoPopover.module.css`).
- [ ] **Step 5: Confirm `.help` is unreferenced**
Run:
```bash
grep -rn "styles.help\|\.help\b" components/compare || echo "no references"
```
Expected: `no references`.
- [ ] **Step 6: Run the tests to verify they pass**
Run:
```bash
npx jest __tests__/components/sectionShared.test.tsx
```
Expected: PASS (3 tests).
- [ ] **Step 7: Full unit suite + typecheck**
Run:
```bash
npx tsc --noEmit && npx jest
```
Expected: typecheck clean; all suites pass (existing + the 3 new suites).
- [ ] **Step 8: Commit**
```bash
git add components/compare/sectionShared.tsx components/compare/compareSections.module.css __tests__/components/sectionShared.test.tsx
git commit -m "refactor(compare): row-label help uses InfoPopover, not native title"
```
---
### Task 5: E2E regression guard — popover stays within the mobile viewport
**Files:**
- Modify: `nextjs-app/../e2e/tests/journeys.spec.ts` (add one test)
**Interfaces:**
- Consumes: the running app (staging/local baseURL), the `twoPrimaryUrns`
helper already defined in `journeys.spec.ts`.
- Produces: a Playwright test asserting an opened compare help popover's
bounding box is fully within the viewport on a narrow screen.
- [ ] **Step 1: Add the failing-guard test**
Append to `nextjs-app/../e2e/tests/journeys.spec.ts` (i.e. `e2e/tests/journeys.spec.ts`):
```ts
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
// The metric-help triggers are the circled-"?" buttons in the row labels.
// "More information" is InfoPopover's default accessible name.
const help = page.getByRole('button', { name: 'More information' }).first();
await expect(help).toBeVisible({ timeout: 15_000 });
await help.click();
const tip = page.getByRole('tooltip');
await expect(tip).toBeVisible();
// The whole bubble must sit inside the viewport — the original bug pushed it
// off the right edge with no way to scroll to it.
const box = await tip.boundingBox();
const width = page.viewportSize()!.width;
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
// And the page must not have gained a horizontal scrollbar from the bubble.
const bodyOverflowsX = await page
.locator('body')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false);
});
```
- [ ] **Step 2: Lint/typecheck the e2e file**
Run (from `e2e/`):
```bash
cd ../e2e && npx tsc --noEmit -p . 2>/dev/null || npx tsc --noEmit journeys 2>/dev/null; cd ../nextjs-app
```
Expected: no type errors reported for `journeys.spec.ts`. (If the e2e package
has no standalone tsconfig, this is a no-op; the CI Playwright run type-checks
on execution.)
- [ ] **Step 3: Note on running e2e**
The e2e journeys run against a deployed environment (staging) in CI, per
`CLAUDE.md`; they are not run locally here (no local server). This test will
execute in the staging gate after merge. Do not attempt to start a local server.
- [ ] **Step 4: Commit**
```bash
git add ../e2e/tests/journeys.spec.ts
git commit -m "test(e2e): compare help popover stays within the mobile viewport"
```
---
### Task 6: Final verification and PR
**Files:** none (verification + PR).
- [ ] **Step 1: Full typecheck + unit suite**
Run (from `nextjs-app/`):
```bash
npx tsc --noEmit && npx jest 2>&1 | tail -8
```
Expected: typecheck clean; all suites pass.
- [ ] **Step 2: Production build sanity (catches client/server boundary issues)**
Run:
```bash
npx next build 2>&1 | tail -20
```
Expected: build completes without errors. (`InfoPopover` is a client
component — `'use client'` — so this confirms the portal usage compiles.)
- [ ] **Step 3: Confirm no stragglers reference removed APIs**
Run:
```bash
grep -rn "MetricTooltip.module.css\|styles.help\|title={tip}" components app || echo "clean"
```
Expected: `clean`.
- [ ] **Step 4: Push and open the PR**
```bash
git push -u origin feat/info-popover-tooltip
```
Then open a Gitea PR (base `main`, head `feat/info-popover-tooltip`) via the
credential-helper + API pattern used in this repo, summarising: the two bugs
fixed (off-viewport mobile bubble; slow native-`title` desktop hover), the
unified `InfoPopover` approach, the circled-`?` standardisation, the new
`@floating-ui/react` dependency, and the mobile-viewport e2e guard.
```
@@ -0,0 +1,125 @@
# 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.
+33
View File
@@ -478,3 +478,36 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
// The metric-help triggers are the circled-"?" buttons in the row labels.
// "More information" is InfoPopover's default accessible name.
const help = page.getByRole('button', { name: 'More information' }).first();
await expect(help).toBeVisible({ timeout: 15_000 });
await help.click();
const tip = page.getByRole('tooltip');
await expect(tip).toBeVisible();
// The whole bubble must sit inside the viewport — the original bug pushed it
// off the right edge with no way to scroll to it.
const box = await tip.boundingBox();
const width = page.viewportSize()!.width;
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
// And the page must not have gained a horizontal scrollbar from the bubble.
const bodyOverflowsX = await page
.locator('body')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false);
});
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfoPopover } from '@/components/InfoPopover';
describe('InfoPopover', () => {
it('renders nothing when there is no plain content', () => {
const { container } = render(<InfoPopover label="X" />);
expect(container).toBeEmptyDOMElement();
});
it('renders a labelled, collapsed trigger button', () => {
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
const btn = screen.getByRole('button', { name: 'Reading score' });
expect(btn).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('opens on click and shows label, plain and detail', async () => {
const user = userEvent.setup();
render(
<InfoPopover
label="Reading, Writing & Maths"
plain="% reaching the expected standard"
detail="National average ~60%"
ariaLabel="RWM"
/>,
);
await user.click(screen.getByRole('button', { name: 'RWM' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Reading, Writing & Maths');
expect(tip).toHaveTextContent('% reaching the expected standard');
expect(tip).toHaveTextContent('National average ~60%');
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
it('closes again on a second click', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
const btn = screen.getByRole('button', { name: 'Info' });
await user.click(btn);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.click(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('closes on Escape', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
await user.click(screen.getByRole('button', { name: 'Info' }));
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('defaults the accessible name when no ariaLabel is given', () => {
render(<InfoPopover plain="body" />);
expect(
screen.getByRole('button', { name: 'More information' }),
).toBeInTheDocument();
});
});
@@ -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(<MetricTooltip metricKey={key} />);
// 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(<MetricTooltip metricKey="__nope__" />);
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(<MetricTooltip metricKey={key} plain="custom text" />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
});
});
@@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RowLabel } from '@/components/compare/sectionShared';
describe('RowLabel', () => {
it('renders its label text', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
});
it('shows no help affordance when no tip is given', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('opens the tip in a popover on click', async () => {
const user = userEvent.setup();
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'Average GCSE score across 8 subjects',
);
});
});
+8
View File
@@ -85,6 +85,14 @@ export default function RootLayout({
data-performance="true"
strategy="afterInteractive"
/>
{/* Heatmap / session recorder — same data-domains guard so staging
(same image, different host) never feeds the heatmap. */}
<Script
src="https://analytics.schoolcompare.co.uk/recorder.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
strategy="afterInteractive"
/>
</head>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" />
@@ -0,0 +1,77 @@
.icon {
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 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
/* The visible affordance: a small circled "?" centred in the 24px target. */
.icon::before {
content: '?';
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 0.65rem;
}
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
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;
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useRef, useState } from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingArrow,
} from '@floating-ui/react';
import styles from './InfoPopover.module.css';
export interface InfoPopoverProps {
label?: string;
plain?: string;
detail?: string;
ariaLabel?: string;
}
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef, padding: 8 }),
],
});
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
// so tap and hover never double-fire.
const hover = useHover(context, { delay: { open: 100, close: 0 } });
const focus = useFocus(context);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
click,
dismiss,
role,
]);
if (!plain) return null;
return (
<>
<button
type="button"
ref={refs.setReference}
className={styles.icon}
aria-label={ariaLabel ?? 'More information'}
aria-expanded={open}
{...getReferenceProps()}
>
?
</button>
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
className={styles.tooltip}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
{label && <span className={styles.label}>{label}</span>}
<span className={styles.plain}>{plain}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</div>
</FloatingPortal>
)}
</>
);
}
@@ -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;
}
}
+8 -47
View File
@@ -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<HTMLSpanElement>(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 (
<span className={styles.wrapper} ref={wrapperRef}>
<button
type="button"
className={styles.icon}
aria-expanded={open}
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
onClick={() => setOpen((o) => !o)}
>
</button>
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
</span>
</span>
<InfoPopover
label={resolvedLabel}
plain={plain ?? explanation?.plain}
detail={detail ?? explanation?.detail}
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
/>
);
}
@@ -855,7 +855,10 @@
/* Charts Section */
.chartContainer {
width: 100%;
height: 280px;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative;
}
@@ -490,7 +490,10 @@
/* ── Charts & Map ────────────────────────────────────── */
.chartContainer {
width: 100%;
height: 280px;
/* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative;
}
@@ -132,20 +132,6 @@
color: var(--text-secondary);
}
.help {
display: inline-flex;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid var(--text-muted);
color: var(--text-muted);
font-size: 0.65rem;
align-items: center;
justify-content: center;
cursor: help;
flex: none;
}
.badge {
display: inline-block;
font-weight: 700;
@@ -11,6 +11,7 @@ import type { CSSProperties, ReactNode } from 'react';
import type { School } from '@/lib/types';
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
import { InfoPopover } from '@/components/InfoPopover';
import styles from './compareSections.module.css';
export function Section({
@@ -52,11 +53,7 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
return (
<div className={styles.rowLabel}>
{children}
{tip && (
<span className={styles.help} title={tip} aria-label={tip}>
?
</span>
)}
{tip && <InfoPopover plain={tip} />}
</div>
);
}
+60
View File
@@ -8,6 +8,7 @@
"name": "nextjs-app",
"version": "0.1.0",
"dependencies": {
"@floating-ui/react": "^0.27.20",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
@@ -832,6 +833,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -9136,6 +9190,12 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"test:coverage": "jest --coverage"
},
"dependencies": {
"@floating-ui/react": "^0.27.20",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",