Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e77057567 | ||
|
|
e0d5a9969e | ||
|
|
dc21e80a5e | ||
|
|
6d3a203699 | ||
|
|
b0c5b6bb57 | ||
|
|
a1128bd801 | ||
|
|
2fd997bfe6 | ||
|
|
bdd9bef349 | ||
|
|
e5f7f4c959 | ||
|
|
60918da483 | ||
|
|
284215fbce |
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Secondary academics: every headline number carries its England anchor and a
|
||||
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
|
||||
* rendered as bare numbers, breaking the "anchored against England" promise).
|
||||
*/
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
|
||||
import { CompareAcademics } from '@/components/compare/CompareAcademics';
|
||||
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
|
||||
function school(urn: number, name: string): School {
|
||||
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
|
||||
}
|
||||
|
||||
function data(urn: number): ComparisonData {
|
||||
return {
|
||||
school_info: school(urn, 'Test High'),
|
||||
yearly_data: [
|
||||
{
|
||||
year: 202425,
|
||||
attainment_8_score: 58.7,
|
||||
english_maths_strong_pass_pct: 30,
|
||||
ebacc_entry_pct: 10,
|
||||
},
|
||||
] as ComparisonData['yearly_data'],
|
||||
ofsted: null,
|
||||
census: null,
|
||||
admissions: null,
|
||||
admissions_history: [],
|
||||
deprivation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const NATIONAL: NationalAverages = {
|
||||
year: 202425,
|
||||
primary: {},
|
||||
secondary: {
|
||||
attainment_8_score: 46.0,
|
||||
english_maths_strong_pass_pct: 45.4,
|
||||
ebacc_entry_pct: 40.5,
|
||||
},
|
||||
by_year: [],
|
||||
};
|
||||
|
||||
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
|
||||
const s = school(137086, 'Bishop Stopford School');
|
||||
render(
|
||||
<CompareAcademics
|
||||
schools={[s]}
|
||||
data={{ '137086': data(137086) }}
|
||||
nationalAverages={NATIONAL}
|
||||
isSecondary
|
||||
/>,
|
||||
);
|
||||
|
||||
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
|
||||
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
|
||||
|
||||
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
|
||||
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
|
||||
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
@@ -183,10 +183,15 @@ body {
|
||||
}
|
||||
|
||||
/* Secondary: teal outline — supporting actions (+ Compare) */
|
||||
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
|
||||
which wins over the base `.btn`'s `1px solid transparent`. The outline
|
||||
variants below therefore declare the full `border` shorthand explicitly so
|
||||
they don't depend on the base border-width — otherwise `border-color` alone
|
||||
has no width and the outline never renders (buttons read as plain text). */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent-teal);
|
||||
border-color: var(--accent-teal);
|
||||
border: 1px solid var(--accent-teal);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--accent-teal-bg);
|
||||
@@ -196,7 +201,7 @@ body {
|
||||
.btn-tertiary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-tertiary:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
@@ -207,7 +212,7 @@ body {
|
||||
.btn-active {
|
||||
background: var(--accent-teal-bg);
|
||||
color: var(--accent-teal);
|
||||
border-color: var(--accent-teal);
|
||||
border: 1px solid var(--accent-teal);
|
||||
}
|
||||
.btn-active:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
|
||||
@@ -123,7 +123,13 @@
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 95vh;
|
||||
/* Bottom sheet sized against the overlay (which tracks the visual
|
||||
viewport), NOT vh: when the keyboard is open the overlay is short, so
|
||||
max-height:100% keeps the whole sheet — input and results — above the
|
||||
keyboard. min-height gives a comfortable default without a tiny stub,
|
||||
but is capped at 100% so it never exceeds the visible area. */
|
||||
min-height: min(55vh, 100%);
|
||||
max-height: 100%;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
animation: slideUp 0.3s ease;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styles from './Modal.module.css';
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
||||
};
|
||||
}, [isOpen, handleEscape]);
|
||||
|
||||
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
|
||||
// the on-screen keyboard shrinks the visual viewport but not the layout one,
|
||||
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
|
||||
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
|
||||
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
|
||||
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
const el = overlayRef.current;
|
||||
if (!vv || !el) return;
|
||||
|
||||
const sync = () => {
|
||||
el.style.top = `${vv.offsetTop}px`;
|
||||
el.style.height = `${vv.height}px`;
|
||||
el.style.bottom = 'auto';
|
||||
};
|
||||
sync();
|
||||
vv.addEventListener('resize', sync);
|
||||
vv.addEventListener('scroll', sync);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', sync);
|
||||
vv.removeEventListener('scroll', sync);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div className={`${styles.modal} ${styles[size]}`}>
|
||||
<div className={styles.header}>
|
||||
{title && <h2 className={styles.title}>{title}</h2>}
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 1.5rem;
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--accent-gold-bg);
|
||||
border: 1px solid var(--accent-gold, #c9a227);
|
||||
@@ -119,12 +111,16 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1612);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.resultButton {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.schoolMeta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -159,21 +155,30 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.25rem;
|
||||
/* One scroll container on mobile: the modal content itself scrolls, so the
|
||||
results list must not add its own inner scroll (double scrollbars, and
|
||||
the input would be trapped above a short 400px window when the keyboard
|
||||
shrinks the sheet). */
|
||||
.results {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Compact stacked card: name + meta, then a full-width action so the tap
|
||||
target is obvious and the card doesn't waste vertical space. */
|
||||
.resultItem {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.625rem;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
.resultButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schoolMeta {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
||||
<div className={styles.modalContent}>
|
||||
<h2 className={styles.title}>Add School to Comparison</h2>
|
||||
|
||||
{!canAddMore && (
|
||||
<div className={styles.warning}>
|
||||
Maximum 5 schools can be compared. Remove a school to add another.
|
||||
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
||||
<button
|
||||
onClick={() => handleAddSchool(school)}
|
||||
disabled={alreadySelected || !canAddMore}
|
||||
className={
|
||||
className={`${styles.resultButton} ${
|
||||
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
||||
}
|
||||
}`}
|
||||
>
|
||||
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
||||
</button>
|
||||
|
||||
@@ -162,6 +162,21 @@ export function CompareAcademics({
|
||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||
|
||||
// Every headline number gets its England anchor + verdict chip, so the
|
||||
// "anchored against the England average" promise holds for the grade-5
|
||||
// and EBacc rows too, not just Attainment 8.
|
||||
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
|
||||
if (value == null || anchor == null) return null;
|
||||
const v = verdict(value, anchor, tol);
|
||||
return (
|
||||
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
|
||||
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
@@ -174,7 +189,8 @@ export function CompareAcademics({
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{att8[i] != null ? (
|
||||
<>
|
||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
|
||||
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
|
||||
{anchorChip(att8[i], att8Anchor, 2)}
|
||||
{att8Anchor != null && (
|
||||
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
|
||||
)}
|
||||
@@ -216,14 +232,38 @@ export function CompareAcademics({
|
||||
</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
|
||||
{grade5[i] != null ? (
|
||||
<>
|
||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||
{Math.round(grade5[i] as number)}%
|
||||
</span>{' '}
|
||||
{anchorChip(grade5[i], grade5Anchor, 3)}
|
||||
{grade5Anchor != null && (
|
||||
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
|
||||
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
|
||||
{schools.map((school, i) => (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
|
||||
{ebacc[i] != null ? (
|
||||
<>
|
||||
<span className={s.big} style={{ fontSize: '1.1rem' }}>
|
||||
{Math.round(ebacc[i] as number)}%
|
||||
</span>{' '}
|
||||
{anchorChip(ebacc[i], ebaccAnchor, 3)}
|
||||
{ebaccAnchor != null && (
|
||||
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={s.small}>No data</span>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
</SectionGrid>
|
||||
|
||||
@@ -239,8 +239,17 @@ export function CompareOfsted({
|
||||
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
|
||||
return (
|
||||
<Cell key={school.urn} school={school} index={i}>
|
||||
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{school.school_name}'s Ofsted page →
|
||||
{/* Short visible label to save space on mobile (the coloured
|
||||
school name already leads the row); the full name stays in
|
||||
aria-label so screen readers can tell the links apart. */}
|
||||
<a
|
||||
className={s.link}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${school.school_name}'s Ofsted page`}
|
||||
>
|
||||
Ofsted page →
|
||||
</a>
|
||||
</Cell>
|
||||
);
|
||||
|
||||
@@ -72,15 +72,18 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.8rem;
|
||||
/* Slightly larger than the values below it so the school each row belongs
|
||||
to is easy to read on mobile (hidden on desktop, where the column header
|
||||
names the school). */
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--sc, var(--text-secondary));
|
||||
margin-bottom: 0.15rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.cellDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot, var(--text-muted));
|
||||
flex: none;
|
||||
|
||||
Reference in New Issue
Block a user