+ {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"
+ >
+
+
+ {t("sidebar.year.sortBy")}
+
+
+
+ {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. */}
+
+ {from}
+
+
+ {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)
+ ) : (
+
+ {from + i}
+
+ ),
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/ui/chipStyles.ts b/frontend/src/components/ui/chipStyles.ts
index cae3df12..0d0c39db 100644
--- a/frontend/src/components/ui/chipStyles.ts
+++ b/frontend/src/components/ui/chipStyles.ts
@@ -7,7 +7,8 @@ export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
export type ChipSize =
| "md" // 28, text-sm — the applied-filter row above the feed
| "sm" // 24, text-xs — a filter row
- | "xs"; // 24, text-2xs, tight — a tag in a table row
+ | "xs" // 24, text-2xs, tight — a tag in a table row
+ | "cell"; // 36, two lines (a label over its count), a rounded box — a cell of a grid of choices
const PAINT: Record