From add7f7d167eb9a16b64cddd28d100c9914b1e687 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 04:22:20 +0100 Subject: [PATCH] feat(channel): on a tablet the header's words give their room to the tags Below xl the action row drops "Prio" (the stepper's tooltip names it) and the Subscribed button's word (a check glyph; its name stays in the aria-label). On an iPad at 820 px with touch-sized targets the tag lane grows from 29 px, a sliver of its control, to 130 px. --- docs/DESIGN.md | 1 + .../feed/ChannelPage.actions.dom.test.tsx | 97 +++++++++++++++++++ frontend/src/modules/feed/ChannelPage.tsx | 12 ++- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5519d723..141f93eb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -831,3 +831,4 @@ of a full-width panel and nothing on the right. | 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 | +| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 | diff --git a/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx new file mode 100644 index 00000000..5a41055b --- /dev/null +++ b/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +// +// The channel header's action row on a tablet. The tags beside Subscribed got the row's leftover width, +// and on an iPad (820 px, 44 px touch targets) that was 29 px: a sliver of the tag control. Below the +// widest windows the row's two words — "Prio" before the stepper and "Subscribed" on its button — give +// their room to the tags: the stepper names itself by its tooltip, and the button keeps its name for a +// screen reader and in its tooltip. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, waitFor } 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/navigation/navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), + useFeedFiltersActions: () => ({ setFilters: () => {} }), +})); +vi.mock("./Feed", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { api, type ChannelDetail, type Me } from "../../lib/api"; + +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(); +}); + +async function renderPage() { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: "UCx", + title: "StarTalk", + subscribed: true, + blocked: false, + hidden: false, + priority: 0, + deep_requested: false, + backfill_done: true, + stored_videos: 10, + tag_ids: [], + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + render( + + + , + ); + return waitFor(() => screen.getByRole("button", { name: "channel.subscribedState" })); +} + +// The word is laid out only from `xl` up: below it the element is `display: none`. +const onlyWide = (el: Element) => + el.classList.contains("hidden") && el.classList.contains("xl:inline"); + +describe("ChannelPage — the action row leaves the tags room on a tablet", () => { + it("shows Subscribed's word only on a wide window, and keeps the button's name", async () => { + const button = await renderPage(); + const word = [...button.querySelectorAll("span")].find( + (s) => s.textContent === "channel.subscribedState", + ); + expect(word).toBeTruthy(); + expect(onlyWide(word!)).toBe(true); + expect(button.getAttribute("title")).toBe("channel.unsubscribe"); + }); + + it("shows the Prio word only on a wide window; the stepper keeps its own name", async () => { + await renderPage(); + const word = screen.getByText("channels.cols.priority"); + expect(onlyWide(word)).toBe(true); + expect(screen.getByRole("group", { name: "channels.row.priorityHint" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index a68f8713..57e4895d 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -474,7 +474,10 @@ export default function ChannelPage({ /> {/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back and the name take that room, so at a tablet width the actions wrap onto a line of their - own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */} + own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). + Below `xl` the row's two words ("Prio", "Subscribed") give their room to the tags: on an + iPad (820 px, 44 px touch targets) the tag lane was left 29 px, a sliver of its control. + The stepper names itself by its tooltip, the button by its aria-label and tooltip. */}
- {t("channels.cols.priority")} + + {t("channels.cols.priority")} + bumpPriority(channelId, d)} @@ -571,9 +576,10 @@ export default function ChannelPage({ onClick={onUnsubscribe} loading={unsubscribe.isPending} title={t("channel.unsubscribe")} + aria-label={t("channel.subscribedState")} icon={} > - {t("channel.subscribedState")} + {t("channel.subscribedState")} ) : (