// An endless list that degrades to pagination: the foot is always a real `rel="next"` link, so // the next page is one navigation away with scripting off. With script, a sentinel below the // list asks for that page before the reader reaches it, and the link's click is intercepted. import type { JSX } from 'solid-js'; import { ariaBool } from '../a11y'; import { cx } from '../cx'; import { UI_KEYS } from '../i18n-keys'; import { useUi } from '../theme/context'; import { solid } from '../theme/solid-adapter'; import styles from './InfiniteScroll.module.scss'; import { loadMoreState } from './infinite-scroll-view'; import { createLoadMoreTrigger } from './load-more-trigger'; import { Spinner } from './Spinner'; /** One viewport-ish of runway, so the next page is usually there before the current one ends. */ const ROOT_MARGIN = '400px'; export interface InfiniteScrollProps { /** The items rendered so far. This component owns the foot, never the list. */ children: JSX.Element; /** Another page exists. */ hasMore: boolean; /** The next page's URL. Required whenever `hasMore` — it is the no-JS path. */ nextHref?: string | undefined; /** A page is in flight; the control stops inviting a second request. */ loading?: boolean | undefined; /** The enhancement: called when the sentinel appears, and in place of following the link. */ onLoadMore?: (() => void) | undefined; /** How far below the fold loading starts. A CSS length, as IntersectionObserver takes it. */ rootMargin?: string | undefined; class?: string | undefined; } export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element { const ui = useUi(); const rt = solid(); let sentinel: HTMLSpanElement | undefined; // Built once per mount; the observer reports into it and so does the `loading` edge below. const trigger = createLoadMoreTrigger(() => props.onLoadMore?.()); rt.createEffect(() => { const onLoadMore = props.onLoadMore; if (onLoadMore === undefined || sentinel === undefined) return; // No observer means no enhancement — the link below is already the working control. if (!props.hasMore || typeof IntersectionObserver === 'undefined') return; const observer = new IntersectionObserver( (entries) => { trigger.seen( entries.some((entry) => entry.isIntersecting), props.loading === true, ); }, { rootMargin: props.rootMargin ?? ROOT_MARGIN }, ); observer.observe(sentinel); rt.onCleanup(() => observer.disconnect()); }); // A page that loaded without pushing the sentinel out of view gets no new intersection report, // so the loading edge re-asks — the list stalled here with its runway still showing. rt.createEffect(() => { const loading = props.loading === true; if (props.hasMore) trigger.settled(loading); }); const onClick = (event: MouseEvent): void => { if (props.onLoadMore === undefined) return; event.preventDefault(); props.onLoadMore(); }; const state = (): ReturnType => loadMoreState({ hasMore: props.hasMore, loading: props.loading, nextHref: props.nextHref, }); return (
{props.children}
{state() === 'loading' ? : null} {state() === 'more' ? ( ) : null} {state() === 'end' ? (

{ui.t(UI_KEYS.endOfList)}

) : null}
); }