refactor(frontend): each page fills the floating header itself; the header knows no page

The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
This commit is contained in:
2026-10-23 00:23:38 +02:00
parent 0b24f2d8fa
commit 4ef963b005
36 changed files with 530 additions and 316 deletions
+34 -26
View File
@@ -1,72 +1,80 @@
// @vitest-environment jsdom
//
// Ctrl/Cmd+K had no visible entry point (debt burn B28). The owner's pick: a key cap in the header's
// search field, and a "Jump to…" pill in its place on a page that has no search field.
import { describe, it, expect, vi, afterEach } from "vitest";
// search field, and a "Jump to…" pill in its place on a page that has no search field. The shell
// composes it the way App does: the page's own pills (a search box here) through PageHeader, and the
// pill as the header's default.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
let page = "home";
vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
vi.mock("../modules/feed/feedFiltersContext", () => ({
useFeedFilters: () => ({ q: "", sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
vi.mock("../components/ui/PageScroller", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("../modules/playlists/playlistsContext", () => ({
usePlaylists: () => ({ search: "" }),
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
}));
vi.mock("../modules/channels/channelsContext", () => ({
useChannels: () => ({ search: "" }),
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
}));
vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
import Header from "./Header";
import PageShell, { PageHeader, PageHeaderDefault } from "../components/ui/PageShell";
import SearchBar from "../features/header/SearchBar";
import { PaletteLauncher } from "../features/header/PaletteLauncher";
import { closePalette, usePaletteOpen } from "../lib/keymap";
import type { Me } from "../lib/api";
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
act(() => closePalette());
});
function mount() {
function mount(withSearch: boolean) {
let open = false;
function Probe() {
open = usePaletteOpen();
return null;
}
render(
<>
<Header me={{ plex_enabled: false } as Me} onYtSearch={() => {}} />
<PageShell header={<Header />}>
{withSearch && (
<PageHeader>
<SearchBar value="" placeholder="search" onChange={() => {}} onGo={() => {}} />
</PageHeader>
)}
<PageHeaderDefault>
<PaletteLauncher />
</PageHeaderDefault>
<Probe />
</>,
</PageShell>,
);
return () => open;
}
describe("the command palette's visible entry", () => {
it("is a key cap inside the search field, naming the chord the palette row binds", () => {
page = "feed";
const isOpen = mount();
const isOpen = mount(true);
const cap = screen.getByRole("button", { name: /^palette\.open \((Ctrl|⌘) K\)$/ });
// Label in Name: the name carries what the cap shows, so "click Ctrl K" finds it.
expect(cap.getAttribute("aria-label")).toContain(cap.textContent);
// …and a tooltip equal to that name, so a screen reader does not read it again as a description.
expect(cap.getAttribute("title")).toBe(cap.getAttribute("aria-label"));
expect(screen.getByRole("textbox")).toBeTruthy();
// The search field takes the pill's place: one entry, not two.
expect(screen.queryByRole("button", { name: /^palette\.title/ })).toBeNull();
fireEvent.click(cap);
expect(isOpen()).toBe(true);
});
it("is a Jump-to pill on a page with no search field", () => {
page = "home";
const isOpen = mount();
const isOpen = mount(false);
expect(screen.queryByRole("textbox")).toBeNull();
// Named by what it shows ("Jump to…" and its key cap), not by a label that replaced it.
const pill = screen.getByRole("button", { name: /^palette\.title ?(Ctrl|⌘) K$/ });