diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8ea9f4c0..05543621 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -275,7 +275,9 @@ Also in force: - **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6] - Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band + Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; the floating + header over it is its header slot (`PageHeader`: the page's own pills, the palette's Jump-to pill where a + page has none, `PageHeaderDefault`). The band holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a diff --git a/frontend/src/components/ui/PageShell.header.dom.test.tsx b/frontend/src/components/ui/PageShell.header.dom.test.tsx new file mode 100644 index 00000000..fa2b8a66 --- /dev/null +++ b/frontend/src/components/ui/PageShell.header.dom.test.tsx @@ -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 }) =>
{children}
, +})); + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const hdr =
; +const fallback = ( + + default + +); + +describe("the header slot", () => { + it("puts a page's pills inside the header box", () => { + render( + + + search + + , + ); + expect(screen.getByTestId("hdr").textContent).toBe("search"); + }); + + it("shows the default on a page with no header of its own", () => { + render({fallback}); + 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) => ( + + {own && ( + + search + + )} + {fallback} + + ); + 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) => ( + + + search + + {both && ( + + toggle + + )} + {fallback} + + ); + 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( + + + search + + {fallback} + , + ); + expect(screen.queryByText("search")).toBeNull(); + expect(screen.queryByText("default")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx index fca644c2..3986f971 100644 --- a/frontend/src/components/ui/PageShell.tsx +++ b/frontend/src/components/ui/PageShell.tsx @@ -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 ( - - - {measuredHeader} -
{ - lastPointer.current = e.pointerType; - }} - onContextMenu={onContextMenu} - > - {title && ownHeadings === 0 &&

{title}

} - {banners} - {/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the + + + + {measuredHeader} +
{ + lastPointer.current = e.pointerType; + }} + onContextMenu={onContextMenu} + > + {title && ownHeadings === 0 &&

{title}

} + {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. */} -
- {children} -
- - +
+ {children} +
+ + + ); } diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 646f6c58..ed7cd8ba 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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 `