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:
@@ -829,3 +829,5 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
|
||||
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
|
||||
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
|
||||
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MenuCheck } from "./MenuCheck";
|
||||
import { SelectTrigger } from "./SelectTrigger";
|
||||
import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { SURFACE } from "./surfaces";
|
||||
@@ -27,6 +27,10 @@ export interface FilterSelectProps {
|
||||
/** Show only the value on the trigger — for a form row whose own caption already names the field.
|
||||
* The label still names the control and its menu for assistive tech. */
|
||||
hideLabel?: boolean;
|
||||
/** A glyph that names the group in place of its word, for a toolbar where the value says enough
|
||||
* once the group is recognisable (the feed's watch state: an eye). The word moves to the tooltip
|
||||
* and stays in the accessible name. */
|
||||
icon?: ReactNode;
|
||||
options: readonly FilterSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
@@ -46,6 +50,7 @@ export default function FilterSelect({
|
||||
multiple = false,
|
||||
requireOne = true,
|
||||
hideLabel = false,
|
||||
icon,
|
||||
testId,
|
||||
}: FilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -95,7 +100,9 @@ export default function FilterSelect({
|
||||
<SelectTrigger
|
||||
context="toolbar"
|
||||
open={open}
|
||||
label={hideLabel ? undefined : label}
|
||||
label={hideLabel || icon ? undefined : label}
|
||||
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
|
||||
title={icon ? label : undefined}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -12,6 +12,16 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
|
||||
// the accent edge and the chevron turned over. The popover wiring
|
||||
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
|
||||
/** A toolbar trigger's group glyph in place of its "Label:" word (FilterSelect's and SortControl's
|
||||
* `icon`): muted like the word, and silent — the trigger's accessible name and tooltip say it. */
|
||||
export function GroupGlyph({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="flex shrink-0 text-muted [&>svg]:size-3.5">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const SelectTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { MenuCheck } from "./MenuCheck";
|
||||
import { SelectTrigger } from "./SelectTrigger";
|
||||
import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
|
||||
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
@@ -27,6 +27,7 @@ export default function SortControl({
|
||||
descLabel,
|
||||
defaultLabel,
|
||||
trailing,
|
||||
icon,
|
||||
}: {
|
||||
options: SortOption[];
|
||||
sort: SortState;
|
||||
@@ -40,6 +41,9 @@ export default function SortControl({
|
||||
defaultLabel?: string;
|
||||
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
|
||||
trailing?: ReactNode;
|
||||
/** A glyph in place of the visible "Sort:" prefix (FilterSelect's `icon`): the label moves to the
|
||||
* tooltip and stays in the accessible name. */
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
@@ -71,7 +75,9 @@ export default function SortControl({
|
||||
<SelectTrigger
|
||||
context="toolbar"
|
||||
open={open}
|
||||
label={label}
|
||||
label={icon ? undefined : label}
|
||||
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
|
||||
title={icon ? label : undefined}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowLeft,
|
||||
Columns2,
|
||||
Eye,
|
||||
Grid3x3,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Shapes,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
@@ -749,9 +752,8 @@ export default function Feed({
|
||||
(k) => t("feed.sortKey." + k),
|
||||
);
|
||||
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
// Sort + view mode end the toolbar's row, on the feed and on a channel page alike: a row of their
|
||||
// own under the filters left most of a wide window empty.
|
||||
// One setter per toolbar control, shared with the page's background menu (the same choices, the
|
||||
// same effect, two doors).
|
||||
const setSort = (next: SortState) => {
|
||||
@@ -792,6 +794,7 @@ export default function Feed({
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={setSort}
|
||||
label={t("feed.sortLabel")}
|
||||
icon={<ArrowDownUp />}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
@@ -814,16 +817,21 @@ export default function Feed({
|
||||
</>
|
||||
);
|
||||
|
||||
// One row: the count, the filters, then Sort + View at its end — they wrap to a line of their own
|
||||
// only when the window cannot hold them beside the filters.
|
||||
const toolbar = (
|
||||
<div className="pb-1.5">
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount
|
||||
count={summaryQuery.data?.count}
|
||||
stale={summaryQuery.isPlaceholderData}
|
||||
failed={summaryQuery.isError && !summaryQuery.data}
|
||||
/>
|
||||
never shoves the controls beside it (see ItemCount). A channel page has none: its header
|
||||
already says how many videos the channel has. */}
|
||||
{!channelScoped && (
|
||||
<ItemCount
|
||||
count={summaryQuery.data?.count}
|
||||
stale={summaryQuery.isPlaceholderData}
|
||||
failed={summaryQuery.isError && !summaryQuery.data}
|
||||
/>
|
||||
)}
|
||||
<FilterFoldButton
|
||||
fold={fold}
|
||||
label={t("sidebar.filters")}
|
||||
@@ -832,9 +840,12 @@ export default function Feed({
|
||||
{!fold.folded && (
|
||||
<>
|
||||
{/* Watch state is single-select (always exactly one); content types are an
|
||||
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
independent multi-select. Both compress to one dropdown pill via FilterSelect, named
|
||||
by a glyph: the value says enough once the group is recognisable, and the word is
|
||||
in the tooltip and the accessible name. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
icon={<Eye />}
|
||||
testId="feed-show"
|
||||
options={showOptions}
|
||||
selected={[filters.show]}
|
||||
@@ -842,6 +853,7 @@ export default function Feed({
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
icon={<Shapes />}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={contentOptions}
|
||||
@@ -863,10 +875,7 @@ export default function Feed({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Folded, the band is one row: Sort + View 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>
|
||||
</div>
|
||||
);
|
||||
@@ -944,26 +953,19 @@ export default function Feed({
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
||||
{toolbar}
|
||||
{/* 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 + View stay
|
||||
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
|
||||
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
|
||||
{!fold.folded && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
{/* 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 padding away with them.
|
||||
Channel-scoped feeds show no chips (their baseline isn't a "filter you applied"). */}
|
||||
{!fold.folded && !channelScoped && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2 empty:hidden">
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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