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:
@@ -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$/ });
|
||||
|
||||
Reference in New Issue
Block a user