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
+1 -1
View File
@@ -52,7 +52,7 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
and player rungs; the `cell` chip, 36, a grid of choices). Pick a rung; do not hand-roll padding on a primitive.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -9,6 +9,7 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
i18n: { language: "en" },
}),
};
});
@@ -80,4 +81,60 @@ describe("YearPicker", () => {
fireEvent.click(screen.getByRole("button", { pressed: true }));
expect(onPick).toHaveBeenLastCalledWith(undefined);
});
it("offers More… while a year with titles is not among the chips, the kept selection aside", () => {
// Seven years, and a selected year with no titles kept beside the six newest: 2013 is hidden.
render(<YearPicker years={YEARS.slice(0, 7)} selected={1999} onPick={() => {}} />);
expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy();
});
it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
expect(
screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"),
).toBeNull();
});
it("clearing the kept older year hands the focus on, not to <body>", () => {
const { rerender } = render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
const kept = screen.getByRole("button", { pressed: true });
kept.focus();
fireEvent.click(kept);
rerender(<YearPicker years={YEARS} onPick={() => {}} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "sidebar.year.more" }));
});
it("a decade row is named once: its caption is not read again", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const row = screen.getAllByRole("group", { name: /sidebar\.year\.decade/ })[0]!;
expect(row.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
});
it("a cell carries its count under the year, compact", () => {
render(<YearPicker years={[...YEARS, { year: 2025, count: 17243 }]} onPick={() => {}} />);
open();
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[data-year="2025"]')!;
expect(cell.textContent).toBe("202517.2K");
});
it("the arrows walk the cells: along a decade, then down to the next one", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const dialog = screen.getByRole("dialog");
dialog.querySelector<HTMLElement>('[data-year="2020"]')!.focus();
press("ArrowRight");
expect((document.activeElement as HTMLElement).dataset.year).toBe("2021");
press("ArrowDown");
expect((document.activeElement as HTMLElement).dataset.year).toMatch(/^201/);
});
});
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
function press(key: string): void {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
+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>
);
}
+8 -5
View File
@@ -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<ChipVariant, string> = {
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
@@ -23,13 +24,15 @@ const PAINT: Record<ChipVariant, string> = {
};
const SIZES: Record<ChipSize, string> = {
md: "h-7 px-2.5 text-sm gap-1",
sm: "h-6 px-2.5 text-xs gap-1.5",
xs: "h-6 px-1.5 text-2xs gap-1",
md: "h-7 px-2.5 text-sm gap-1 rounded-full",
sm: "h-6 px-2.5 text-xs gap-1.5 rounded-full",
xs: "h-6 px-1.5 text-2xs gap-1 rounded-full",
// Narrow enough for ten across a dialog at a tablet's width: the count goes under the label.
cell: "h-9 px-1 text-xs leading-tight flex-col rounded-lg",
};
const BASE =
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
"inline-flex min-w-6 shrink-0 items-center justify-center whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
@@ -35,7 +35,7 @@ const IV = keys<IconButtonVariant>({
}); // prettier-ignore
const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
const CS = keys<ChipSize>({ md: true, sm: true, xs: true });
const CS = keys<ChipSize>({ md: true, sm: true, xs: true, cell: true });
const missing = (emitted: string[], file: string) => {
const found = tokens(file);