The section ranks on distance and is headed "Other schools nearby", but every identifier still called it "similar" — the exact drift that leaves a later reader trusting a name over the behaviour. Mechanical: files, the module, the payload key, the type, the components, the prop. No behaviour change; the suites are unchanged in count and still green. Free to do now because #150 has not merged, so the payload key rename needs no lockstep deploy. Uses of "similar" that are ordinary English — progress measures compared to similar pupils, and unrelated comments — are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
128 lines
3.7 KiB
TypeScript
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 './NearbySchools.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 NearbySchoolsCarousel({
|
|
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>
|
|
);
|
|
}
|