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);
+2
View File
@@ -76,6 +76,8 @@
"rating": "IMDb rating",
"genre": "Genre",
"year": "Year",
"yearFrom": "From year",
"yearTo": "To year",
"duration": "Length",
"durationUnit": "min",
"durationOpt": {
+1 -1
View File
@@ -34,7 +34,7 @@
"year": "Year"
},
"year": {
"more": "More",
"more": "More…",
"moreLabel": "All years",
"decade": "{{from}}–{{to}}",
"none": "No years yet",
+2
View File
@@ -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": {
+1 -1
View File
@@ -34,7 +34,7 @@
"year": "Év"
},
"year": {
"more": "Több",
"more": "Több…",
"moreLabel": "Összes év",
"decade": "{{from}}–{{to}}",
"none": "Még nincs év",
+2
View File
@@ -52,6 +52,8 @@ import { plexApi } from "./api/plex";
export type * from "./api/plex";
export {
EMPTY_PLEX_FILTERS,
oneYear,
singleYear,
plexFilterCount,
coercePlexFilters,
plexFiltersSignature,
+11
View File
@@ -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<PlexFilters, "yearMin" | "yearMax"> => ({
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<PlexFilters, "yearMin" | "yearMax">): 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[];
@@ -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<PlexFilters>) =>
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");
});
});
+9 -6
View File
@@ -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, unknown>) => 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}`,
@@ -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<typeof import("react-i18next")>();
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(
<QueryClientProvider client={new QueryClient()}>
<YearFilter filters={filters} setFilters={setFilters} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: /2010/ }));
expect(setFilters).toHaveBeenCalledWith({
...filters,
year: 2010,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
});
});
});
+2 -6
View File
@@ -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}
</Filterable>
+54 -31
View File
@@ -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: (
<div className="flex flex-col gap-2">
{/* 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 && (
<YearPicker
years={facets.years}
selected={
filters.yearMin != null && filters.yearMin === filters.yearMax
? filters.yearMin
: undefined
}
onPick={(year) => patch({ yearMin: year ?? null, yearMax: year ?? null })}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
),
body: <PlexYearFilter filters={filters} facets={facets} patch={patch} />,
},
...(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<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
selected={singleYear(filters)}
onPick={(year) => patch(oneYear(year))}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
label={t("plex.filter.yearFrom")}
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
label={t("plex.filter.yearTo")}
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
);
}
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 (
<Input
aria-label={label}
className="w-20 tabular-nums"
size="sm"
type="number"
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The rail's Year section: a year from the picker is a range of one (both ends), the picker shows a
// range of one as its selected year, and the two range fields carry a name of their own.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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(
<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={patch} />,
);
fireEvent.click(screen.getByRole("button", { name: /1999/ }));
expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 });
rerender(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 1999 }}
facets={FACETS}
patch={patch}
/>,
);
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(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 2001 }}
facets={FACETS}
patch={() => {}}
/>,
);
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
});
it("names the range fields", () => {
render(<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={() => {}} />);
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy();
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy();
});
});
@@ -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
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
onClick={onFilter ? () => onFilter(oneYear(year)) : undefined}
>
{year}
</Filterable>
+2 -5
View File
@@ -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({
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined}
>
{show.year}
</Filterable>