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:
@@ -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 }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"rating": "IMDb rating",
|
||||
"genre": "Genre",
|
||||
"year": "Year",
|
||||
"yearFrom": "From year",
|
||||
"yearTo": "To year",
|
||||
"duration": "Length",
|
||||
"durationUnit": "min",
|
||||
"durationOpt": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"year": "Year"
|
||||
},
|
||||
"year": {
|
||||
"more": "More",
|
||||
"more": "More…",
|
||||
"moreLabel": "All years",
|
||||
"decade": "{{from}}–{{to}}",
|
||||
"none": "No years yet",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"year": "Év"
|
||||
},
|
||||
"year": {
|
||||
"more": "Több",
|
||||
"more": "Több…",
|
||||
"moreLabel": "Összes év",
|
||||
"decade": "{{from}}–{{to}}",
|
||||
"none": "Még nincs év",
|
||||
|
||||
@@ -52,6 +52,8 @@ import { plexApi } from "./api/plex";
|
||||
export type * from "./api/plex";
|
||||
export {
|
||||
EMPTY_PLEX_FILTERS,
|
||||
oneYear,
|
||||
singleYear,
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user