diff --git a/frontend/src/components/ui/CountBadge.tsx b/frontend/src/components/ui/CountBadge.tsx index a99265bb..bbb1c283 100644 --- a/frontend/src/components/ui/CountBadge.tsx +++ b/frontend/src/components/ui/CountBadge.tsx @@ -1,15 +1,20 @@ +import { useTranslation } from "react-i18next"; +import { formatCompact } from "../../lib/format"; + // A small count pill shown beside a tab label or filter chip (pending requests, item counts). One // source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits // on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant -// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. +// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. The count is +// compact in the UI language ("17.2K" / "17,2 E"), the same as every other count in the app. export default function CountBadge({ count, active }: { count: number; active?: boolean }) { + const { i18n } = useTranslation(); return ( - {count} + {formatCompact(count, i18n.language)} ); } diff --git a/frontend/src/components/ui/YearPicker.dom.test.tsx b/frontend/src/components/ui/YearPicker.dom.test.tsx index c78bb35c..2f47b17e 100644 --- a/frontend/src/components/ui/YearPicker.dom.test.tsx +++ b/frontend/src/components/ui/YearPicker.dom.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { useState } from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react"; @@ -88,6 +89,44 @@ describe("YearPicker", () => { expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy(); }); + it("offers no More… once every year with titles is a chip", () => { + const { rerender } = render( {}} />); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + // Seven years, the seventh selected: it is kept as a chip, so nothing is left for the dialog. + rerender( {}} />); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + }); + + it("picking the one year the chips hid moves the focus to its chip, not to ", () => { + // Seven years: the pick keeps 2013 as a chip, so More… leaves with the dialog it opened. + function Picker() { + const [year, setYear] = useState(); + return ; + } + vi.useFakeTimers(); + try { + render(); + const more = screen.getByRole("button", { name: "sidebar.year.more" }); + more.focus(); + fireEvent.click(more); + const cell = screen.getByRole("dialog").querySelector('[data-year="2013"]')!; + cell.focus(); + fireEvent.click(cell); + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("button", { pressed: true })); + } finally { + vi.useRealTimers(); + } + }); + + it("a chip's count is compact too, the same as its cell's", () => { + render( {}} />); + expect(screen.getByRole("button").textContent).toBe("202517.2K"); + }); + it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => { render( {}} />); expect( diff --git a/frontend/src/components/ui/YearPicker.tsx b/frontend/src/components/ui/YearPicker.tsx index c686ad4f..ae9e4184 100644 --- a/frontend/src/components/ui/YearPicker.tsx +++ b/frontend/src/components/ui/YearPicker.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Chip } from "./Chip"; import CountBadge from "./CountBadge"; @@ -52,10 +52,22 @@ export default function YearPicker({ // only if it is one of them. const more = years.some((y) => !quick.some((q) => q.year === y.year)); + const chipsRef = useRef(null); const toggle = (year: number) => onPick(selected === year ? undefined : year); const pickAndClose = (year: number) => { toggle(year); setOpen(false); + // A pick that leaves the dialog nothing to show takes More… away with it, and the dialog then has + // nowhere to give the focus back: it fell to , which also folded an unpinned rail. So it + // goes to the picked year's chip (the first chip if the pick cleared it). A timer, not a frame: a + // page the browser is not painting runs no requestAnimationFrame. + window.setTimeout(() => { + const now = document.activeElement; + const row = chipsRef.current; + if ((now && now !== document.body && now.isConnected) || !row) return; + const chip = row.querySelector(`[data-year="${year}"]`); + (chip ?? row.querySelector("button"))?.focus({ preventScroll: true }); + }, 0); }; const cell = (y: YearCount, index: number) => { @@ -79,7 +91,7 @@ export default function YearPicker({ }; return ( -
+
{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 @@ -89,6 +101,7 @@ export default function YearPicker({ { if (leaves && e.currentTarget === document.activeElement) handOffFocus(e.currentTarget, () => toggle(y.year)); diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index bc90f2fd..a8eafbe7 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { qk } from "../../lib/queryKeys"; import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles"; import { useTranslation } from "react-i18next"; @@ -85,6 +85,27 @@ export default function PlexSidebar() { // just lists them as removable chips. Studios now have their own picker section (below). const peopleActive = filters.directors.length + filters.actors.length > 0; + // A section the facets fill, shown when its facet has values. While the facets are not here, it + // says why it is empty: a first load or a failed one must not read as a library with no genres + // (honest states). The Year section announces it; these only show it, so it is read once. + const facetsState: FacetsState = facets + ? "ready" + : facetsQ.isError + ? "error" + : facetsQ.isLoading + ? "loading" + : "ready"; + const facetSection = ( + id: string, + title: string, + has: (f: PlexFacets) => boolean, + body: (f: PlexFacets) => ReactNode, + ): PanelItem[] => { + if (facets) return has(facets) ? [{ id, title, body: body(facets) }] : []; + if (facetsState === "ready") return []; + return [{ id, title, body: }]; + }; + // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved // layout and lets the user reorder / hide / collapse them. @@ -154,33 +175,32 @@ export default function PlexSidebar() { /> ), }, - ...(facets && facets.genres.length > 0 - ? [ - { - id: "genre", - title: t("plex.filter.genre"), - body: ( - <> - patch({ genreMode: m })} - /> - patch({ genres: next })} - /> - - ), - }, - ] - : []), + ...facetSection( + "genre", + t("plex.filter.genre"), + (f) => f.genres.length > 0, + (f) => ( + <> + patch({ genreMode: m })} + /> + patch({ genres: next })} + /> + + ), + ), { id: "year", title: t("plex.filter.year"), - body: , + body: ( + + ), }, ...(scope === "movie" ? [ @@ -227,62 +247,55 @@ export default function PlexSidebar() { /> ), }, - ...(facets && facets.content_ratings.length > 0 - ? [ - { - id: "contentrating", - title: t("plex.filter.contentRating"), - body: ( - ({ - ...o, - label: vocab(t, "certificate", o.value), - }))} - selected={filters.contentRatings} - onChange={(next) => patch({ contentRatings: next })} - /> - ), - }, - ] - : []), - ...(facets && facets.studios.length > 0 - ? [ - { - id: "studio", - title: t("plex.filter.studio"), - body: ( - patch({ studios: next })} - /> - ), - }, - ] - : []), + ...facetSection( + "contentrating", + t("plex.filter.contentRating"), + (f) => f.content_ratings.length > 0, + (f) => ( + ({ + ...o, + label: vocab(t, "certificate", o.value), + }))} + selected={filters.contentRatings} + onChange={(next) => patch({ contentRatings: next })} + /> + ), + ), + ...facetSection( + "studio", + t("plex.filter.studio"), + (f) => f.studios.length > 0, + (f) => ( + patch({ studios: next })} + /> + ), + ), // Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no // class), and dropped when the facet is empty. Labels are localized (the facet ships class keys). - ...(scope === "movie" && facets && facets.playable.length > 0 - ? [ - { - id: "playable", - title: t("plex.filter.playable"), - body: ( - ({ - value: p.value, - label: t(`plex.filter.playableOpt.${p.value}`), - count: p.count, - }))} - selected={filters.playable} - onChange={(next) => patch({ playable: next })} - /> - ), - }, - ] + ...(scope === "movie" + ? facetSection( + "playable", + t("plex.filter.playable"), + (f) => f.playable.length > 0, + (f) => ( + ({ + value: p.value, + label: t(`plex.filter.playableOpt.${p.value}`), + count: p.count, + }))} + selected={filters.playable} + onChange={(next) => patch({ playable: next })} + /> + ), + ) : []), ]; @@ -483,15 +496,18 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi export function PlexYearFilter({ filters, facets, + facetsState = "ready", patch, }: { filters: PlexFilters; facets: PlexFacets | undefined; + facetsState?: FacetsState; patch: (p: Partial) => void; }) { const { t } = useTranslation(); return (
+ {facetsState !== "ready" && } {facets && facets.years.length > 0 && ( ; + announce?: boolean; +}) { + const { t } = useTranslation(); + const role = announce ? (state === "error" ? "alert" : "status") : undefined; + return ( +

+ {t(state === "error" ? "common.loadError" : "common.loading")} +

+ ); +} + function NumberInput({ label, value, diff --git a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx index 25a9a54e..056d4de8 100644 --- a/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx +++ b/frontend/src/modules/plex/PlexSidebar.year.dom.test.tsx @@ -64,6 +64,27 @@ describe("the Plex rail's Year section", () => { expect(screen.queryByRole("button", { pressed: true })).toBeNull(); }); + it("says the years are loading, or failed to load, rather than showing none", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.getByRole("status").textContent).toBe("common.loading"); + rerender( + {}} + />, + ); + expect(screen.getByRole("alert").textContent).toBe("common.loadError"); + }); + it("names the range fields", () => { render( {}} />); expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy(); diff --git a/frontend/src/modules/settings/MyLogViewer.dom.test.tsx b/frontend/src/modules/settings/MyLogViewer.dom.test.tsx index d9772099..24fc67c1 100644 --- a/frontend/src/modules/settings/MyLogViewer.dom.test.tsx +++ b/frontend/src/modules/settings/MyLogViewer.dom.test.tsx @@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (key: string, opts?: { count?: number }) => opts?.count != null ? `${key}:${opts.count}` : key, + i18n: { language: "en" }, }), }; });