Picking in the year dialog the one year the chips hid took More… away with the dialog, and the focus fell to <body>: it now goes to the picked year's chip. A count badge is compact in the UI language everywhere (a chip read "17200" beside its cell's "17.2K"). While the Plex facets load or fail, the sections they fill say so instead of vanishing as if the library had none.
204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
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<Sort>("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<HTMLDivElement>(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 <body>, 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<HTMLElement>(`[data-year="${year}"]`);
|
|
(chip ?? row.querySelector<HTMLElement>("button"))?.focus({ preventScroll: true });
|
|
}, 0);
|
|
};
|
|
|
|
const cell = (y: YearCount, index: number) => {
|
|
const on = selected === y.year;
|
|
return (
|
|
<Chip
|
|
key={y.year}
|
|
size="cell"
|
|
pressed={on}
|
|
className="w-full"
|
|
onClick={() => pickAndClose(y.year)}
|
|
data-year={y.year}
|
|
{...navItemAttrs(index)}
|
|
>
|
|
<span className="tabular-nums">{y.year}</span>
|
|
<span className={on ? "text-2xs tabular-nums" : "text-muted text-2xs tabular-nums"}>
|
|
{formatCompact(y.count, i18n.language)}
|
|
</span>
|
|
</Chip>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div ref={chipsRef} className="flex flex-wrap gap-1.5">
|
|
{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 <body> (which also folded an unpinned rail).
|
|
const leaves = on && !recent.some((r) => r.year === y.year);
|
|
return (
|
|
<Chip
|
|
key={y.year}
|
|
pressed={on}
|
|
data-year={y.year}
|
|
onClick={(e) => {
|
|
if (leaves && e.currentTarget === document.activeElement)
|
|
handOffFocus(e.currentTarget, () => toggle(y.year));
|
|
else toggle(y.year);
|
|
}}
|
|
>
|
|
<span className="tabular-nums">{y.year}</span>
|
|
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
|
</Chip>
|
|
);
|
|
})}
|
|
{more && (
|
|
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
|
|
{t("sidebar.year.more")}
|
|
</Chip>
|
|
)}
|
|
{open && (
|
|
<Modal
|
|
title={t("sidebar.year.moreLabel")}
|
|
initialFocus="close"
|
|
onClose={() => setOpen(false)}
|
|
maxWidth="max-w-4xl"
|
|
>
|
|
<div className="mb-3 flex items-center justify-end gap-2">
|
|
<span className="text-muted text-xs" aria-hidden="true">
|
|
{t("sidebar.year.sortBy")}
|
|
</span>
|
|
<SegmentedToggle
|
|
ariaLabel={t("sidebar.year.sortBy")}
|
|
value={sort}
|
|
onChange={setSort}
|
|
options={[
|
|
{ value: "year", label: t("sidebar.year.sortYear") },
|
|
{ value: "count", label: t("sidebar.year.sortCount") },
|
|
]}
|
|
/>
|
|
</div>
|
|
{sort === "year" ? (
|
|
<DecadeGrid years={newestFirst} cell={cell} />
|
|
) : (
|
|
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-1.5">
|
|
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
|
|
</NavGrid>
|
|
)}
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="flex flex-col gap-1.5">
|
|
{decades.map((from) => {
|
|
const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i));
|
|
return (
|
|
<div
|
|
key={from}
|
|
role="group"
|
|
aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
|
|
className="grid grid-cols-[3rem_minmax(0,1fr)] items-center gap-1.5"
|
|
>
|
|
{/* The group's name says the decade; read twice, it was noise. */}
|
|
<span className="text-muted text-xs tabular-nums" aria-hidden="true">
|
|
{from}
|
|
</span>
|
|
<NavGrid className="grid grid-cols-10 items-center gap-1.5">
|
|
{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)
|
|
) : (
|
|
<span
|
|
key={from + i}
|
|
aria-hidden="true"
|
|
className="text-muted/50 text-center text-xs tabular-nums"
|
|
>
|
|
{from + i}
|
|
</span>
|
|
),
|
|
)}
|
|
</NavGrid>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|