Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf2ac47c1 | ||
|
|
a102508ef1 | ||
|
|
684da611ff | ||
|
|
0186227ced | ||
|
|
59b5f3f163 | ||
|
|
8e763e39d1 | ||
|
|
84bca53c7e | ||
|
|
15b7493b85 | ||
|
|
1d9d2eb5ae | ||
|
|
ddb42badb6 | ||
|
|
702408d6a4 | ||
|
|
09d94e513e | ||
|
|
598ba9af4e | ||
|
|
dc85254ad2 | ||
|
|
2f038285f6 |
@@ -606,17 +606,21 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"religious_denomination": latest.get("religious_denomination", ""),
|
"religious_denomination": latest.get("religious_denomination", ""),
|
||||||
"age_range": latest.get("age_range", ""),
|
"age_range": latest.get("age_range", ""),
|
||||||
"has_sixth_form": latest.get("has_sixth_form"),
|
"has_sixth_form": latest.get("has_sixth_form"),
|
||||||
|
"nursery_provision": latest.get("nursery_provision"),
|
||||||
"status": latest.get("status"),
|
"status": latest.get("status"),
|
||||||
"latitude": latest.get("latitude"),
|
"latitude": latest.get("latitude"),
|
||||||
"longitude": latest.get("longitude"),
|
"longitude": latest.get("longitude"),
|
||||||
"phase": latest.get("phase"),
|
"phase": latest.get("phase"),
|
||||||
# GIAS fields
|
# GIAS fields
|
||||||
"website": latest.get("website"),
|
"website": latest.get("website"),
|
||||||
|
"telephone": latest.get("telephone"),
|
||||||
"headteacher_name": latest.get("headteacher_name"),
|
"headteacher_name": latest.get("headteacher_name"),
|
||||||
"capacity": latest.get("capacity"),
|
"capacity": latest.get("capacity"),
|
||||||
"total_pupils": latest.get("gias_total_pupils"),
|
"total_pupils": latest.get("gias_total_pupils"),
|
||||||
"trust_name": latest.get("trust_name"),
|
"trust_name": latest.get("trust_name"),
|
||||||
"gender": latest.get("gender"),
|
"gender": latest.get("gender"),
|
||||||
|
"county": latest.get("county"),
|
||||||
|
"parliamentary_constituency": latest.get("parliamentary_constituency"),
|
||||||
}.items()
|
}.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -169,6 +169,8 @@ _MAIN_QUERY = text("""
|
|||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
|
s.telephone,
|
||||||
|
s.nursery_provision,
|
||||||
foi.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
foi.ofsted_date,
|
foi.ofsted_date,
|
||||||
foi.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
@@ -178,6 +180,8 @@ _MAIN_QUERY = text("""
|
|||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
l.address_line2 AS address2,
|
l.address_line2 AS address2,
|
||||||
l.town,
|
l.town,
|
||||||
|
l.county,
|
||||||
|
l.parliamentary_constituency,
|
||||||
l.postcode,
|
l.postcode,
|
||||||
l.latitude,
|
l.latitude,
|
||||||
l.longitude,
|
l.longitude,
|
||||||
@@ -301,6 +305,32 @@ for _old, _new in _LEGACY_NAME_REPLACEMENTS:
|
|||||||
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
_MAIN_QUERY_LEGACY_NAMES = _MAIN_QUERY_LEGACY_NAMES.replace(_old, _new)
|
||||||
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
_MAIN_QUERY_LEGACY_NAMES = text(_MAIN_QUERY_LEGACY_NAMES)
|
||||||
|
|
||||||
|
# Fallback used when marts predate the optional GIAS/location detail columns
|
||||||
|
# (telephone, nursery provision, county, parliamentary constituency) — i.e. the
|
||||||
|
# nightly dbt pipeline hasn't rebuilt the mart yet on this DB. Keeps each column
|
||||||
|
# present as NULL so the whole data load degrades gracefully instead of failing.
|
||||||
|
_EXTRA_OPTIONAL_COLUMNS = (
|
||||||
|
"telephone",
|
||||||
|
"nursery_provision",
|
||||||
|
"county",
|
||||||
|
"parliamentary_constituency",
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = str(_MAIN_QUERY)
|
||||||
|
for _col, _tbl in (
|
||||||
|
("telephone", "s"),
|
||||||
|
("nursery_provision", "s"),
|
||||||
|
("county", "l"),
|
||||||
|
("parliamentary_constituency", "l"),
|
||||||
|
):
|
||||||
|
_src = f"{_tbl}.{_col},"
|
||||||
|
assert _src in _MAIN_QUERY_NO_EXTRA_COLS, (
|
||||||
|
f"expected {_src!r} to be present in _MAIN_QUERY before replacement"
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = _MAIN_QUERY_NO_EXTRA_COLS.replace(
|
||||||
|
_src, f"NULL AS {_col},"
|
||||||
|
)
|
||||||
|
_MAIN_QUERY_NO_EXTRA_COLS = text(_MAIN_QUERY_NO_EXTRA_COLS)
|
||||||
|
|
||||||
_GIAS_CODE_COLUMN_NAMES = (
|
_GIAS_CODE_COLUMN_NAMES = (
|
||||||
"phase_code",
|
"phase_code",
|
||||||
"school_type_code",
|
"school_type_code",
|
||||||
@@ -309,7 +339,11 @@ _GIAS_CODE_COLUMN_NAMES = (
|
|||||||
"admissions_policy_code",
|
"admissions_policy_code",
|
||||||
)
|
)
|
||||||
|
|
||||||
_MISSING_COLUMN_RE = re.compile(r'column "?(?:s\.)?(\w+)"? does not exist')
|
# Strip any single table alias prefix (s., l., p., foi., …) — Postgres reports
|
||||||
|
# an undefined *qualified* column unquoted as "column l.county does not exist",
|
||||||
|
# so matching only the s. alias would miss dim_location columns (county,
|
||||||
|
# parliamentary_constituency) and defeat the graceful-degradation fallback.
|
||||||
|
_MISSING_COLUMN_RE = re.compile(r'column "?(?:\w+\.)?(\w+)"? does not exist')
|
||||||
|
|
||||||
|
|
||||||
def _missing_column_name(exc: Exception) -> Optional[str]:
|
def _missing_column_name(exc: Exception) -> Optional[str]:
|
||||||
@@ -352,6 +386,18 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
except Exception as exc2:
|
except Exception as exc2:
|
||||||
print(f"Warning: Could not load school data from marts: {exc2}")
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
elif missing in _EXTRA_OPTIONAL_COLUMNS:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"marts.dim_school/dim_location is missing %s (pipeline hasn't "
|
||||||
|
"rebuilt the mart yet on this DB) — retrying without the "
|
||||||
|
"optional detail columns: %s",
|
||||||
|
missing, exc,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
df = pd.read_sql(_MAIN_QUERY_NO_EXTRA_COLS, engine)
|
||||||
|
except Exception as exc2:
|
||||||
|
print(f"Warning: Could not load school data from marts: {exc2}")
|
||||||
|
return pd.DataFrame()
|
||||||
else:
|
else:
|
||||||
print(f"Warning: Could not load school data from marts: {exc}")
|
print(f"Warning: Could not load school data from marts: {exc}")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ def test_missing_column_name_table_prefixed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_name_location_alias_prefixed():
|
||||||
|
# dim_location columns are selected via the `l.` alias; Postgres reports a
|
||||||
|
# missing qualified column unquoted (e.g. "column l.county does not exist").
|
||||||
|
# The matcher must strip any alias, not just `s.`, or the county /
|
||||||
|
# parliamentary_constituency fallback never triggers and the whole data
|
||||||
|
# load degrades to an empty DataFrame (zero schools) instead of NULLs.
|
||||||
|
assert (
|
||||||
|
_missing_column_name(_fake_exc("column l.county does not exist"))
|
||||||
|
== "county"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_missing_column_name(
|
||||||
|
_fake_exc("column l.parliamentary_constituency does not exist")
|
||||||
|
)
|
||||||
|
== "parliamentary_constituency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_column_name_no_match_returns_none():
|
def test_missing_column_name_no_match_returns_none():
|
||||||
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
assert _missing_column_name(_fake_exc("relation \"marts.dim_school\" does not exist")) is None
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -121,6 +121,33 @@ test('school detail page renders name and performance data', async ({ page }) =>
|
|||||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('school detail page shows GIAS identity/contact details and drops the unwired Phonics section', async ({ page }) => {
|
||||||
|
const [urn] = await twoPrimaryUrns(page);
|
||||||
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const info = (await res.json()).school_info;
|
||||||
|
|
||||||
|
await page.goto(`/school/${urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Phonics, SEN-type breakdown and average class size were never populated by
|
||||||
|
// the backend — the sections have been removed, so the Phonics section (its
|
||||||
|
// own #phonics anchor) must no longer exist.
|
||||||
|
await expect(page.locator('#phonics')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Newly surfaced GIAS/location fields render when the record carries them.
|
||||||
|
const ageMatch = String(info.age_range ?? '').match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||||||
|
if (ageMatch) {
|
||||||
|
await expect(page.getByText(`Ages ${ageMatch[1]}–${ageMatch[2]}`).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
if (info.telephone) {
|
||||||
|
await expect(page.locator('a[href^="tel:"]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
if (info.parliamentary_constituency) {
|
||||||
|
await expect(page.getByText('Constituency:').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
|
||||||
// Detail views detected report cards via `framework`, which the API never
|
// Detail views detected report cards via `framework`, which the API never
|
||||||
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
|
||||||
@@ -478,3 +505,36 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
|
|||||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||||
expect(await rows.count()).toBeGreaterThan(5);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,30 @@ describe('buildCompareChart', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores a no-results school whose only row has a null year', () => {
|
||||||
|
// A school with no performance rows comes back from /api/compare with a
|
||||||
|
// single phantom yearly_data row (LEFT JOIN) where year and every metric
|
||||||
|
// are null. That null year must NOT pollute the axis: Math.trunc(null) is
|
||||||
|
// 0, and filling from year 0 blows past the real years, blanking every
|
||||||
|
// school's line. Regression guard for "add a no-data school → chart empty".
|
||||||
|
const withNoData = {
|
||||||
|
...THREE_SCHOOLS,
|
||||||
|
'4': school(4, [[null as unknown as number, null]]),
|
||||||
|
};
|
||||||
|
const list = [...SCHOOL_LIST, { urn: 4, school_name: 'School 4' }];
|
||||||
|
const chart = buildCompareChart(withNoData, list, 'rwm_expected_pct');
|
||||||
|
|
||||||
|
// The real years still drive the axis; the phantom year 0 is gone.
|
||||||
|
expect(chart.years).toContain(201819);
|
||||||
|
expect(chart.years).toContain(202425);
|
||||||
|
expect(chart.years).not.toContain(0);
|
||||||
|
// The three real schools still render their lines.
|
||||||
|
for (const urn of [1, 2, 3]) {
|
||||||
|
const ds = chart.schoolDatasets[urn - 1];
|
||||||
|
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('handles float years from the API (202425.0 style)', () => {
|
it('handles float years from the API (202425.0 style)', () => {
|
||||||
const floaty = {
|
const floaty = {
|
||||||
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -191,8 +191,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
senDetail={sen_detail ?? null}
|
|
||||||
phonics={phonics ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -205,8 +203,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
senDetail={sen_detail ?? null}
|
|
||||||
phonics={phonics ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
|
||||||
import styles from './MetricTooltip.module.css';
|
import { InfoPopover } from './InfoPopover';
|
||||||
|
|
||||||
interface MetricTooltipProps {
|
interface MetricTooltipProps {
|
||||||
metricKey?: string;
|
metricKey?: string;
|
||||||
@@ -13,51 +12,13 @@ interface MetricTooltipProps {
|
|||||||
|
|
||||||
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
|
||||||
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
|
||||||
const tooltipLabel = label ?? explanation?.label;
|
const resolvedLabel = 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.wrapper} ref={wrapperRef}>
|
<InfoPopover
|
||||||
<button
|
label={resolvedLabel}
|
||||||
type="button"
|
plain={plain ?? explanation?.plain}
|
||||||
className={styles.icon}
|
detail={detail ?? explanation?.detail}
|
||||||
aria-expanded={open}
|
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { MetricTooltip } from './MetricTooltip';
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||||
@@ -68,15 +68,13 @@ interface SchoolDetailViewProps {
|
|||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
senDetail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SchoolDetailView({
|
export function SchoolDetailView({
|
||||||
schoolInfo, yearlyData, absenceData,
|
schoolInfo, yearlyData, absenceData,
|
||||||
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
ofsted, census, admissions, admissionsHistory, deprivation, finance,
|
||||||
}: SchoolDetailViewProps) {
|
}: SchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
@@ -161,14 +159,12 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||||||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||||||
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
// have KS4 data) but isAllThrough gates the primary-only content (KS2 SATs,
|
||||||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||||||
const isPrimary = !isSecondary;
|
const isPrimary = !isSecondary;
|
||||||
// Primary-stage content shows for pure-primary AND all-through schools.
|
|
||||||
const showPrimaryContent = isPrimary || isAllThrough;
|
|
||||||
|
|
||||||
// National averages (fetched dynamically so they stay current)
|
// National averages (fetched dynamically so they stay current)
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
@@ -220,11 +216,9 @@ export function SchoolDetailView({
|
|||||||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||||||
|| (latestResults?.eal_pct != null)
|
|| (latestResults?.eal_pct != null)
|
||||||
|| (latestResults?.sen_support_pct != null)
|
|| (latestResults?.sen_support_pct != null)
|
||||||
|| senDetail != null
|
|
||||||
|| hasGenderSplit;
|
|| hasGenderSplit;
|
||||||
|
|
||||||
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
const hasSchoolLife = absenceData != null;
|
||||||
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
|
||||||
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||||
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
||||||
@@ -265,7 +259,6 @@ export function SchoolDetailView({
|
|||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
|
||||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
@@ -358,6 +351,22 @@ export function SchoolDetailView({
|
|||||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.age_range && (
|
||||||
|
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.religious_denomination && (
|
||||||
|
<span className={styles.metaItem}>
|
||||||
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||||
|
? 'None'
|
||||||
|
: schoolInfo.religious_denomination}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.nursery_provision && (
|
||||||
|
<span className={styles.metaItem}>Nursery</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.has_sixth_form && (
|
||||||
|
<span className={styles.metaItem}>Sixth form</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isProposedToClose(schoolInfo) && (
|
{isProposedToClose(schoolInfo) && (
|
||||||
<div className={styles.closingStrip} role="note">
|
<div className={styles.closingStrip} role="note">
|
||||||
@@ -416,6 +425,24 @@ export function SchoolDetailView({
|
|||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.telephone && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Phone:</strong>{' '}
|
||||||
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||||
|
{schoolInfo.telephone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.county && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>County:</strong> {schoolInfo.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.parliamentary_constituency && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions} ref={heroActionsRef}>
|
<div className={styles.actions} ref={heroActionsRef}>
|
||||||
@@ -1084,29 +1111,6 @@ export function SchoolDetailView({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{senDetail && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle}>Types of additional needs supported</h3>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
What proportion of pupils with additional needs have each type of support need.
|
|
||||||
</p>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
{[
|
|
||||||
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
|
|
||||||
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
|
|
||||||
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
|
|
||||||
{ label: 'Specific Learning (e.g. Dyslexia)', pct: senDetail.primary_need_spld_pct },
|
|
||||||
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
|
|
||||||
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
|
|
||||||
].filter(n => n.pct != null).map(({ label, pct }) => (
|
|
||||||
<div key={label} className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>{label}</div>
|
|
||||||
<div className={styles.metricValue}>{pct}%</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1231,41 +1235,11 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
|
||||||
{hasPhonics && showPrimaryContent && phonics && (
|
|
||||||
<section id="phonics" className={styles.card}>
|
|
||||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
|
||||||
<p className={styles.sectionSubtitle}>
|
|
||||||
Phonics is a key early reading skill. Children are tested at the end of Year 1.
|
|
||||||
</p>
|
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Passed the phonics check</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
|
|
||||||
<div className={styles.metricHint}>Phonics is a key early reading skill tested at end of Year 1</div>
|
|
||||||
</div>
|
|
||||||
{phonics.year2_phonics_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Year 2 pupils who retook and passed</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* School Life */}
|
{/* School Life */}
|
||||||
{hasSchoolLife && (
|
{hasSchoolLife && (
|
||||||
<section id="school-life" className={styles.card}>
|
<section id="school-life" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>School Life</h2>
|
<h2 className={styles.sectionTitle}>School Life</h2>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{census?.class_size_avg != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>Average class size</div>
|
|
||||||
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
|
|
||||||
<div className={styles.metricHint}>Average number of pupils per class</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{absenceData?.overall_absence_rate != null && (
|
{absenceData?.overall_absence_rate != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.metricLabel}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const PerformanceChart = dynamic(
|
|||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||||
@@ -68,15 +68,13 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
senDetail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
export function SecondarySchoolDetailView({
|
||||||
schoolInfo, yearlyData,
|
schoolInfo, yearlyData,
|
||||||
ofsted, census, admissions, senDetail, deprivation, finance, absenceData,
|
ofsted, census, admissions, deprivation, finance, absenceData,
|
||||||
}: SecondarySchoolDetailViewProps) {
|
}: SecondarySchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Hero map — the "View on map" link opens its fullscreen view.
|
// Hero map — the "View on map" link opens its fullscreen view.
|
||||||
@@ -248,6 +246,16 @@ export function SecondarySchoolDetailView({
|
|||||||
{schoolInfo.age_range && (
|
{schoolInfo.age_range && (
|
||||||
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
<span className={styles.badge}>{formatAgeRange(schoolInfo.age_range)}</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.religious_denomination && (
|
||||||
|
<span className={styles.badge}>
|
||||||
|
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
||||||
|
? 'None'
|
||||||
|
: schoolInfo.religious_denomination}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.nursery_provision && (
|
||||||
|
<span className={styles.badge}>Nursery</span>
|
||||||
|
)}
|
||||||
{hasSixthForm && (
|
{hasSixthForm && (
|
||||||
<span className={styles.badge}>Sixth form</span>
|
<span className={styles.badge}>Sixth form</span>
|
||||||
)}
|
)}
|
||||||
@@ -310,6 +318,24 @@ export function SecondarySchoolDetailView({
|
|||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{schoolInfo.telephone && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Phone:</strong>{' '}
|
||||||
|
<a href={`tel:${schoolInfo.telephone.replace(/\s+/g, '')}`}>
|
||||||
|
{schoolInfo.telephone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.county && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>County:</strong> {schoolInfo.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.parliamentary_constituency && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -132,20 +132,6 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { CSSProperties, ReactNode } from 'react';
|
|||||||
|
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils';
|
||||||
|
import { InfoPopover } from '@/components/InfoPopover';
|
||||||
import styles from './compareSections.module.css';
|
import styles from './compareSections.module.css';
|
||||||
|
|
||||||
export function Section({
|
export function Section({
|
||||||
@@ -52,11 +53,7 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string
|
|||||||
return (
|
return (
|
||||||
<div className={styles.rowLabel}>
|
<div className={styles.rowLabel}>
|
||||||
{children}
|
{children}
|
||||||
{tip && (
|
{tip && <InfoPopover plain={tip} />}
|
||||||
<span className={styles.help} title={tip} aria-label={tip}>
|
|
||||||
?
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
|
|
||||||
import type { ComparisonData } from './types';
|
import type { ComparisonData } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A yearly row only counts once it carries a real academic year. A school with
|
||||||
|
* no performance data still comes back from /api/compare with a single phantom
|
||||||
|
* row (the dim_school LEFT JOIN) where `year` is null — and Math.trunc(null) is
|
||||||
|
* 0, which would seed the axis at year 0 and, via fillAcademicYears, blow past
|
||||||
|
* every real year and blank all schools' lines. Drop those rows up front.
|
||||||
|
*/
|
||||||
|
function hasYear(row: { year: number }): boolean {
|
||||||
|
return typeof row.year === 'number' && Number.isFinite(row.year);
|
||||||
|
}
|
||||||
|
|
||||||
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
||||||
function nextAcademicYear(year: number): number {
|
function nextAcademicYear(year: number): number {
|
||||||
const start = Math.floor(year / 100);
|
const start = Math.floor(year / 100);
|
||||||
@@ -66,14 +77,19 @@ export function buildCompareChart(
|
|||||||
nationalByYear?: Record<number, number | null | undefined>,
|
nationalByYear?: Record<number, number | null | undefined>,
|
||||||
): CompareChart {
|
): CompareChart {
|
||||||
const rawYears = schools.flatMap(
|
const rawYears = schools.flatMap(
|
||||||
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
(s) =>
|
||||||
|
comparisonData[String(s.urn)]?.yearly_data.filter(hasYear).map((d) => Math.trunc(d.year)) ??
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
const years = fillAcademicYears(rawYears);
|
const years = fillAcademicYears(rawYears);
|
||||||
|
|
||||||
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
||||||
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
||||||
const byYear = new Map<number, Record<string, unknown>>();
|
const byYear = new Map<number, Record<string, unknown>>();
|
||||||
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
for (const row of rows) {
|
||||||
|
if (!hasYear(row)) continue;
|
||||||
|
byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: school.school_name,
|
label: school.school_name,
|
||||||
data: years.map((year) => {
|
data: years.map((year) => {
|
||||||
|
|||||||
+4
-19
@@ -18,6 +18,7 @@ export interface School {
|
|||||||
religious_denomination: string | null;
|
religious_denomination: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
has_sixth_form?: boolean | null;
|
has_sixth_form?: boolean | null;
|
||||||
|
nursery_provision?: boolean | null;
|
||||||
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
|
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
|
||||||
|
|
||||||
// Address
|
// Address
|
||||||
@@ -60,11 +61,14 @@ export interface School {
|
|||||||
|
|
||||||
// GIAS enrichment fields
|
// GIAS enrichment fields
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
telephone?: string | null;
|
||||||
headteacher_name?: string | null;
|
headteacher_name?: string | null;
|
||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
trust_name?: string | null;
|
trust_name?: string | null;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
admissions_policy?: string | null;
|
admissions_policy?: string | null;
|
||||||
|
county?: string | null;
|
||||||
|
parliamentary_constituency?: string | null;
|
||||||
|
|
||||||
// Ofsted (for list view — summary only)
|
// Ofsted (for list view — summary only)
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
@@ -130,7 +134,6 @@ export interface SchoolCensus {
|
|||||||
male_pupils: number | null;
|
male_pupils: number | null;
|
||||||
fsm_pct: number | null;
|
fsm_pct: number | null;
|
||||||
eal_pct: number | null;
|
eal_pct: number | null;
|
||||||
class_size_avg?: number | null;
|
|
||||||
ethnicity_white_pct?: number | null;
|
ethnicity_white_pct?: number | null;
|
||||||
ethnicity_asian_pct?: number | null;
|
ethnicity_asian_pct?: number | null;
|
||||||
ethnicity_black_pct?: number | null;
|
ethnicity_black_pct?: number | null;
|
||||||
@@ -159,22 +162,6 @@ export interface SchoolAdmissions {
|
|||||||
cross_la_offers?: number | null;
|
cross_la_offers?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SenDetail {
|
|
||||||
year: number;
|
|
||||||
primary_need_speech_pct: number | null;
|
|
||||||
primary_need_autism_pct: number | null;
|
|
||||||
primary_need_mld_pct: number | null;
|
|
||||||
primary_need_spld_pct: number | null;
|
|
||||||
primary_need_semh_pct: number | null;
|
|
||||||
primary_need_physical_pct: number | null;
|
|
||||||
primary_need_other_pct: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Phonics {
|
|
||||||
year: number;
|
|
||||||
year1_phonics_pct: number | null;
|
|
||||||
year2_phonics_pct: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchoolDeprivation {
|
export interface SchoolDeprivation {
|
||||||
lsoa_code: string | null;
|
lsoa_code: string | null;
|
||||||
@@ -343,8 +330,6 @@ export interface SchoolDetailsResponse {
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
admissions_history: SchoolAdmissions[];
|
admissions_history: SchoolAdmissions[];
|
||||||
sen_detail: SenDetail | null;
|
|
||||||
phonics: Phonics | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+60
@@ -8,6 +8,7 @@
|
|||||||
"name": "nextjs-app",
|
"name": "nextjs-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@types/node": "^25.2.0",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -832,6 +833,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -9136,6 +9190,12 @@
|
|||||||
"url": "https://opencollective.com/synckit"
|
"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": {
|
"node_modules/test-exclude": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:coverage": "jest --coverage"
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@types/node": "^25.2.0",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user