feat(feed): toolbar pills named by a glyph, Sort and View on the filters' row

The watch-state, content-type and sort pills on the feed, a channel page and
Plex Browse show a glyph where their group's word was; the word moves to the
tooltip and stays in the accessible name. Sort + View end the toolbar's row
instead of a row of their own, the chip row shows only with a chip, and a
channel page drops the count pill its header already carries.

Also logs the docked bar's glyph-only decision in DESIGN.md.
This commit is contained in:
2026-11-01 04:18:01 +01:00
parent e9ce989998
commit a29afd916d
8 changed files with 282 additions and 54 deletions
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
//
// The Browse grid's toolbar, the feed's in Plex: the watch-state pill and Sort name their group by a
// glyph (the word in the tooltip and the accessible name), and Sort ends the filters' row instead of
// taking a row of its own.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
import PlexBrowse from "./PlexBrowse";
import { api } from "../../lib/api";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" }, _subOf: "v" }, ""));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const slot = document.body.appendChild(document.createElement("div"));
async function mount() {
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<PlexBrowse />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
return screen.findByTestId("plex-show");
}
const sortTrigger = () =>
screen
.getAllByRole("button")
.find((b) => b.getAttribute("aria-label")?.startsWith("plex.filter.sort:"))!;
describe("PlexBrowse toolbar", () => {
it("the watch-state pill shows a glyph, not its group's word, and names itself", async () => {
const pill = await mount();
// The key of a value ("plex.filter.showOpt.all") holds the group's key: test the visible text.
expect(pill.textContent).toBe("plex.filter.showOpt.all");
expect(pill.querySelector("svg")).not.toBeNull();
expect(pill.getAttribute("title")).toBe("plex.filter.show");
expect(pill.getAttribute("aria-label")).toMatch(/^plex\.filter\.show: /);
});
it("Sort shows a glyph, names itself, and ends the filters' row", async () => {
const pill = await mount();
const sort = sortTrigger();
expect(sort.textContent).not.toContain("plex.filter.sort");
expect(sort.getAttribute("title")).toBe("plex.filter.sort");
expect(pill.parentElement!.contains(sort)).toBe(true);
});
});