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,126 @@
// @vitest-environment jsdom
//
// The feed's toolbar, on the feed and on a channel page. The filter pills name their group by a glyph
// (the word moved to the tooltip and the accessible name), Sort + View end the first row instead of
// taking a row of their own, and a channel page drops the count pill: its header already says how
// many videos the channel has.
import { describe, it, expect, vi, beforeAll, 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("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("./VirtualFeed", () => ({ default: () => null }));
vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null }));
vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
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;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const slot = document.body.appendChild(document.createElement("div"));
async function renderFeed(channelScoped = false) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 12, facets: {} } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped={channelScoped}
/>
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
// The toolbar renders once the first page has answered.
await screen.findByTestId("feed-show");
}
const trigger = (name: string) =>
screen.getAllByRole("button").find((b) => b.getAttribute("aria-label")?.startsWith(`${name}:`))!;
const sortTrigger = () => trigger("feed.sortLabel");
describe("Feed toolbar", () => {
it.each([
["feed-show", "feed.showLabel"],
["feed-content", "feed.contentLabel"],
])("the %s pill shows a glyph, not its group's word, and names itself", async (id, word) => {
await renderFeed();
const pill = screen.getByTestId(id);
expect(pill.textContent).not.toContain(word);
expect(pill.querySelector("svg")).not.toBeNull();
expect(pill.getAttribute("title")).toBe(word);
expect(pill.getAttribute("aria-label")).toMatch(new RegExp(`^${word}: `));
});
it("the sort pill shows a glyph, not the word Sort, and names itself", async () => {
await renderFeed();
const sort = sortTrigger();
expect(sort.textContent).not.toContain("feed.sortLabel");
expect(sort.getAttribute("title")).toBe("feed.sortLabel");
});
it.each([false, true])(
"Sort and View end the filters' row instead of a row of their own (channel page: %s)",
async (channelScoped) => {
await renderFeed(channelScoped);
const row = screen.getByTestId("feed-show").parentElement!;
expect(row.contains(sortTrigger())).toBe(true);
expect(row.contains(trigger("feed.viewLabel"))).toBe(true);
},
);
it("a channel page shows no count pill; the feed keeps it", async () => {
await renderFeed(true);
expect(screen.queryByTestId("item-count")).toBeNull();
cleanup();
await renderFeed(false);
expect(screen.getByTestId("item-count")).toBeTruthy();
});
});