diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7ee47296..71e7230e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -232,6 +232,8 @@ Also in force: `FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2] - **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch). + One exception, the `cell` size: a cell of a grid of choices (the year dialog's decades) is a 36-high + rounded box with its count under the label, so ten fit across a dialog at a tablet's width. `filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter chips too, and while their popover is open they show it as an open trigger does, the accent edge and text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing @@ -894,3 +896,5 @@ of a full-width panel and nothing on the right. | 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB | | 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit | | 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere | +| 2026-11-14 | One year filter for the feed and Plex (owner ★) | `ui/YearPicker`: the six newest years as chips (the selected one kept beside them), "More…" opens every year laid out by decade, no paging, sortable by count; Plex keeps its range fields under it, a year is a range of one | The feed's scrolling popover; Plex's range fields alone | A library spanning ninety years fits on one screen by decade, and both rails pick a year the same way | +| 2026-11-14 | The year dialog's cell (owner ★A) | The count under the year in a 36-high rounded box, compact ("17.2K"), one row per decade at every width | The year-and-count pill, five columns and two rows per decade on a narrow screen | The pill spilled out of its cell at a tablet's width (measured 74 px in 62); a decade on one row still reads at a glance | diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index fc96ed83..e69c8414 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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 diff --git a/frontend/src/components/ui/YearPicker.dom.test.tsx b/frontend/src/components/ui/YearPicker.dom.test.tsx index 243735c8..c78bb35c 100644 --- a/frontend/src/components/ui/YearPicker.dom.test.tsx +++ b/frontend/src/components/ui/YearPicker.dom.test.tsx @@ -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(); @@ -9,6 +9,7 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (k: string, o?: Record) => 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( {}} />); + expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy(); + }); + + it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => { + render( {}} />); + expect( + screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"), + ).toBeNull(); + }); + + it("clearing the kept older year hands the focus on, not to ", () => { + const { rerender } = render( {}} />); + const kept = screen.getByRole("button", { pressed: true }); + kept.focus(); + fireEvent.click(kept); + rerender( {}} />); + 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( {}} />); + 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( {}} />); + open(); + const cell = screen.getByRole("dialog").querySelector('[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( {}} />); + open(); + const dialog = screen.getByRole("dialog"); + dialog.querySelector('[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 }), + ); + }); +} diff --git a/frontend/src/components/ui/YearPicker.tsx b/frontend/src/components/ui/YearPicker.tsx index a66b393d..c686ad4f 100644 --- a/frontend/src/components/ui/YearPicker.tsx +++ b/frontend/src/components/ui/YearPicker.tsx @@ -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("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 ( pickAndClose(y.year)} data-year={y.year} + {...navItemAttrs(index)} > {y.year} - + + {formatCompact(y.count, i18n.language)} + ); }; @@ -72,17 +82,27 @@ export default function YearPicker({
{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 ( - toggle(y.year)}> + { + if (leaves && e.currentTarget === document.activeElement) + handOffFocus(e.currentTarget, () => toggle(y.year)); + else toggle(y.year); + }} + > {y.year} {y.count > 0 && } ); })} - {years.length > quick.length && ( + {more && ( setOpen(true)}> - {t("sidebar.year.more")} - )} {open && ( @@ -109,9 +129,9 @@ export default function YearPicker({ {sort === "year" ? ( ) : ( -
+ {[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)} -
+ )} )} @@ -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 (
- {decades.map((from) => ( -
- {from} - {Array.from({ length: 10 }, (_, i) => { - const y = byYear.get(from + i); - return y ? ( - cell(y) - ) : ( - - ); - })} -
- ))} + {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. */} + + + {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) + ) : ( + + ), + )} + +
+ ); + })}
); } 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 = { // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed @@ -23,13 +24,15 @@ const PAINT: Record = { }; const SIZES: Record = { - 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. */ diff --git a/frontend/src/components/ui/tailwindScan.contract.test.ts b/frontend/src/components/ui/tailwindScan.contract.test.ts index 0f8381fe..e3bfa561 100644 --- a/frontend/src/components/ui/tailwindScan.contract.test.ts +++ b/frontend/src/components/ui/tailwindScan.contract.test.ts @@ -35,7 +35,7 @@ const IV = keys({ }); // prettier-ignore const IS = keys({ sm: true, md: true, lg: true, player: true }); const CV = keys({ filter: true, token: true, tag: true, suggest: true, ghost: true }); -const CS = keys({ md: true, sm: true, xs: true }); +const CS = keys({ md: true, sm: true, xs: true, cell: true }); const missing = (emitted: string[], file: string) => { const found = tokens(file); diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 72ff1ecf..62f1a2f3 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -76,6 +76,8 @@ "rating": "IMDb rating", "genre": "Genre", "year": "Year", + "yearFrom": "From year", + "yearTo": "To year", "duration": "Length", "durationUnit": "min", "durationOpt": { diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 818307f3..3aae23f1 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -34,7 +34,7 @@ "year": "Year" }, "year": { - "more": "More", + "more": "More…", "moreLabel": "All years", "decade": "{{from}}–{{to}}", "none": "No years yet", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index a7e17d40..9f0c7554 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -76,6 +76,8 @@ "rating": "IMDb pont", "genre": "Műfaj", "year": "Év", + "yearFrom": "Ettől az évtől", + "yearTo": "Eddig az évig", "duration": "Hossz", "durationUnit": "perc", "durationOpt": { diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index bce083d9..b61e14eb 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -34,7 +34,7 @@ "year": "Év" }, "year": { - "more": "Több", + "more": "Több…", "moreLabel": "Összes év", "decade": "{{from}}–{{to}}", "none": "Még nincs év", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8f27f625..92d7ddfa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,8 @@ import { plexApi } from "./api/plex"; export type * from "./api/plex"; export { EMPTY_PLEX_FILTERS, + oneYear, + singleYear, plexFilterCount, coercePlexFilters, plexFiltersSignature, diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index bf031975..197d6011 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -261,6 +261,17 @@ export interface PlexPersonDetail { movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person shows: PlexCard[]; // enabled-library shows, same } +/** One year as a Plex filter: a range of one, both ends set (the year picker, a year on an info page). + * `null` clears it. */ +export const oneYear = (year?: number | null): Pick => ({ + yearMin: year ?? null, + yearMax: year ?? null, +}); + +/** The one year a filter narrows to, when its range is a single year. */ +export const singleYear = (f: Pick): number | undefined => + f.yearMin != null && f.yearMin === f.yearMax ? f.yearMin : undefined; + // The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. export interface PlexFilters { genres: string[]; diff --git a/frontend/src/lib/plexActiveFilters.dom.test.ts b/frontend/src/lib/plexActiveFilters.dom.test.ts new file mode 100644 index 00000000..9fc63b51 --- /dev/null +++ b/frontend/src/lib/plexActiveFilters.dom.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { buildPlexActiveFilters } from "./plexActiveFilters"; +import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api"; + +const t = (key: string) => key; +const yearLabel = (f: Partial) => + buildPlexActiveFilters( + { ...EMPTY_PLEX_FILTERS, ...f }, + "", + () => {}, + () => {}, + t, + ).find((c) => c.key === "year")?.label; + +describe("the Plex year chip", () => { + it("names one year as the year, not a range of one", () => { + expect(yearLabel({ yearMin: 1999, yearMax: 1999 })).toBe("plex.filter.year: 1999"); + }); + + it("names a range and an open end as before", () => { + expect(yearLabel({ yearMin: 1990, yearMax: 1999 })).toBe("plex.filter.year: 1990–1999"); + expect(yearLabel({ yearMin: 1990 })).toBe("plex.filter.year: ≥1990"); + expect(yearLabel({ yearMax: 1999 })).toBe("plex.filter.year: ≤1999"); + }); +}); diff --git a/frontend/src/lib/plexActiveFilters.ts b/frontend/src/lib/plexActiveFilters.ts index 4bf461fb..9b040707 100644 --- a/frontend/src/lib/plexActiveFilters.ts +++ b/frontend/src/lib/plexActiveFilters.ts @@ -1,6 +1,6 @@ import { vocabI18nKey } from "./vocab"; import { type ActiveFilter } from "./useActiveFilters"; -import { type PlexFilters } from "./api"; +import { singleYear, type PlexFilters } from "./api"; type TFn = (key: string, options?: Record) => string; @@ -42,12 +42,15 @@ export function buildPlexActiveFilters( }); } if (has(filters.yearMin) || has(filters.yearMax)) { + const one = singleYear(filters); const range = - has(filters.yearMin) && has(filters.yearMax) - ? `${filters.yearMin}–${filters.yearMax}` - : has(filters.yearMin) - ? `≥${filters.yearMin}` - : `≤${filters.yearMax}`; + one != null + ? String(one) + : has(filters.yearMin) && has(filters.yearMax) + ? `${filters.yearMin}–${filters.yearMax}` + : has(filters.yearMin) + ? `≥${filters.yearMin}` + : `≤${filters.yearMax}`; out.push({ key: "year", label: `${t("plex.filter.year")}: ${range}`, diff --git a/frontend/src/modules/feed/YearFilter.dom.test.tsx b/frontend/src/modules/feed/YearFilter.dom.test.tsx new file mode 100644 index 00000000..a14f773b --- /dev/null +++ b/frontend/src/modules/feed/YearFilter.dom.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// +// The feed rail's Year: a year and the Upload-date control are the same narrowing, so picking a year +// clears an upload-date window or range. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +import YearFilter from "./YearFilter"; +import { api, type FeedFilters } from "../../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const BASE: FeedFilters = { + tags: [], + tagModes: { language: "or", topic: "or", other: "or" }, + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", + channelIds: [], +}; + +describe("the feed rail's Year", () => { + it("picking a year clears the Upload-date window and range", async () => { + vi.spyOn(api, "feedYears").mockResolvedValue({ years: [{ year: 2010, count: 3 }] }); + const setFilters = vi.fn(); + const filters = { ...BASE, maxAgeDays: 7, dateFrom: "2020-01-01", dateTo: "2020-02-01" }; + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: /2010/ })); + expect(setFilters).toHaveBeenCalledWith({ + ...filters, + year: 2010, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + }); + }); +}); diff --git a/frontend/src/modules/plex/PlexInfo.tsx b/frontend/src/modules/plex/PlexInfo.tsx index c1f99b96..a78e75ab 100644 --- a/frontend/src/modules/plex/PlexInfo.tsx +++ b/frontend/src/modules/plex/PlexInfo.tsx @@ -3,7 +3,7 @@ import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; -import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; +import { api, oneYear, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { notifyActionError } from "../../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; @@ -427,11 +427,7 @@ export default function PlexInfo({ title={ onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined } - onClick={ - onFilter - ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) - : undefined - } + onClick={onFilter ? () => onFilter(oneYear(detail.year)) : undefined} > {detail.year} diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 12737e4e..bc90f2fd 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -6,7 +6,14 @@ import { vocab } from "../../lib/vocab"; import { plexLang } from "../../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; -import { api, plexFilterCount, type PlexFilters } from "../../lib/api"; +import { + api, + oneYear, + plexFilterCount, + singleYear, + type PlexFacets, + type PlexFilters, +} from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; import { useDebounced } from "../../lib/useDebounced"; import FilterRail from "../../components/ui/FilterRail"; @@ -173,36 +180,7 @@ export default function PlexSidebar() { { id: "year", title: t("plex.filter.year"), - body: ( -
- {/* One year is a range of one: picking it sets both ends, as the year on an info page does, - so the range fields below show it and a decade stays typeable there. */} - {facets && facets.years.length > 0 && ( - patch({ yearMin: year ?? null, yearMax: year ?? null })} - /> - )} -
- patch({ yearMin: v })} - /> - – - patch({ yearMax: v })} - /> -
-
- ), + body: , }, ...(scope === "movie" ? [ @@ -499,17 +477,62 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi ); } +/** The rail's Year section: the year picker over the range fields. One year is a range of one: + * picking it sets both ends, as the year on an info page does, so the range fields show it and a + * decade stays typeable there. */ +export function PlexYearFilter({ + filters, + facets, + patch, +}: { + filters: PlexFilters; + facets: PlexFacets | undefined; + patch: (p: Partial) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {facets && facets.years.length > 0 && ( + patch(oneYear(year))} + /> + )} +
+ patch({ yearMin: v })} + /> + – + patch({ yearMax: v })} + /> +
+
+ ); +} + function NumberInput({ + label, value, placeholder, onChange, }: { + /** Its accessible name: the placeholder (a year) is not one. */ + label: string; value: number | null; placeholder?: number; onChange: (v: number | null) => void; }) { return ( { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); +vi.mock("./plexContext", () => ({ + usePlex: () => ({}), + usePlexActions: () => ({ setPlaylistOpen: vi.fn() }), +})); + +import { PlexYearFilter } from "./PlexSidebar"; +import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api"; + +afterEach(cleanup); + +const FACETS = { + years: [ + { year: 2001, count: 4 }, + { year: 1999, count: 2 }, + ], + year_min: 1999, + year_max: 2001, +} as unknown as PlexFacets; + +describe("the Plex rail's Year section", () => { + it("picks a year as a range of one, and clears both ends again", () => { + const patch = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /1999/ })); + expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 }); + + rerender( + , + ); + const on = screen.getByRole("button", { pressed: true }); + expect(on.textContent).toContain("1999"); + fireEvent.click(on); + expect(patch).toHaveBeenLastCalledWith({ yearMin: null, yearMax: null }); + }); + + it("a wider range selects no year", () => { + render( + {}} + />, + ); + expect(screen.queryByRole("button", { pressed: true })).toBeNull(); + }); + + it("names the range fields", () => { + render( {}} />); + expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy(); + expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/plex/plexDetailAside.tsx b/frontend/src/modules/plex/plexDetailAside.tsx index 3e0fcd57..07448cf2 100644 --- a/frontend/src/modules/plex/plexDetailAside.tsx +++ b/frontend/src/modules/plex/plexDetailAside.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { PlexFilters } from "../../lib/api"; +import { oneYear, type PlexFilters } from "../../lib/api"; import { formatMoney, formatRuntime } from "../../lib/format"; import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail"; import { regionName, vocab } from "../../lib/vocab"; @@ -75,7 +75,7 @@ function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFi onFilter({ yearMin: year, yearMax: year }) : undefined} + onClick={onFilter ? () => onFilter(oneYear(year)) : undefined} > {year} diff --git a/frontend/src/modules/plex/plexViews.tsx b/frontend/src/modules/plex/plexViews.tsx index 2ee74481..8dda49a7 100644 --- a/frontend/src/modules/plex/plexViews.tsx +++ b/frontend/src/modules/plex/plexViews.tsx @@ -16,6 +16,7 @@ import { import { qk } from "../../lib/queryKeys"; import { api, + oneYear, type PlexCard, type PlexFilters, type PlexPersonDetail, @@ -613,11 +614,7 @@ export function PlexShowView({ onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } + onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined} > {show.year}