Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e8df006d7 | ||
|
|
1f8284adfc | ||
|
|
b2dc4d0779 | ||
|
|
1cdcd85e41 | ||
|
|
a00cbe9161 | ||
|
|
64121592fd | ||
|
|
6828f6cd44 | ||
|
|
331ae8d89f | ||
|
|
3adea73ee0 | ||
|
|
47335fcda0 |
@@ -25,6 +25,16 @@ test('home page loads with hero search', async ({ page }) => {
|
||||
await expect(page.getByPlaceholder('School name or postcode').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('home hero offers a "use my location" shortcut beside the search box', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The geolocation shortcut lives inside the hero search card, right under the
|
||||
// search input — not in a separate strip further down the page.
|
||||
const searchInput = page.getByPlaceholder('School name or postcode').first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
const nearMe = page.getByRole('button', { name: /use my location/i });
|
||||
await expect(nearMe).toBeVisible();
|
||||
});
|
||||
|
||||
test('searching by name returns school results', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
@@ -75,6 +85,31 @@ test('school hero map opens fullscreen on mobile without the Fullscreen API', as
|
||||
await expect(openMap).toBeVisible();
|
||||
});
|
||||
|
||||
test('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
||||
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
||||
// map's fullscreen button must fall back to a CSS overlay.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() => {
|
||||
// @ts-expect-error deliberate API removal
|
||||
delete Element.prototype.requestFullscreen;
|
||||
});
|
||||
|
||||
await searchByName(page, 'B1 1BB');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to the map view, then open the map fullscreen.
|
||||
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||
await openFs.click();
|
||||
|
||||
// The button flips to its exit state once the overlay is up.
|
||||
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
||||
await expect(exitFs).toBeVisible();
|
||||
await exitFs.click();
|
||||
await expect(openFs).toBeVisible();
|
||||
});
|
||||
|
||||
test('comparing two schools shows both side by side', async ({ page }) => {
|
||||
// Collect two school URNs from search results, then load the share URL
|
||||
await searchByName(page, 'primary');
|
||||
@@ -100,15 +135,20 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
|
||||
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
|
||||
);
|
||||
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
|
||||
expect(urns.length).toBeGreaterThanOrEqual(2);
|
||||
// Compare three schools, not two: a "primary" search can return all-through
|
||||
// schools that classify as secondary, and the chips only appear for the
|
||||
// active phase. With three schools across two phases, the auto-selected
|
||||
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
|
||||
expect(urns.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`);
|
||||
await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
|
||||
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The mobile chart legend renders one chip per school inside the chart card.
|
||||
// The mobile chart legend renders one chip per school in the active phase.
|
||||
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
|
||||
const chips = chipGroup.getByRole('button');
|
||||
await expect(chips).toHaveCount(2);
|
||||
await expect(chips.first()).toBeVisible({ timeout: 15_000 });
|
||||
expect(await chips.count()).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Tapping a chip focuses that school's line; tapping again releases it.
|
||||
await chips.first().click();
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* Two chips per row so long school names don't crowd into a single
|
||||
line; each chip fills its column and truncates with an ellipsis. */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -31,8 +33,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
max-width: 100%;
|
||||
min-height: 40px;
|
||||
min-width: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, .12);
|
||||
border-radius: 999px;
|
||||
@@ -58,6 +60,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 9rem;
|
||||
/* min-width:0 lets the name shrink inside the grid cell so the
|
||||
ellipsis kicks in instead of overflowing. */
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,91 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary, #5a554d);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,21 @@ interface FilterBarProps {
|
||||
filters: Filters;
|
||||
isHero?: boolean;
|
||||
resultFilters?: ResultFilters;
|
||||
// Geolocation "use my location" affordance, shown beside the hero search box.
|
||||
// The state and handler live in HomeView (which owns the geolocation flow).
|
||||
onNearMe?: () => void;
|
||||
geoState?: "idle" | "requesting" | "error";
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
resultFilters,
|
||||
onNearMe,
|
||||
geoState = "idle",
|
||||
geoError,
|
||||
}: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -182,6 +194,52 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
<>
|
||||
<p className={styles.searchHint}>
|
||||
Search by <strong>school name</strong> — or use your{" "}
|
||||
<strong>postcode</strong> for the nearest schools.
|
||||
</p>
|
||||
{onNearMe && (
|
||||
<div className={styles.nearMeRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.nearMeBtn}
|
||||
onClick={onNearMe}
|
||||
disabled={geoState === "requesting"}
|
||||
>
|
||||
{geoState === "requesting" ? (
|
||||
<>
|
||||
<span className={styles.nearMeSpinner} aria-hidden="true" />
|
||||
Locating you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z" />
|
||||
<circle cx="12" cy="9" r="2.5" />
|
||||
</svg>
|
||||
Use my location
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{geoError && (
|
||||
<p className={styles.geoError} role="alert">
|
||||
{geoError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{!isHero && (
|
||||
|
||||
@@ -369,6 +369,16 @@
|
||||
|
||||
.viewToggle {
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
|
||||
& Maths %"), which overflows a phone viewport — beside the view toggle it
|
||||
ran off the right edge. Let it flex into the remaining space and shrink;
|
||||
the selected label truncates instead of pushing past the screen. */
|
||||
.sortSelect {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapViewContainer {
|
||||
@@ -496,68 +506,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.discoverySection {
|
||||
padding: 0.5rem 0 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--accent-teal, #2d7d7d);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: #235f5f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeBtnSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quickSearches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -284,37 +284,11 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
{/* Discovery section shown on landing page before any search */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.discoverySection}>
|
||||
<div className={styles.nearMeRow}>
|
||||
<button
|
||||
className={styles.nearMeBtn}
|
||||
onClick={handleNearMe}
|
||||
disabled={geoState === 'requesting'}
|
||||
>
|
||||
{geoState === 'requesting' ? (
|
||||
<>
|
||||
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||||
Locating you…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||||
<circle cx="12" cy="9" r="2.5"/>
|
||||
</svg>
|
||||
Schools near me
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
|
||||
can't promote the element, so pin it over the page ourselves. Above the
|
||||
comparison toast (3000) and the bottom nav; below modals (9999+). */
|
||||
.mapWrapper.fsFallback {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
.fullscreenBtn {
|
||||
position: absolute;
|
||||
top: 0.625rem;
|
||||
|
||||
@@ -33,22 +33,52 @@ interface SchoolMapProps {
|
||||
|
||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
|
||||
// overlay driven by state instead of the Fullscreen API.
|
||||
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || fallbackFullscreen;
|
||||
|
||||
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||
}, []);
|
||||
|
||||
// Lock body scroll while the fallback overlay is up.
|
||||
useEffect(() => {
|
||||
if (!fallbackFullscreen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [fallbackFullscreen]);
|
||||
|
||||
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
|
||||
// one; the CSS fallback overlay changes size without a resize event, so nudge
|
||||
// Leaflet after the layout settles or the map fills only part of the screen.
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
wrapperRef.current?.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
if (fallbackFullscreen) {
|
||||
setFallbackFullscreen(false);
|
||||
return;
|
||||
}
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
||||
} else {
|
||||
setFallbackFullscreen(true);
|
||||
}
|
||||
}, [fallbackFullscreen]);
|
||||
|
||||
// Calculate center if not provided
|
||||
const mapCenter: [number, number] = center || (() => {
|
||||
@@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
||||
})();
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
||||
<button
|
||||
className={styles.fullscreenBtn}
|
||||
onClick={toggleFullscreen}
|
||||
|
||||
Reference in New Issue
Block a user