Files
school_compare/nextjs-app/components/school/SimilarSchoolsCarousel.tsx
T
TudorandClaude Opus 5 91314a80b5 feat(web): similar schools section, honest about what it matched
Server-rendered cards inside a client carousel that scrolls rather than
paginates, so all six links stay in the initial HTML and the row still works
with JavaScript off. Three client islands, split by what each needs: a school,
the whole selection, a DOM ref.

The lede claims a similar intake only when no card came from tier 3, chips
list what a school actually shares, a missing figure reads "Not published",
and the neighbour's number carries no valence colour — green and terracotta
mean "against England" everywhere else, and colouring it here would read as
ranking the neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:41:40 +01:00

128 lines
3.7 KiB
TypeScript

'use client';
/**
* The scroller and its arrows.
*
* `children` are the server-rendered cards and `header` the server-rendered
* heading and lede: both stay server components, passed through, so this file
* owns a DOM ref and nothing else. That is what keeps all six links in the
* initial HTML — a carousel that mounted cards on click would put four of the
* six beyond a crawler and beyond a reader with no JavaScript.
*
* With JavaScript off this degrades to a horizontally scrollable row, which is
* still usable by touch and trackpad.
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import styles from './SimilarSchools.module.css';
/** Three cards fit the row, so fewer than four has nowhere to scroll to.
* Below 640px the arrows are not rendered at all — see the stylesheet. */
const VISIBLE = 3;
/**
* Why a tolerance rather than `=== 0`.
*
* The scroller carries 2px of padding so focus rings are not clipped, and
* scroll-snap treats that padding as the first card's snap position — a row at
* rest reports scrollLeft 2, not 0. Sub-pixel rounding moves it again at other
* zoom levels. An exact test leaves the back arrow live on first paint,
* pointing nowhere.
*/
const EDGE = 8;
export function SimilarSchoolsCarousel({
count,
labelledBy,
header,
children,
}: {
count: number;
labelledBy: string;
header: ReactNode;
children: ReactNode;
}) {
const scroller = useRef<HTMLUListElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const scrollable = count > VISIBLE;
const sync = useCallback(() => {
const node = scroller.current;
if (!node) return;
const max = node.scrollWidth - node.clientWidth;
setAtStart(node.scrollLeft <= EDGE);
setAtEnd(node.scrollLeft >= max - EDGE);
}, []);
// `atEnd` is not only the forward arrow's disabled state: below 640px, where
// no arrow is rendered, it is the only thing driving the scroll-fade.
// Also on mount: the first measurement can only happen once there is layout.
useEffect(sync, [sync]);
const page = (direction: 1 | -1) => {
const node = scroller.current;
if (!node) return;
// A page is what the reader can see, so the viewport is the step.
node.scrollBy({ left: direction * node.clientWidth, behavior: 'smooth' });
};
return (
<>
<div className={styles.top}>
{header}
{scrollable && (
<div className={styles.arrows}>
<button
type="button"
className={styles.arrow}
onClick={() => page(-1)}
disabled={atStart}
aria-label="Previous schools"
>
<Chevron direction="prev" />
</button>
<button
type="button"
className={styles.arrow}
onClick={() => page(1)}
disabled={atEnd}
aria-label="More schools"
>
<Chevron direction="next" />
</button>
</div>
)}
</div>
<ul
ref={scroller}
className={styles.scroller}
onScroll={sync}
data-at-end={atEnd}
{...(scrollable
? { tabIndex: 0, role: 'group', 'aria-labelledby': labelledBy }
: {})}
>
{children}
</ul>
</>
);
}
function Chevron({ direction }: { direction: 'prev' | 'next' }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={direction === 'prev' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
</svg>
);
}