Merge: toolbar/header UI-consistency refactor + a11y fixes
gate / frontend (push) Failing after 1m13s
gate / backend (push) Successful in 1m52s

Collects the toolbar/header/layout redesign and assorted bugfixes on the
bug/channel-back-header-overlap branch: new FilterSelect / ItemCount /
pill-SortControl primitives, removed ModuleName, the --sbw / --content-mw /
--rail-pinned-w chrome-alignment machinery, the centred search box, and the
two deep-review rounds (menu-role a11y, sort aria-pressed, rem rail threshold,
accessible trigger names, e2e helper fix, added coverage).

Not a release: VERSION stays 0.71.0 and this is not deployed to prod.
This commit is contained in:
2026-08-22 02:07:20 +02:00
33 changed files with 888 additions and 512 deletions
+11 -27
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { clickVisibleChannelLink, openApp } from "./helpers";
import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
@@ -68,29 +68,22 @@ test.describe("channel view", () => {
}) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const feedLive = page.getByTestId("feed-content-includeLive");
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
await expectContentType(page, "includeLive", false);
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
await feedLive.click();
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
await toggleContentType(page, "includeLive");
await expectContentType(page, "includeLive", true);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
await expectContentType(page, "includeLive", true);
});
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
@@ -99,27 +92,18 @@ test.describe("channel view", () => {
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
// Turn Live/Upcoming ON inside the channel view.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("feed-content-includeLive").click();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
await toggleContentType(page, "includeLive");
await expectContentType(page, "includeLive", true);
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
@@ -127,7 +111,7 @@ test.describe("channel view", () => {
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
+10 -6
View File
@@ -8,15 +8,18 @@ test.describe("feed", () => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
// Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state
// pill summarises the current pick).
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
// Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick).
// The seed guarantees exactly one, a real deterministic change.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-watched").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
await expect(count).not.toHaveText(baseline);
@@ -24,7 +27,7 @@ test.describe("feed", () => {
// Remove the chip → default show restored, chips gone.
await page.getByTestId("active-filter-chip").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
});
test("scrolling virtualizes in fresh cards", async ({ page }) => {
@@ -52,7 +55,7 @@ test.describe("feed", () => {
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
@@ -73,6 +76,7 @@ test.describe("feed", () => {
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
// Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-all").click();
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
});
+24
View File
@@ -33,6 +33,30 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
return false;
}
/**
* Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown.
* The options only exist while the dropdown is open, so this opens it, checks the option's
* `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it
* (Escape) so the floating panel can't block a later click.
*/
export async function expectContentType(page: Page, value: string, on: boolean): Promise<void> {
await page.getByTestId("feed-content").click();
await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute(
"aria-checked",
on ? "true" : "false",
);
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */
export async function toggleContentType(page: Page, value: string): Promise<void> {
await page.getByTestId("feed-content").click();
await page.getByTestId(`feed-content-${value}`).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible();
+15 -1
View File
@@ -136,6 +136,19 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
useEffect(() => {
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
document.body.appendChild(probe);
const w = probe.offsetWidth - probe.clientWidth;
document.body.removeChild(probe);
document.documentElement.style.setProperty("--sbw", `${w}px`);
}, []);
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
@@ -300,7 +313,7 @@ export default function App() {
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
@@ -366,6 +379,7 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
maxWidth={maxWidth}
// Channels: the registry disables the top fade for its sticky-header table, but the card
// layout has no sticky header, so re-enable it there.
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
@@ -21,7 +21,7 @@ export default function ActiveFilterChips({
className="flex flex-wrap items-center gap-1.5 pb-3"
aria-label={t("feed.chips.label")}
>
<span className="text-2xs uppercase tracking-wider text-muted mr-0.5">
<span className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
@@ -32,7 +32,7 @@ export default function ActiveFilterChips({
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
className="chip group pl-2.5 pr-1.5 py-1 text-sm hover:border-accent hover:bg-accent/20 transition"
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
@@ -42,7 +42,7 @@ export default function ActiveFilterChips({
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
className="ml-0.5 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
+1 -1
View File
@@ -34,7 +34,7 @@ export default function AdminUsers() {
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
+1 -1
View File
@@ -200,7 +200,7 @@ export default function ChannelDiscovery({
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
+16 -76
View File
@@ -1,11 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import { inputCls } from "./ui/form";
import Combobox, { type ComboboxOption } from "./Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
@@ -22,86 +19,29 @@ export default function ChannelPicker({
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade();
const channelsQuery = useQuery({
queryKey: qk.channels(),
queryFn: api.channels,
staleTime: 5 * 60_000,
});
const q = query.trim().toLowerCase();
const all = channelsQuery.data ?? [];
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
const picked = filters.channelIds;
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
const toggle = (id: string) =>
setFilters({
...filters,
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
});
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
// narrowing ever drops it from the visible rows.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
return (
<>
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
{picked.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-2">
{picked.map((id) => (
<button
key={id}
onClick={() => toggle(id)}
title={t("sidebar.channelRemove", { name: nameOf(id) })}
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
>
<span className="truncate">{nameOf(id)}</span>
<X className="w-3 h-3 shrink-0" />
</button>
))}
</div>
)}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sidebar.channelSearch")}
aria-label={t("sidebar.channelSearch")}
className={inputCls}
/>
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
but does drop the rows that explain it. */}
{!q && all.length > 0 && (
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
)}
<div
ref={fade.ref}
style={fade.style}
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
>
{matches.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted">
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
</div>
) : (
matches.map((c) => {
const on = picked.includes(c.id);
return (
<button
key={c.id}
onClick={() => toggle(c.id)}
aria-pressed={on}
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
// them they squash to ~12px each and clip their own text instead of the box scrolling.
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
}`}
>
{c.title ?? c.id}
</button>
);
})
)}
</div>
</>
<Combobox
label={t("sidebar.channelSearch")}
placeholder={t("sidebar.channelSearch")}
options={options}
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
/>
);
}
+1 -1
View File
@@ -154,7 +154,7 @@ export default function Combobox({
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
<span className="max-w-36 truncate">{labelFor(v)}</span>
<button
type="button"
+1 -1
View File
@@ -135,7 +135,7 @@ export default function DownloadCenter() {
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
+45 -64
View File
@@ -40,7 +40,8 @@ import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterSelect from "./FilterSelect";
import ItemCount from "./ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -568,75 +569,55 @@ export default function Feed({
}));
const toolbar = (
<div className="pb-3">
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
<FilterChipGroup
ariaLabel={t("feed.showLabel")}
options={SHOW_IDS.map((id) => ({
value: id,
label: t("sidebar.show." + id),
testId: `feed-show-${id}`,
}))}
value={filters.show}
onChange={(show) => setFilters({ ...filters, show })}
{/* 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} />
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
onChange={(next) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
}}
/>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
filterChipClass, but not a single-select group. */}
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={filterChipClass}
>
{t(c.label)}
</button>
);
})}
</div>
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
it selects how videos ENTERED the library. On a channel-scoped view we already show all
of this one channel's videos (librarySource is pinned to "all"), so the selector would
only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
<span>{t("feed.source.label")}</span>
<select
value={filters.librarySource ?? "organic"}
onChange={(e) =>
setFilters({
...filters,
librarySource: e.target.value as FeedFilters["librarySource"],
})
}
title={t("feed.source.tip")}
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
>
<option value="organic">{t("feed.source.organic")}</option>
<option value="all">{t("feed.source.all")}</option>
<option value="search">{t("feed.source.only")}</option>
</select>
</label>
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
{summaryQuery.data
? t("feed.videoCount", {
count: summaryQuery.data.count,
formattedCount: summaryQuery.data.count.toLocaleString(),
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={[
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
]}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) =>
setFilters({
...filters,
librarySource: next[0] as FeedFilters["librarySource"],
})
: " "}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
}
/>
)}
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
@@ -684,7 +665,7 @@ export default function Feed({
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
@@ -692,7 +673,7 @@ export default function Feed({
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
//
// Contract of the labeled-pill FilterSelect. Labels are plain props (no i18n). Built on the floating-ui
// AnchoredPopover platform, so we stub the two observers autoUpdate wires and wrap in a FloatingTree.
// floating-ui never positions in jsdom → the surface stays visibility:hidden, so the menuitem role
// queries pass `{ hidden: true }`. Single-select rows are `menuitemradio`, multi-select `menuitemcheckbox`
// (a menu surface with checkable items, not a listbox of orphaned options).
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import FilterSelect, { type FilterSelectOption } from "./FilterSelect";
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);
const STATUS: FilterSelectOption[] = [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
];
const TYPES: FilterSelectOption[] = [
{ value: "n", label: "Normal" },
{ value: "s", label: "Shorts" },
];
const tree = (ui: React.ReactNode) => render(<FloatingTree>{ui}</FloatingTree>);
describe("FilterSelect", () => {
it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => {
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: /^Status/ });
expect(trigger.textContent).toContain("Status:");
expect(trigger.textContent).toContain("All");
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
});
it("single: picking a row replaces the selection and closes", () => {
const onChange = vi.fn();
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
expect(rows.map((o) => o.textContent)).toEqual(["All", "New"]);
expect(rows[0]!.getAttribute("aria-checked")).toBe("true"); // "All" is the current selection
fireEvent.click(rows[1]!); // New
expect(onChange).toHaveBeenCalledWith(["new"]);
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
});
it("multiple: toggling a row adds it and keeps the panel open", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[1]!); // Shorts
expect(onChange).toHaveBeenCalledWith(["n", "s"]);
expect(screen.queryAllByRole("menuitemcheckbox", { hidden: true }).length).toBeGreaterThan(0); // still open
});
it("multiple + requireOne: the last remaining selection can't be turned off", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[0]!); // Normal — the only one selected
expect(onChange).not.toHaveBeenCalled();
});
it("multiple: the trigger summarises extra picks as `First +N`", () => {
tree(
<FilterSelect
label="Type"
multiple
options={TYPES}
selected={["n", "s"]}
onChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("Normal +1");
});
});
+142
View File
@@ -0,0 +1,142 @@
import { useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
// with one pill per filter group, in two flavours:
// - single (default): picking a row replaces the choice and closes — always exactly one active.
// - multiple: rows toggle independently and the panel stays open; `requireOne` blocks clearing the last.
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
export interface FilterSelectOption {
value: string;
label: string;
/** A facet count shown at the row's end. */
count?: number;
disabled?: boolean;
}
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
/** Toggle a subset (default false = pick exactly one). */
multiple?: boolean;
/** Multi only: keep at least one selected — the last active row can't be turned off. Default true. */
requireOne?: boolean;
/** Test hook: the trigger gets `data-testid={testId}`; each option gets `${testId}-${value}`. */
testId?: string;
}
export default function FilterSelect({
label,
options,
selected,
onChange,
multiple = false,
requireOne = true,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
const chosen = options.filter((o) => selected.includes(o.value));
const first = chosen[0];
const summary = multiple
? first
? `${first.label}${chosen.length > 1 ? ` +${chosen.length - 1}` : ""}`
: "—"
: labelOf(selected[0] ?? options[0]?.value ?? "");
const commit = (value: string) => {
if (!multiple) {
onChange([value]);
setOpen(false);
return;
}
const has = selected.includes(value);
if (has) {
if (requireOne && selected.length === 1) return; // don't let the last one go
onChange(selected.filter((x) => x !== value));
} else {
onChange([...selected, value]);
}
};
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
// Carry the current value in the accessible name (not just the group label), matching
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
aria-label={`${label}: ${summary}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
menu (see the mode above), never a bare listbox with orphaned options. */}
<div role="group" aria-label={label}>
{options.map((o) => {
const on = selected.includes(o.value);
return (
<PopoverItem
key={o.value}
role={multiple ? "menuitemcheckbox" : "menuitemradio"}
checked={on}
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label}
data-testid={testId ? `${testId}-${o.value}` : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
{multiple ? (
<span
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
on ? "border-accent bg-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
) : (
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
)}
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
);
}
+10 -22
View File
@@ -2,20 +2,17 @@ import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import type { Me } from "../lib/api";
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper (E5 S7).
@@ -30,7 +27,6 @@ const Header = forwardRef<
>(function Header({ me, onYtSearch }, ref) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
@@ -40,12 +36,6 @@ const Header = forwardRef<
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language.
const order = moduleOrder(me);
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
const multiModule = order.length > 1;
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
@@ -99,16 +89,14 @@ const Header = forwardRef<
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
// once the column is wider than the box's max-w — not hugged to the left edge.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
onPrev={() => setPage(stepModule(me, page, -1))}
onNext={() => setPage(stepModule(me, page, 1))}
canPrev={multiModule}
canNext={multiModule}
/>
{search}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// Contract of the fixed-width count pill. Uses the REAL i18n instance (not a key-passthrough) so the
// plural selection (itemCount_one/_other) and the "1M+" overflow interpolation are actually exercised —
// those are the two branches worth guarding. English is forced so the singular/plural strings are stable.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import "../i18n"; // side-effect: initialise the shared i18next instance
import i18n from "i18next";
import ItemCount from "./ItemCount";
beforeAll(async () => {
await i18n.changeLanguage("en");
});
afterEach(cleanup);
const text = () => screen.getByTestId("item-count").textContent;
describe("ItemCount", () => {
it("uses the singular string at 1 and the plural above it", () => {
render(<ItemCount count={1} />);
expect(text()).toBe("1 item");
});
it("pluralises and thousands-separates a normal count", () => {
render(<ItemCount count={192045} />);
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
expect(text()).toBe(`${(192045).toLocaleString()} items`);
});
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render(<ItemCount count={1_000_000} />);
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
(1_000_000).toLocaleString(),
);
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
render(<ItemCount />);
expect(text()).toBe(""); // the Layers icon carries no text
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
// so any surface can reuse it for whatever it counts.
export default function ItemCount({ count }: { count?: number }) {
const { t } = useTranslation();
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
// show the icon until the number arrives.
const label =
count == null
? ""
: t("common.itemCount", {
count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
});
return (
<span
data-testid="item-count"
title={count?.toLocaleString()}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
</span>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
// The floating module-name pill in the top header. The label area is sized to the WIDEST
// reachable module title (measured live, in the current language only — so dropping a locale or
// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label
// column a constant width: the name never shifts and the arrows stay put when paging between
// modules. Carries the back/forward arrows that step cyclically through the active modules, plus
// the accent dot (the former PageTitle "eyebrow" look, folded in here).
export default function ModuleName({
label,
labels,
onPrev,
onNext,
canPrev,
canNext,
}: {
label: string;
// Every reachable module's title in the current language — drives the fixed label width.
labels: string[];
onPrev: () => void;
onNext: () => void;
canPrev: boolean;
canNext: boolean;
}) {
const { t } = useTranslation();
const labelRef = useRef<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [labelW, setLabelW] = useState<number>();
// Measure the widest label using the label span's own rendered font (respects font-scale,
// tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a
// language switch or an account switch that changes which modules are reachable.
useLayoutEffect(() => {
const el = labelRef.current;
if (!el || labels.length === 0) return;
const cs = getComputedStyle(el);
const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas"));
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const ls = parseFloat(cs.letterSpacing) || 0;
const upper = cs.textTransform === "uppercase";
let max = 0;
for (const s of labels) {
const txt = upper ? s.toUpperCase() : s;
const w = ctx.measureText(txt).width + ls * txt.length;
if (w > max) max = w;
}
setLabelW(Math.ceil(max) + 2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [labels.join("")]);
const arrow =
"p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none";
return (
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
<button
onClick={onPrev}
disabled={!canPrev}
title={t("header.prevModule")}
aria-label={t("header.prevModule")}
className={arrow}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canNext}
title={t("header.nextModule")}
aria-label={t("header.nextModule")}
className={arrow}
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
<span
ref={labelRef}
className="min-w-0 truncate"
style={labelW ? { width: labelW } : undefined}
>
{label}
</span>
</span>
</div>
);
}
@@ -123,7 +123,7 @@ export default function NotificationsPanel() {
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
+87 -1
View File
@@ -43,11 +43,97 @@ describe("PageShell --hdr-h (E5 S7)", () => {
Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true });
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
roCb?.();
expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8
expect(content.style.getPropertyValue("--hdr-h")).toBe("112px"); // 12 + 96 + 4
});
it("adds no `--hdr-h` pad when there is no header", () => {
const { container } = render(<PageShell>content</PageShell>);
expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull();
});
it("re-measures when the header is removed then restored (feed→channel→back)", () => {
// PageShell is REUSED across feed↔channel navigation (same component position in App.tsx) — it
// never remounts, so the header DOM node is swapped under a stable PageShell. The measurement
// must re-run on that swap; a one-shot effect kept observing the FIRST node and, when it detached,
// rewrote --hdr-h to a stale 4px that stuck on Back. Regression for the channel-Back header overlap.
const hdr = () => <div data-testid="hdr">H</div>;
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
const measure = (top: number, h: number) => {
const el = container.querySelector('[data-testid="hdr"]') as HTMLElement;
Object.defineProperty(el, "offsetTop", { value: top, configurable: true });
Object.defineProperty(el, "offsetHeight", { value: h, configurable: true });
roCb?.();
};
measure(12, 52);
const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement;
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // 12 + 52 + 4
rerender(<PageShell>content</PageShell>); // → channel page: header gone
rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node
measure(12, 52); // fires the NEW header's observer
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // re-measured, not the stale 4px
});
});
// The pinned-rail overlay decision: a rail pins as OVERLAY (--rail-pinned-w 2.25rem, the toolbar keeps a
// slim gutter and content stays put) once the window is wide enough that the centred content leaves room
// for the rail in its left margin (maxWidth + ~36rem); below that it pins in-flow (16.75rem). The rem→px
// conversion must use the LIVE root font-size, not a hardcoded 16, so it stays correct at every text size.
describe("PageShell --rail-pinned-w / --content-mw", () => {
const root = document.documentElement;
const setWidth = (w: number) =>
Object.defineProperty(window, "innerWidth", { value: w, configurable: true });
afterEach(() => {
root.style.removeProperty("font-size");
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
});
it("overlays the rail once the window clears maxWidth + 36rem (at 16px/rem)", () => {
setWidth(1600); // 64rem*16 + 36rem*16 = 1024 + 576 = 1600 → exactly roomy
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--content-mw")).toBe("64rem");
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
});
it("keeps the rail in-flow just below the threshold", () => {
setWidth(1599);
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("never overlays on a full-width page (no maxWidth)", () => {
setWidth(3000);
render(<PageShell>content</PageShell>);
expect(root.style.getPropertyValue("--content-mw")).toBe("none");
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("recomputes on resize", () => {
setWidth(1599);
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
setWidth(1600);
window.dispatchEvent(new Event("resize"));
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
});
it("scales the rem threshold by the live root font-size, not a hardcoded 16", () => {
root.style.fontSize = "17px"; // as the --font-scale 1.06 default renders it
setWidth(1650); // roomy at 16px/rem (>1600) but NOT at 17px/rem (needs 100rem*17 = 1700)
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("recomputes at runtime when --font-scale changes the root font, without looping", async () => {
setWidth(1650); // roomy at 16px/rem (1650 >= 1600) → overlay
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
// The text-size slider changes the root font-size with NO resize event — the MutationObserver
// must recompute. At 17px/rem the threshold is 100rem*17 = 1700 > 1650, so the rail goes in-flow.
// (If the observer's self-write guard were broken this would loop forever and time out.)
root.style.fontSize = "17px";
await new Promise((r) => setTimeout(r, 0)); // flush the MutationObserver microtask
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
});
+73 -20
View File
@@ -4,7 +4,6 @@ import {
isValidElement,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
@@ -53,6 +52,7 @@ export default function PageShell({
children,
scrollKey,
fadeTop = true,
maxWidth,
}: {
header?: ReactNode;
banners?: ReactNode;
@@ -61,43 +61,96 @@ export default function PageShell({
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
// The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via
// --content-mw. Omit for a full-width page (feed). See index.css --content-mw.
maxWidth?: string;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
// Publish this page's content max-width so the floating header (and toolbar band) can centre on the
// same width the content column uses. Global (one page shell renders at a time). Also decide whether
// a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the
// centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left
// margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow).
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
// nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
let lastPxPerRem = 0;
const apply = () => {
// Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded
// 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's
// own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size.
const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16;
lastPxPerRem = pxPerRem;
const mwPx = maxWidth
? maxWidth.endsWith("rem")
? parseFloat(maxWidth) * pxPerRem
: parseFloat(maxWidth)
: null;
const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem;
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
};
apply();
window.addEventListener("resize", apply);
// The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the
// rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps
// our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer.
const fontObserver = new MutationObserver(() => {
if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply();
});
fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] });
return () => {
window.removeEventListener("resize", apply);
fontObserver.disconnect();
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
};
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
// would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto
// its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so
// the two live together with no assumption about the header's DOM parent.
const headerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
// would let the content toolbar slide up under it (E5 S7). Measure the header and write its height
// straight onto the content wrapper that consumes `--hdr-h`.
//
// Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`:
// PageShell is reused — never remounted — across feed↔channel navigation (same component position in
// App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps
// observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with
// a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class
// returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const h = headerRef.current;
const c = contentRef.current;
if (!h || !c) return;
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`);
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
contentEl?.style.removeProperty("--hdr-h");
return;
}
// `+ 4` = a tight gap below the floating header before the content toolbar starts.
const apply = () =>
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`);
apply();
const ro = new ResizeObserver(apply);
ro.observe(h);
return () => {
ro.disconnect();
c.style.removeProperty("--hdr-h");
};
}, []);
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl]);
const measuredHeader = isValidElement(header)
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
ref: headerRef,
ref: setHeaderEl,
})
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{measuredHeader}
<div
ref={contentRef}
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function PanelGroup({
<button
onClick={editing ? undefined : onToggleCollapse}
disabled={editing}
className="flex-1 min-w-0 flex items-center text-left select-none text-xs uppercase tracking-wide text-muted enabled:hover:text-fg transition"
className="flex-1 min-w-0 flex items-center text-left select-none text-sm uppercase tracking-wide text-muted enabled:hover:text-fg transition"
>
<span className="truncate">{title}</span>
</button>
+2 -2
View File
@@ -571,7 +571,7 @@ export default function Playlists() {
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
@@ -757,7 +757,7 @@ export default function Playlists() {
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl">
<div className="max-w-5xl mx-auto">
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
+4 -10
View File
@@ -29,6 +29,7 @@ import SegmentedToggle from "./SegmentedToggle";
import PlexDiscover from "./PlexDiscover";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -388,18 +389,11 @@ export default function PlexBrowse() {
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
{viewToggle}
<p className="text-xs text-muted">
{browseQ.isLoading
? " "
: dq
? t("plex.searchCount", { count: total })
: t("plex.count", { count: total })}
</p>
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
<div className="ml-auto flex items-center gap-2">
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={sortState}
+6 -4
View File
@@ -44,14 +44,16 @@ export default function SidePanel({
return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
<div
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
// on this full-height slot — that made the whole column next to the nav rail a live target, so
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
// handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-67" : "w-9"}`}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
mounted while the card is open — swapping it out from under the pointer would drop the very
@@ -74,7 +76,7 @@ export default function SidePanel({
</span>
)}
<span
className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted"
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{title}
@@ -98,7 +100,7 @@ export default function SidePanel({
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
{count > 0 && (
+39 -84
View File
@@ -15,7 +15,8 @@ import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -29,39 +30,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 365, key: "1year" },
];
function TagChip({
tag,
count,
active,
onClick,
}: {
tag: Tag;
count: number;
active: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<button
data-testid="filter-tag-chip"
data-tag={tag.name}
onClick={onClick}
title={t("sidebar.channelCount", { count })}
aria-pressed={active}
className={filterChipClass}
>
<span>{tag.name}</span>
<span
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
}`}
>
{count}
</span>
</button>
);
}
export default function Sidebar({
layout,
setLayout,
@@ -113,11 +81,20 @@ export default function Sidebar({
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
function toggleTag(id: number) {
const has = filters.tags.includes(id);
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag): ComboboxOption => ({
value: String(tg.id),
label: tg.name,
count: chipCount(tg),
});
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
const inList = new Set(list.map((tg) => tg.id));
setFilters({
...filters,
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
@@ -216,28 +193,19 @@ export default function Sidebar({
);
}
case "language": {
const chips = visibleChips(languages);
return (
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
<Combobox
label={t("sidebar.widget.language")}
options={visibleChips(languages).map(tagOption)}
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
);
}
case "topic": {
const chips = visibleChips(topics);
return (
<>
<div className="flex flex-col gap-2">
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
@@ -249,43 +217,30 @@ export default function Sidebar({
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
</>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</div>
);
}
case "tags": {
const tagChips = visibleChips(userTags);
return (
<div className="flex flex-wrap items-center gap-1.5">
{tagChips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && tagChips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
<div className="flex flex-col gap-2">
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
className="inline-flex items-center gap-1 self-start text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}
@@ -1,12 +1,29 @@
// @vitest-environment jsdom
//
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
// columnSort.test.ts). Labels are plain props here — no i18n needed.
import { describe, it, expect, vi, afterEach } from "vitest";
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
// (a labeled pill + a menu of `menuitemradio` sort keys with a footer direction segment), so we stub
// the two autoUpdate observers and wrap in a FloatingTree; rows only exist while open → role queries
// pass { hidden: true }.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import SortControl from "./SortControl";
import type { SortOption, SortState } from "../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const OPTS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" },
@@ -16,67 +33,71 @@ const OPTS: SortOption[] = [
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
const onChange = vi.fn();
render(
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>,
<FloatingTree>
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>
</FloatingTree>,
);
return { onChange };
}
const openPanel = () => fireEvent.click(screen.getByRole("button", { name: /^Sort/ }));
afterEach(cleanup);
describe("SortControl", () => {
it("renders one option per SortOption, no empty option without defaultLabel", () => {
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
renderControl({ key: "title", dir: "asc" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"Date",
"Title",
"Shuffle",
]);
openPanel();
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["Date", "Title", "Shuffle"]);
});
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
renderControl(null, { defaultLabel: "None" });
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
expect(none.value).toBe("");
openPanel();
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["None", "Date", "Title", "Shuffle"]);
});
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
renderControl({ key: "ghost", dir: "asc" });
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
expect(screen.getByRole("button", { name: /^Sort/ }).textContent).toContain("Date");
});
it("picking a new option fires onChange at that option's defaultDir", () => {
const { onChange } = renderControl({ key: "title", dir: "asc" });
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
openPanel();
fireEvent.click(screen.getAllByRole("menuitemradio", { hidden: true })[0]!); // Date
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
});
it("shows the direction toggle for a directional sort and flips it on click", () => {
it("shows the direction segment for a directional sort and sets asc on click", () => {
const { onChange } = renderControl({ key: "date", dir: "desc" });
const toggle = screen.getByRole("button", { name: "Descending" });
fireEvent.click(toggle);
openPanel();
fireEvent.click(screen.getByText("Ascending"));
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
});
it("hides the direction toggle for a directionless sort", () => {
it("hides the direction segment for a directionless sort", () => {
renderControl({ key: "shuffle", dir: "desc" });
expect(screen.queryByRole("button")).toBeNull();
openPanel();
expect(screen.queryByText("Ascending")).toBeNull();
expect(screen.queryByText("Descending")).toBeNull();
});
it("renders the trailing adornment", () => {
renderControl(
{ key: "shuffle", dir: "desc" },
{
trailing: <button>Reshuffle</button>,
},
);
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
it("renders the trailing adornment in the footer", () => {
renderControl({ key: "shuffle", dir: "desc" }, { trailing: <button>Reshuffle</button> });
openPanel();
expect(screen.getByText("Reshuffle")).toBeTruthy();
});
});
+137 -40
View File
@@ -1,13 +1,19 @@
import { type ReactNode } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared
// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via
// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a
// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment
// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control.
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
// — that opens a listbox of `SortOption`s with an asc/desc segment in the panel footer. Two-dimensional
// (key + direction) unlike FilterSelect, so it stays its own component. Driven by the shared `SortState`.
// `defaultLabel` opts a view into a "no sort" choice (tables); omit it for always-sorted views
// (feed/Plex). `trailing` is an adornment slot (the feed's reshuffle, playlists' dirty-first toggle)
// rendered in the footer beside the direction segment.
//
// Direction lives in the footer, not on the trigger, on purpose: the trigger stays a single plain
// <button> (the popover reference) with no nested button — picking a key keeps the panel open so key
// AND direction can be set in one visit; picking "no sort" closes it (nothing left to direct).
export default function SortControl({
options,
sort,
@@ -21,55 +27,146 @@ export default function SortControl({
options: SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
/** Accessible name for the dropdown (e.g. "Sort"). */
/** Accessible name + visible prefix for the trigger (e.g. "Sort"). */
label: string;
ascLabel: string;
descLabel: string;
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
* always-sorted views (feed/Plex), which never surface a null sort. */
defaultLabel?: string;
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
trailing?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
const showArrow = !!activeOpt && !activeOpt.directionless;
// An always-sorted view (no `defaultLabel`) must still show a real option when the current key isn't
// offered (a stale/out-of-scope sort) — fall back to the first option rather than a blank select
// (which also trips React's controlled-value warning). Views with a "no sort" option keep it.
const selectValue = active?.key ?? (defaultLabel === undefined ? (options[0]?.key ?? "") : "");
// Trigger summary: the active option's label, or the "no sort" text, or (a stale/out-of-scope sort on
// an always-sorted view) the first option's label rather than a blank pill.
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
const segCls = (on: boolean) =>
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
on
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`;
return (
<div className="flex items-center gap-1">
<select
value={selectValue}
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
aria-label={label}
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
// not a bare "Sort".
aria-label={`${label}: ${summary}${
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
{defaultLabel !== undefined && <option value="">{defaultLabel}</option>}
{options.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
{showArrow && active && (
<button
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
title={active.dir === "asc" ? ascLabel : descLabel}
aria-label={active.dir === "asc" ? ascLabel : descLabel}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{active.dir === "asc" ? (
<ArrowUp className="w-4 h-4" />
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
{showArrow &&
active &&
(active.dir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<ArrowDown className="w-4 h-4" />
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
))}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
<div role="group" aria-label={label}>
{defaultLabel !== undefined && (
<PopoverItem
role="menuitemradio"
checked={active === null}
onSelect={() => {
onChange(null);
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
active === null ? "text-fg" : "text-muted"
}`}
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
)}
</button>
)}
{trailing}
</div>
{options.map((o) => {
const on = active?.key === o.key;
return (
<PopoverItem
key={o.key}
role="menuitemradio"
checked={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{((showArrow && active) || trailing) && (
<div
role="group"
aria-label={label}
className="mt-1 flex items-center gap-1.5 border-t border-border px-1 pt-2"
>
{showArrow && active && (
<div className="flex flex-1 gap-1">
<button
type="button"
aria-pressed={active.dir === "asc"}
onClick={() => onChange({ key: active.key, dir: "asc" })}
className={segCls(active.dir === "asc")}
>
<ArrowUp className="h-3 w-3" aria-hidden="true" />
{ascLabel}
</button>
<button
type="button"
aria-pressed={active.dir === "desc"}
onClick={() => onChange({ key: active.key, dir: "desc" })}
className={segCls(active.dir === "desc")}
>
<ArrowDown className="h-3 w-3" aria-hidden="true" />
{descLabel}
</button>
</div>
)}
{trailing}
</div>
)}
</PopoverSurface>
</>
);
}
+5 -3
View File
@@ -65,12 +65,14 @@ export default function ViewSwitcher<T extends string>({
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
className={`shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<PopoverSurface
+10 -6
View File
@@ -75,21 +75,25 @@ export type PageDef = {
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false },
playlists: { Component: Playlists },
plex: { Component: PlexBrowse },
notifications: { Component: NotificationsPanel },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
+2
View File
@@ -12,6 +12,8 @@
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
"itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items",
"accessRequestsTitle": "Access requests",
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review",
+2
View File
@@ -12,6 +12,8 @@
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{formattedCount}} elem",
"accessRequestsTitle": "Hozzáférési kérések",
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
+11
View File
@@ -98,6 +98,17 @@
place since App and Playlists both reference it. */
--hdr-h: 4.25rem;
/* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
Measured once at startup (App) and written here; the floating header and the toolbar band pad
their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
--sbw: 0px;
/* Max width of a page's content column (cards / table). The floating header + toolbar band read it
so they centre on the SAME max-width the content uses, instead of spanning the whole column while
a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */
--content-mw: none;
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
-12
View File
@@ -145,15 +145,3 @@ export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map
export function moduleOrder(me: Me): Page[] {
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
}
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
// If the current page isn't a primary module (e.g. Settings), forward lands on the first module
// and back on the last.
export function stepModule(me: Me, current: Page, dir: 1 | -1): Page {
const order = moduleOrder(me);
if (order.length === 0) return current;
const i = order.indexOf(current);
// order is non-empty (guarded above) and every index below is in-bounds, so none are undefined.
if (i === -1) return dir === 1 ? order[0]! : order[order.length - 1]!;
return order[(i + dir + order.length) % order.length]!;
}