feat(filters): pick one year from a decade grid, in the feed and in Plex

A shared YearPicker: the recent years as chips with their counts, and a
More dialog that lays every year out by decade (sortable by count), so a
ninety-year library fits on one screen. The feed's scrolling popover gives
way to it; the Plex rail gets it above its range fields, a pick setting
both ends. The Plex facets now count titles per year, the year filter left
out.
This commit is contained in:
2026-11-14 15:08:50 +01:00
parent 94f9ffdb1c
commit 1dff809268
10 changed files with 338 additions and 161 deletions
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `YearPicker` (one calendar year from a list of years with counts: the recent ones as chips, every one in a decade-grid dialog; the feed and Plex rails), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
}),
};
});
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import YearPicker from "./YearPicker";
afterEach(cleanup);
// Eight years over three decades, 1987 and 2014 without titles.
const YEARS = [
{ year: 2026, count: 3 },
{ year: 2024, count: 9 },
{ year: 2023, count: 1 },
{ year: 2021, count: 4 },
{ year: 2020, count: 2 },
{ year: 2015, count: 5 },
{ year: 2013, count: 7 },
{ year: 1985, count: 6 },
];
const open = () => fireEvent.click(screen.getByRole("button", { name: /sidebar\.year\.more/ }));
describe("YearPicker", () => {
it("shows the six newest years as chips, the selected one kept beside them", () => {
render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
const chips = screen.getAllByRole("button", { pressed: false }).map((b) => b.textContent);
expect(chips.slice(0, 6)).toEqual(["20263", "20249", "20231", "20214", "20202", "20155"]);
expect(screen.getByRole("button", { pressed: true }).textContent).toBe("19856");
});
it("lays every year out by decade in the dialog; a year without titles is not a choice", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const dialog = screen.getByRole("dialog");
const rows = within(dialog).getAllByRole("group", { name: /sidebar\.year\.decade/ });
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual([
"sidebar.year.decade:2020-2029",
"sidebar.year.decade:2010-2019",
"sidebar.year.decade:1980-1989",
]);
const in1980s = within(rows[2]!)
.getAllByRole("button")
.map((b) => b.dataset.year);
expect(in1980s).toEqual(["1985"]);
expect(rows[1]!.querySelector('[data-year="2014"]')).toBeNull();
});
it("sorted by count, the busiest year comes first", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
fireEvent.click(screen.getByRole("button", { name: "sidebar.year.sortCount" }));
const order = within(screen.getByRole("dialog"))
.getAllByRole("button")
.map((b) => b.dataset.year)
.filter(Boolean);
expect(order.slice(0, 3)).toEqual(["2024", "2013", "1985"]);
});
it("a pick in the dialog names the year and closes it; the selected year again clears it", () => {
const onPick = vi.fn();
const { rerender } = render(<YearPicker years={YEARS} onPick={onPick} />);
open();
fireEvent.click(screen.getByRole("dialog").querySelector('[data-year="1985"]')!);
expect(onPick).toHaveBeenLastCalledWith(1985);
expect(screen.queryByRole("dialog")).toBeNull();
rerender(<YearPicker years={YEARS} selected={2024} onPick={onPick} />);
fireEvent.click(screen.getByRole("button", { pressed: true }));
expect(onPick).toHaveBeenLastCalledWith(undefined);
});
});
+160
View File
@@ -0,0 +1,160 @@
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 SegmentedToggle from "./SegmentedToggle";
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 — 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.
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 } = useTranslation();
const [open, setOpen] = useState(false);
const [sort, setSort] = useState<Sort>("year");
const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]);
// 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]);
const toggle = (year: number) => onPick(selected === year ? undefined : year);
const pickAndClose = (year: number) => {
toggle(year);
setOpen(false);
};
const cell = (y: YearCount) => {
const on = selected === y.year;
return (
<Chip
key={y.year}
pressed={on}
className="w-full"
onClick={() => pickAndClose(y.year)}
data-year={y.year}
>
<span className="tabular-nums">{y.year}</span>
<CountBadge count={y.count} active={on} />
</Chip>
);
};
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<Chip key={y.year} pressed={on} onClick={() => toggle(y.year)}>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip>
);
})}
{years.length > quick.length && (
<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" />
</Chip>
)}
{open && (
<Modal
title={t("sidebar.year.moreLabel")}
initialFocus="close"
onClose={() => setOpen(false)}
maxWidth="max-w-4xl"
>
<div className="mb-3 flex items-center">
<SegmentedToggle
className="ml-auto"
ariaLabel={t("sidebar.year.sortBy")}
label={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} />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
</div>
)}
</Modal>
)}
</div>
);
}
/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. */
function DecadeGrid({
years,
cell,
}: {
years: readonly YearCount[];
cell: (y: YearCount) => 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>
))}
</div>
);
}