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:
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The floating header knows no page: a page fills it through PageHeader (a portal into the header's
|
||||
// root box), and the shell's PageHeaderDefault shows only while no page does. These pin the slot's
|
||||
// three promises: the page's pills land IN the header, the default steps aside for them and comes
|
||||
// back when the next page has none, and a shell without a header (the channel page) renders the
|
||||
// page's pills nowhere.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import PageShell, { PageHeader, PageHeaderDefault } from "./PageShell";
|
||||
|
||||
vi.mock("./PageScroller", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const hdr = <div data-testid="hdr" />;
|
||||
const fallback = (
|
||||
<PageHeaderDefault>
|
||||
<span>default</span>
|
||||
</PageHeaderDefault>
|
||||
);
|
||||
|
||||
describe("the header slot", () => {
|
||||
it("puts a page's pills inside the header box", () => {
|
||||
render(
|
||||
<PageShell header={hdr}>
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
</PageHeader>
|
||||
</PageShell>,
|
||||
);
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||
});
|
||||
|
||||
it("shows the default on a page with no header of its own", () => {
|
||||
render(<PageShell header={hdr}>{fallback}</PageShell>);
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||
});
|
||||
|
||||
it("hides the default while a page fills the header, and brings it back on the next page", () => {
|
||||
const page = (own: boolean) => (
|
||||
<PageShell header={hdr}>
|
||||
{own && (
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
</PageHeader>
|
||||
)}
|
||||
{fallback}
|
||||
</PageShell>
|
||||
);
|
||||
const { rerender } = render(page(true));
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||
rerender(page(false));
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||
rerender(page(true));
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||
});
|
||||
|
||||
it("keeps the default hidden while one of two PageHeaders on a page remains", () => {
|
||||
const page = (both: boolean) => (
|
||||
<PageShell header={hdr}>
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
</PageHeader>
|
||||
{both && (
|
||||
<PageHeader>
|
||||
<span>toggle</span>
|
||||
</PageHeader>
|
||||
)}
|
||||
{fallback}
|
||||
</PageShell>
|
||||
);
|
||||
const { rerender } = render(page(true));
|
||||
rerender(page(false));
|
||||
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||
});
|
||||
|
||||
it("renders a page's pills nowhere in a shell without a header", () => {
|
||||
render(
|
||||
<PageShell>
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
</PageHeader>
|
||||
{fallback}
|
||||
</PageShell>,
|
||||
);
|
||||
expect(screen.queryByText("search")).toBeNull();
|
||||
expect(screen.queryByText("default")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import PageScroller from "./PageScroller";
|
||||
import { PageToolbarSlot } from "./pageToolbarSlot";
|
||||
import { PageHeaderSlot } from "./pageHeaderSlot";
|
||||
import { PageHeadingProvider } from "./pageHeading";
|
||||
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
|
||||
import { isBackgroundPoint } from "../../lib/backgroundTarget";
|
||||
@@ -29,6 +30,25 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
||||
return slot ? createPortal(children, slot) : null;
|
||||
}
|
||||
|
||||
// A page renders its controls in the floating header (its search box, scope toggles, sync chip)
|
||||
// through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React
|
||||
// subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse).
|
||||
// The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault).
|
||||
export function PageHeader({ children }: { children: ReactNode }) {
|
||||
const slot = useContext(PageHeaderSlot);
|
||||
const claim = slot?.claim;
|
||||
useLayoutEffect(() => claim?.(), [claim]);
|
||||
return slot?.target ? createPortal(children, slot.target) : null;
|
||||
}
|
||||
|
||||
// What the header shows on a page that renders no PageHeader of its own (the palette's entry). App
|
||||
// renders it beside the page, inside the page's Suspense, so a page still loading shows no default
|
||||
// that its own controls would replace a moment later.
|
||||
export function PageHeaderDefault({ children }: { children: ReactNode }) {
|
||||
const slot = useContext(PageHeaderSlot);
|
||||
return slot?.target && !slot.claimed ? createPortal(children, slot.target) : null;
|
||||
}
|
||||
|
||||
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
|
||||
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
|
||||
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
|
||||
@@ -38,6 +58,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
||||
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
|
||||
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
|
||||
// and no fixed top band, so the module's own banner can scroll from the very top.
|
||||
// The header is an empty band: the page fills it through PageHeader.
|
||||
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
|
||||
// the visible identity, and a screen reader still lands on a named page. A view that
|
||||
// shows its own title renders it through `PageHeading`, and this one steps aside.
|
||||
@@ -137,6 +158,18 @@ export default function PageShell({
|
||||
ro.observe(headerEl);
|
||||
return () => ro.disconnect();
|
||||
}, [headerEl, contentEl]);
|
||||
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
|
||||
// that leaves releases only its own claim.
|
||||
const [headerClaims, setHeaderClaims] = useState(0);
|
||||
const claimHeader = useCallback(() => {
|
||||
setHeaderClaims((n) => n + 1);
|
||||
return () => setHeaderClaims((n) => n - 1);
|
||||
}, []);
|
||||
const headerSlot = {
|
||||
target: headerEl,
|
||||
claimed: headerClaims > 0,
|
||||
claim: claimHeader,
|
||||
};
|
||||
// How many views below render their own page `h1` right now (see ./pageHeading).
|
||||
const [ownHeadings, setOwnHeadings] = useState(0);
|
||||
const registerOwnHeading = useCallback(() => {
|
||||
@@ -173,26 +206,28 @@ export default function PageShell({
|
||||
: header;
|
||||
return (
|
||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||
<PageMenuSlot.Provider value={registerPageMenu}>
|
||||
<PageHeadingProvider value={registerOwnHeading}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
|
||||
{banners}
|
||||
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
|
||||
<PageHeaderSlot.Provider value={headerSlot}>
|
||||
<PageMenuSlot.Provider value={registerPageMenu}>
|
||||
<PageHeadingProvider value={registerOwnHeading}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
onPointerDown={(e) => {
|
||||
lastPointer.current = e.pointerType;
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
|
||||
{banners}
|
||||
{/* 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}>{children}</PageScroller>
|
||||
</div>
|
||||
</PageHeadingProvider>
|
||||
</PageMenuSlot.Provider>
|
||||
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
|
||||
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
|
||||
</div>
|
||||
</PageHeadingProvider>
|
||||
</PageMenuSlot.Provider>
|
||||
</PageHeaderSlot.Provider>
|
||||
</PageToolbarSlot.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
// The floating header's slot (see PageHeader in PageShell): the node a page portals its header
|
||||
// controls into, whether a page has claimed it, and how a page claims it. Null with no shell, or in a
|
||||
// shell without a header (the channel page), so a page renders its header nowhere there. Its own
|
||||
// module so PageShell exports only components (Fast Refresh).
|
||||
export type PageHeaderSlotValue = {
|
||||
target: HTMLElement | null;
|
||||
claimed: boolean;
|
||||
/** Claim the header for this page's controls; the returned function releases it. */
|
||||
claim: () => () => void;
|
||||
};
|
||||
|
||||
export const PageHeaderSlot = createContext<PageHeaderSlotValue | null>(null);
|
||||
Reference in New Issue
Block a user