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
+2
View File
@@ -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 |
+10 -3
View File
@@ -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> & {
+8 -2
View File
@@ -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();
});
});
+36 -34
View File
@@ -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);
});
});
+12 -15
View File
@@ -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>