import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Chip } from "./Chip"; import CountBadge from "./CountBadge"; import Modal from "./Modal"; import NavGrid from "./NavGrid"; import SegmentedToggle from "./SegmentedToggle"; import { formatCompact } from "../../lib/format"; import { navItemAttrs } from "../../lib/listNav"; import { handOffFocus } from "../../lib/useFocusKeeper"; export interface YearCount { year: number; count: number; } // A filter for one calendar year, shared by the feed and Plex rails: chips for the most recent years // and a "More…" dialog with every year and its count. The dialog lays the years out by decade — a row // per decade, a cell per year, the count under the year so ten fit across at a tablet's width — and a // library spanning ninety years fits on one screen without paging; sorted by count it is one grid, // the busiest year first. A year with no titles is a blank cell, not a choice. The arrows walk the // cells (lib/listNav): ←→ along a decade, ↑↓ to the nearest year of the next one. Picking the // selected year again clears it. What a pick means (a whole-year date range, a min = max range) is // the caller's: this only names the year. const QUICK = 6; // recent-year chips before "More…" type Sort = "year" | "count"; export default function YearPicker({ years, selected, onPick, }: { /** Every year that has titles, with its count; any order. */ years: readonly YearCount[]; selected?: number; onPick: (year?: number) => void; }) { const { t, i18n } = useTranslation(); const [open, setOpen] = useState(false); const [sort, setSort] = useState("year"); const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]); const recent = useMemo(() => newestFirst.slice(0, QUICK), [newestFirst]); // The selected year stays visible among the chips, even when it is older than the recent ones. const quick = useMemo(() => { if (selected == null || recent.some((y) => y.year === selected)) return recent; const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 }; return [...recent, sel].sort((a, b) => b.year - a.year); }, [newestFirst, recent, selected]); // "More…" while a year with titles is not among the chips — the one kept for the selection counts // only if it is one of them. const more = years.some((y) => !quick.some((q) => q.year === y.year)); const chipsRef = useRef(null); const toggle = (year: number) => onPick(selected === year ? undefined : year); const pickAndClose = (year: number) => { toggle(year); setOpen(false); // A pick that leaves the dialog nothing to show takes More… away with it, and the dialog then has // nowhere to give the focus back: it fell to , which also folded an unpinned rail. So it // goes to the picked year's chip (the first chip if the pick cleared it). A timer, not a frame: a // page the browser is not painting runs no requestAnimationFrame. window.setTimeout(() => { const now = document.activeElement; const row = chipsRef.current; if ((now && now !== document.body && now.isConnected) || !row) return; const chip = row.querySelector(`[data-year="${year}"]`); (chip ?? row.querySelector("button"))?.focus({ preventScroll: true }); }, 0); }; const cell = (y: YearCount, index: number) => { const on = selected === y.year; return ( pickAndClose(y.year)} data-year={y.year} {...navItemAttrs(index)} > {y.year} {formatCompact(y.count, i18n.language)} ); }; return (
{quick.map((y) => { const on = selected === y.year; // The chip kept only for the selection leaves with it: from the keyboard, the focus goes to // its neighbour first, not to (which also folded an unpinned rail). const leaves = on && !recent.some((r) => r.year === y.year); return ( { if (leaves && e.currentTarget === document.activeElement) handOffFocus(e.currentTarget, () => toggle(y.year)); else toggle(y.year); }} > {y.year} {y.count > 0 && } ); })} {more && ( setOpen(true)}> {t("sidebar.year.more")} )} {open && ( setOpen(false)} maxWidth="max-w-4xl" >
{sort === "year" ? ( ) : ( {[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)} )}
)}
); } /** One row per decade, newest first, with a cell per year of it; a year without titles is blank. * Each decade is a list of its own, so ↑↓ land on the nearest year of the next decade. */ function DecadeGrid({ years, cell, }: { years: readonly YearCount[]; cell: (y: YearCount, index: number) => React.ReactNode; }) { const { t } = useTranslation(); const byYear = new Map(years.map((y) => [y.year, y])); const decades = [...new Set(years.map((y) => y.year - (y.year % 10)))].sort((a, b) => b - a); return (
{decades.map((from) => { const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i)); return (
{/* The group's name says the decade; read twice, it was noise. */} {row.map((y, i) => y ? ( // Its index among the decade's years: a blank cell is not an item. cell(y, row.slice(0, i).filter(Boolean).length) ) : ( ), )}
); })}
); }