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.
This commit is contained in:
2026-11-01 04:22:20 +01:00
parent a29afd916d
commit add7f7d167
3 changed files with 107 additions and 3 deletions
+1
View File
@@ -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 |
@@ -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<typeof import("react-i18next")>()),
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<typeof import("../../components/ui/PageShell")>()),
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(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage channelId="UCx" initialName="StarTalk" me={{ id: 1, can_write: true } as Me} />
</QueryClientProvider>,
);
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();
});
});
+9 -3
View File
@@ -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. */}
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
<IconLink
href={ytUrl}
@@ -498,7 +501,9 @@ export default function ChannelPage({
)}
{ch?.subscribed && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<span className="hidden text-xs text-muted xl:inline">
{t("channels.cols.priority")}
</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => 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={<Check className="w-4 h-4" />}
>
{t("channel.subscribedState")}
<span className="hidden xl:inline">{t("channel.subscribedState")}</span>
</Button>
) : (
<Button