import type { BrowsableSourceItemView } from "@batch-cooking/shared"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import "../recipes.scss"; /** * List of one source's browsable items (`RecipeSourcesPanel`) — same * "photo + name, click/Enter to select" row shape as `RecipeTable`, plus * an "already imported" badge in place of allergen/regime columns (a * source item has neither, it's not resolved against our catalogs until * previewed). * * Infinite scroll, not a "voir plus" button: a zero-content sentinel row * (`source-item-table__sentinel`) sits right after `items`, watched by an * `IntersectionObserver` scoped to this table's own scrolling container * (`.recipe-table-wrap`, not the page) — scrolling it into view calls * `onLoadMore`, the same callback a button's `onClick` would have. Kept * entirely inside this component rather than exposed as a prop callback * signature change on every call site: `RecipeSourcesPanel` doesn't need to * know *how* "load more" gets triggered, only that it does. */ export function SourceItemTable({ items, selectedExternalId, onSelect, placeholderCount = 0, hasMore = false, onLoadMore, }: { items: BrowsableSourceItemView[]; selectedExternalId: string | null; onSelect: (item: BrowsableSourceItemView) => void; /** * Extra pulsing skeleton rows appended after `items` — `RecipeSourcesPanel` * sets this while a "load more" fetch is in flight, so the list fills in * right away instead of looking like nothing happened. Purely decorative: * never clickable/focusable, unlike a real row. */ placeholderCount?: number; /** Whether a further page exists — renders the sentinel row (and therefore observes it) only when true; the observer would otherwise have nothing meaningful to trigger once the catalog is exhausted. */ hasMore?: boolean; /** Called once when the sentinel row scrolls into view — `RecipeSourcesPanel`'s own re-entrancy guard (not this component) is what keeps a still-visible sentinel from firing this repeatedly while a fetch is already in flight. */ onLoadMore?: () => void; }) { const { t } = useTranslation(); const scrollContainerRef = useRef(null); const sentinelRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: `onLoadMore` is deliberately excluded — RecipeSourcesPanel passes a fresh function identity on every render, and re-subscribing the observer on every single render (rather than only when hasMore actually flips) would be wasteful busywork for no behavioral difference. useEffect(() => { const root = scrollContainerRef.current; const sentinel = sentinelRef.current; if (!hasMore || !onLoadMore || !root || !sentinel) return; // `rootMargin` starts loading the next page slightly before the // sentinel actually reaches the visible edge — combined with // RecipeSourcesPanel's own prefetch-ahead-of-time, the goal is that a // page finishes arriving before anyone actually scrolls far enough to // need it, not just "as soon as" they do. const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) onLoadMore(); }, { root, rootMargin: "200px 0px" }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [hasMore]); return (
{items.map((item) => ( onSelect(item)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(item); } }} tabIndex={0} aria-current={item.externalId === selectedExternalId ? "true" : undefined} > ))} {hasMore && ( )} {Array.from({ length: placeholderCount }, (_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: a fixed-length run of interchangeable, content-less placeholders — there's no stable identity to key on, and the count never reorders. ))}
{t("recipes.table.name")}
{item.title} {item.alreadyImported && ( {t("recipes.sources.alreadyImported")} )}
); }