diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 01c946fa..5519d723 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx index d94b4997..822d4e52 100644 --- a/frontend/src/components/ui/FilterSelect.tsx +++ b/frontend/src/components/ui/FilterSelect.tsx @@ -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({ {icon}} + title={icon ? label : undefined} ref={setReference} {...pop.getReferenceProps()} data-testid={testId} diff --git a/frontend/src/components/ui/SelectTrigger.tsx b/frontend/src/components/ui/SelectTrigger.tsx index bef9e098..1e8b769c 100644 --- a/frontend/src/components/ui/SelectTrigger.tsx +++ b/frontend/src/components/ui/SelectTrigger.tsx @@ -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 ( + + ); +} + export const SelectTrigger = forwardRef< HTMLButtonElement, ButtonHTMLAttributes & { diff --git a/frontend/src/components/ui/SortControl.tsx b/frontend/src/components/ui/SortControl.tsx index 7a842533..c3ba37d2 100644 --- a/frontend/src/components/ui/SortControl.tsx +++ b/frontend/src/components/ui/SortControl.tsx @@ -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({ {icon}} + title={icon ? label : undefined} ref={setReference} {...pop.getReferenceProps()} // Carry the active sort key AND direction in the accessible name (the visible arrow is diff --git a/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx b/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx new file mode 100644 index 00000000..2841ada9 --- /dev/null +++ b/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx @@ -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()), + 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( + + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + channelScoped={channelScoped} + /> + + , + ); + // 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(); + }); +}); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 82a282c9..1072a9b2 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -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={} 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 = (
{/* The item count is the fixed leading element — a fixed-width pill so a changing number - never shoves the controls beside it (see ItemCount). */} - + never shoves the controls beside it (see ItemCount). A channel page has none: its header + already says how many videos the channel has. */} + {!channelScoped && ( + + )} {/* 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. */} } testId="feed-show" options={showOptions} selected={[filters.show]} @@ -842,6 +853,7 @@ export default function Feed({ /> } 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 && ( -
{sortView}
- )} +
{sortView}
); @@ -944,26 +953,19 @@ export default function Feed({
{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 && ( -
- {!channelScoped && ( - <> - {/* The active personal view (if any) leads the applied-filter row. */} - - setFilters(clearedFilters(filters))} - className="flex-1" - /> - - )} -
{sortView}
+ {/* 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 && ( +
+ {/* The active personal view (if any) leads the applied-filter row. */} + + setFilters(clearedFilters(filters))} + className="flex-1" + />
)}
diff --git a/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx new file mode 100644 index 00000000..e5ea6b16 --- /dev/null +++ b/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx @@ -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()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + 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( + + + + + , + ); + 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); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index dd820682..86f31603 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -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 ); } - // 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 = ( setSort(next ?? PLEX_SORT_FALLBACK)} label={t("plex.filter.sort")} + icon={} ascLabel={t("plex.filter.dir.asc")} descLabel={t("plex.filter.dir.desc")} /> @@ -634,30 +634,28 @@ function PlexBrowseView({ sub }: { sub: ReturnType while only the poster grid scrolls. */}
-
+
{/* 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 && ( } 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 && ( -
{sortView}
- )} +
{sortView}
- {/* 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 && ( -
+
{/* The active personal view (if any) leads the applied-filter row, like the feed. */} onClearAll={clearAllFilters} className="flex-1" /> -
{sortView}
)}