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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { User, X } from "lucide-react";
|
||||
import { ArrowDownUp, Eye, User, X } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../../lib/nextPage";
|
||||
import { healPlexField } from "../../lib/plexSearch";
|
||||
@@ -556,15 +556,15 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the
|
||||
// applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as
|
||||
// the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill).
|
||||
// Sort ends the toolbar's row, the same idiom as the Feed's `sortView`. The Discover/Browse toggle
|
||||
// moved up to the header (next to the scope pill).
|
||||
const sortView = (
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||
label={t("plex.filter.sort")}
|
||||
icon={<ArrowDownUp />}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
@@ -634,30 +634,28 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-1 max-w-400 mx-auto">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 gap-y-2">
|
||||
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={plexChips.length} />
|
||||
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
||||
the same idiom as the feed's Watch-state FilterSelect. */}
|
||||
the same idiom as the feed's Watch-state FilterSelect, named by its glyph. */}
|
||||
{!fold.folded && (
|
||||
<FilterSelect
|
||||
label={t("plex.filter.show")}
|
||||
icon={<Eye />}
|
||||
testId="plex-show"
|
||||
options={showOptions}
|
||||
selected={[show]}
|
||||
onChange={(next) => setShow(next[0] ?? "all")}
|
||||
/>
|
||||
)}
|
||||
{/* Folded, the band is one row: Sort + the view toggle join it on the right. */}
|
||||
{fold.folded && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the
|
||||
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
||||
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
||||
{/* The applied-filter chips, directly above the grid (overflowing to a "+N"/count pill so
|
||||
they never crowd a line) — a row only while there is a chip: both render nothing when
|
||||
nothing is applied, and `empty:hidden` takes the empty row's margin away with them. */}
|
||||
{!fold.folded && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 empty:hidden">
|
||||
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
@@ -665,7 +663,6 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
onClearAll={clearAllFilters}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user