fix(plex): address S3 review nits — tests + narrow-header wrap

- Add PlexScopeToggle.dom.test.tsx (renders the three scopes, current one
  pressed, click writes scope back to the provider) and
  usePlexActiveFilters.dom.test.tsx (emits filter + watch-state chips but never
  a sort chip, even with a non-default sort active) — the S3 relocation was
  untested.
- Narrow-width header: make the header row flex-wrap so the scope toggle drops
  to a second line instead of crowding the flex-1 search box to zero width, and
  let the field-scope pill shrink/truncate. Desktop stays a single line
  (verified: 1280px header unchanged, search 237px, no wrap).

The removed one-click sort-reset chip is intended (sort is an always-visible
control now, like the Feed) — no change. FE suite 174 green; typecheck + eslint
clean.
This commit is contained in:
2026-08-16 19:02:02 +02:00
parent a702e72973
commit 1d6c5f051e
4 changed files with 96 additions and 3 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2">
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
//
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
// floating-ui), so no observer stubs are needed.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } 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 }) };
});
const plexState = { scope: "both" } as { scope: string };
const setScope = vi.fn();
vi.mock("./PlexProvider", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setScope }),
}));
import PlexScopeToggle from "./PlexScopeToggle";
beforeEach(() => {
plexState.scope = "both";
setScope.mockReset();
});
afterEach(cleanup);
describe("PlexScopeToggle", () => {
it("renders the three library scopes, current one pressed", () => {
plexState.scope = "movie";
render(<PlexScopeToggle />);
const group = screen.getByRole("group", { name: "plex.filter.scope" });
const btns = [...group.querySelectorAll("button")];
expect(btns.map((b) => b.textContent)).toEqual([
"plex.filter.scopeOpt.both",
"plex.filter.scopeOpt.movie",
"plex.filter.scopeOpt.show",
]);
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
});
it("writes the picked scope back to the provider", () => {
render(<PlexScopeToggle />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
expect(setScope).toHaveBeenCalledWith("show");
});
});
+2 -2
View File
@@ -117,10 +117,10 @@ export default function PlexSearchBox() {
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
trigger={
<>
<span className="max-w-28 truncate">{pillLabel}</span>
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
</>
}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
//
// Guards the S3 behavior change: the active-filter chip row (usePlexActiveFilters) still emits chips
// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip —
// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and
// the provider is mocked so the hook can be driven with a non-default sort present.
import { describe, it, expect, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { EMPTY_PLEX_FILTERS } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const plexState = {
scope: "both",
show: "watched",
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
sort: { key: "year", dir: "asc" },
filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] },
};
const setShow = vi.fn();
const setFilters = vi.fn();
vi.mock("./PlexProvider", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setShow, setFilters }),
}));
import { usePlexActiveFilters } from "./usePlexActiveFilters";
describe("usePlexActiveFilters", () => {
it("emits filter + watch-state chips but never a sort chip", () => {
const { result } = renderHook(() => usePlexActiveFilters());
const keys = result.current.map((c) => c.key);
// The in-panel narrowings still surface...
expect(keys).toContain("genre:Action");
expect(keys).toContain("show");
// ...but sort does not, even though a non-default sort is active in the shared state.
expect(keys).not.toContain("sort");
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
});
});