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);
|
||||
|
||||
Reference in New Issue
Block a user