fix(filters): the year dialog fits a tablet, walks by arrow keys, names one year once

- a decade cell is a 36-high box with its count under the year (owner A)
- arrow keys walk the cells: along a decade, then to the nearest year below
- clearing the kept older year hands the focus on instead of dropping it
- More... shows while a year with titles is hidden, as a plain chip
- the decade caption is not read twice; the Plex range fields have names
- one Plex year is labelled 1999, not 1999-1999, via oneYear/singleYear
- tests for the Plex wiring and the feed year clearing the upload date
This commit is contained in:
2026-11-14 17:56:37 +01:00
parent 02714e4986
commit 0eca18bcb4
20 changed files with 393 additions and 109 deletions
+78 -49
View File
@@ -1,10 +1,13 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
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;
@@ -12,13 +15,15 @@ export interface YearCount {
}
// 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 — so a library spanning ninety years fits on one screen without paging,
// and a decade reads at a glance; sorted by count it is one grid, the busiest year first. A year with
// no titles is a blank cell, not a choice. 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.
// 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"
const QUICK = 6; // recent-year chips before "More…"
type Sort = "year" | "count";
export default function YearPicker({
@@ -31,20 +36,21 @@ export default function YearPicker({
selected?: number;
onPick: (year?: number) => void;
}) {
const { t } = useTranslation();
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(() => {
const top = newestFirst.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [newestFirst, selected]);
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 toggle = (year: number) => onPick(selected === year ? undefined : year);
const pickAndClose = (year: number) => {
@@ -52,18 +58,22 @@ export default function YearPicker({
setOpen(false);
};
const cell = (y: YearCount) => {
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>
<CountBadge count={y.count} active={on} />
<span className={on ? "text-2xs tabular-nums" : "text-muted text-2xs tabular-nums"}>
{formatCompact(y.count, i18n.language)}
</span>
</Chip>
);
};
@@ -72,17 +82,27 @@ export default function YearPicker({
<div 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} onClick={() => toggle(y.year)}>
<Chip
key={y.year}
pressed={on}
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>
);
})}
{years.length > quick.length && (
{more && (
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
{t("sidebar.year.more")}
</Chip>
)}
{open && (
@@ -109,9 +129,9 @@ export default function YearPicker({
{sort === "year" ? (
<DecadeGrid years={newestFirst} cell={cell} />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
<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)}
</div>
</NavGrid>
)}
</Modal>
)}
@@ -119,43 +139,52 @@ export default function YearPicker({
);
}
/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. */
/** 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) => React.ReactNode;
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) => (
<div
key={from}
role="group"
aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
className="grid grid-cols-[3rem_repeat(10,minmax(0,1fr))] items-center gap-1.5"
>
<span className="text-muted text-xs tabular-nums">{from}</span>
{Array.from({ length: 10 }, (_, i) => {
const y = byYear.get(from + i);
return y ? (
cell(y)
) : (
<span
key={from + i}
aria-hidden="true"
className="text-muted/50 text-center text-xs tabular-nums"
>
{from + i}
</span>
);
})}
</div>
))}
{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>
);
}