From be6bd586d72b5a7f8b1a4e1c57fbe910111ce428 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 19:32:01 +0100 Subject: [PATCH] =?UTF-8?q?fix(filters):=20a=20pick=20that=20hides=20More?= =?UTF-8?q?=E2=80=A6=20keeps=20the=20focus,=20counts=20read=20alike,=20Ple?= =?UTF-8?q?x=20facets=20say=20they=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking in the year dialog the one year the chips hid took More… away with the dialog, and the focus fell to : it now goes to the picked year's chip. A count badge is compact in the UI language everywhere (a chip read "17200" beside its cell's "17.2K"). While the Plex facets load or fail, the sections they fill say so instead of vanishing as if the library had none. --- frontend/src/components/ui/CountBadge.tsx | 9 +- .../src/components/ui/YearPicker.dom.test.tsx | 39 ++++ frontend/src/components/ui/YearPicker.tsx | 17 +- frontend/src/modules/plex/PlexSidebar.tsx | 194 +++++++++++------- .../plex/PlexSidebar.year.dom.test.tsx | 21 ++ .../modules/settings/MyLogViewer.dom.test.tsx | 1 + 6 files changed, 198 insertions(+), 83 deletions(-) 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" }, }), }; });