fix(filters): a pick that hides More… keeps the focus, counts read alike, Plex facets say they failed

Picking in the year dialog the one year the chips hid took More… away with the dialog, and the
focus fell to <body>: 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.
This commit is contained in:
2026-11-14 19:32:01 +01:00
parent 36f9aefb76
commit be6bd586d7
6 changed files with 198 additions and 83 deletions
+115 -79
View File
@@ -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: <FacetsStatus state={facetsState} /> }];
};
// 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: (
<>
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={facets.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
},
]
: []),
...facetSection(
"genre",
t("plex.filter.genre"),
(f) => f.genres.length > 0,
(f) => (
<>
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={f.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
),
{
id: "year",
title: t("plex.filter.year"),
body: <PlexYearFilter filters={filters} facets={facets} patch={patch} />,
body: (
<PlexYearFilter filters={filters} facets={facets} facetsState={facetsState} patch={patch} />
),
},
...(scope === "movie"
? [
@@ -227,62 +247,55 @@ export default function PlexSidebar() {
/>
),
},
...(facets && facets.content_ratings.length > 0
? [
{
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<Combobox
label={t("plex.filter.contentRating")}
options={facets.content_ratings.map((o) => ({
...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: (
<Combobox
label={t("plex.filter.studio")}
options={facets.studios}
selected={filters.studios}
onChange={(next) => patch({ studios: next })}
/>
),
},
]
: []),
...facetSection(
"contentrating",
t("plex.filter.contentRating"),
(f) => f.content_ratings.length > 0,
(f) => (
<Combobox
label={t("plex.filter.contentRating")}
options={f.content_ratings.map((o) => ({
...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) => (
<Combobox
label={t("plex.filter.studio")}
options={f.studios}
selected={filters.studios}
onChange={(next) => 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: (
<Combobox
label={t("plex.filter.playable")}
options={facets.playable.map((p) => ({
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) => (
<Combobox
label={t("plex.filter.playable")}
options={f.playable.map((p) => ({
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<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{facetsState !== "ready" && <FacetsStatus state={facetsState} announce />}
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
@@ -518,6 +534,26 @@ export function PlexYearFilter({
);
}
type FacetsState = "ready" | "loading" | "error";
/** Why a facet-filled section is empty while the facets are not here. `announce` makes it a live
* region: one section announces, the rest only show it. */
function FacetsStatus({
state,
announce = false,
}: {
state: Exclude<FacetsState, "ready">;
announce?: boolean;
}) {
const { t } = useTranslation();
const role = announce ? (state === "error" ? "alert" : "status") : undefined;
return (
<p className="text-muted px-1 py-1.5 text-xs" role={role}>
{t(state === "error" ? "common.loadError" : "common.loading")}
</p>
);
}
function NumberInput({
label,
value,