feat(design): the page band (B4) — one h1 per page, section headings, skip to content, filters fold on a short window (E28 S3d4)
- PageShell names the page with a visually hidden h1; a view with its own title renders it as PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell). - Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included. - "Skip to content" is the first Tab stop and focuses the scroller's <main>. - Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log), the same row shown in place. - The band holds controls only: Downloads and Notifications lose their title cards, the Config and channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band, and the server log's toolbar sticks with the fade under it.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3d4 (B4): below ~600px of height a band's filter row folds into one "Filters · n" button that
|
||||
// shows it again in place; on a tall window there is no button and the row is always there.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FilterFoldButton } from "./FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
|
||||
function Band() {
|
||||
const fold = useFilterFold();
|
||||
return (
|
||||
<>
|
||||
<FilterFoldButton fold={fold} label="Filters" count={2} />
|
||||
{!fold.folded && <div data-testid="row">the filter row</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function windowHeight(short: boolean) {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query === "(max-height: 600px)" && short,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("the band's filter fold", () => {
|
||||
it("is not there on a tall window: the row always shows", () => {
|
||||
windowHeight(false);
|
||||
render(<Band />);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.getByTestId("row")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("folds the row into 'Filters · n' on a short window, and unfolds it in place", () => {
|
||||
windowHeight(true);
|
||||
render(<Band />);
|
||||
const button = screen.getByRole("button", { name: "Filters · 2" });
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByTestId("row")).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(screen.getByTestId("row")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import type { FilterFold } from "../../lib/useFilterFold";
|
||||
import { SelectTrigger } from "./SelectTrigger";
|
||||
|
||||
/** B4: the "Filters · n" button that folds a page band's filter row on a short window (see
|
||||
* lib/useFilterFold). Rendered only while the window is short. `count` = active filters. */
|
||||
export function FilterFoldButton({
|
||||
fold,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
fold: FilterFold;
|
||||
label: string;
|
||||
count: number;
|
||||
}) {
|
||||
if (!fold.short) return null;
|
||||
return (
|
||||
<SelectTrigger
|
||||
context="toolbar"
|
||||
open={fold.open}
|
||||
aria-expanded={fold.open}
|
||||
icon={<SlidersHorizontal className="size-3.5 shrink-0 text-muted" />}
|
||||
onClick={fold.toggle}
|
||||
>
|
||||
{count > 0 ? `${label} · ${count}` : label}
|
||||
</SelectTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { useOwnPageHeading } from "./pageHeading";
|
||||
|
||||
// The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen
|
||||
// reader user moves by headings, so a section caption drawn as a styled `div` hides the page's
|
||||
// structure from exactly the people who navigate by it.
|
||||
|
||||
/** A view's own visible title (a channel, a Plex show, a playlist): the page's `h1`, in place of the
|
||||
* shell's hidden page name. */
|
||||
export function PageHeading(props: ComponentPropsWithoutRef<"h1">) {
|
||||
useOwnPageHeading();
|
||||
return <h1 {...props} />;
|
||||
}
|
||||
|
||||
/** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or
|
||||
* a panel whose own title is the `h2`. */
|
||||
export function SectionLabel({
|
||||
level = 2,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
level?: 2 | 3;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const Tag = level === 2 ? "h2" : "h3";
|
||||
return (
|
||||
<Tag className={`text-xs uppercase tracking-wide text-muted ${className}`}>{children}</Tag>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useFocusKeeper } from "../../lib/useFocusKeeper";
|
||||
import { StickyTopProvider } from "./stickyTop";
|
||||
import { CONTENT_ID } from "./landmarks";
|
||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
||||
|
||||
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
||||
@@ -156,10 +157,14 @@ export default function PageScroller({
|
||||
}, [resolve]);
|
||||
|
||||
return (
|
||||
// `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab
|
||||
// lands on the page's first control.
|
||||
<main
|
||||
ref={setRef}
|
||||
id={CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
style={fade.style}
|
||||
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
|
||||
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
|
||||
>
|
||||
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import PageShell from "./PageShell";
|
||||
import { PageHeading } from "./Headings";
|
||||
|
||||
vi.mock("./PageScroller", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@@ -137,3 +138,28 @@ describe("PageShell --rail-pinned-w / --content-mw", () => {
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
});
|
||||
});
|
||||
|
||||
// E28 S3d4 (B4): every page has one h1 — the page's name, visually hidden, unless a view shows its
|
||||
// own title (a channel, a Plex show), which is then the page's h1 instead.
|
||||
describe("PageShell page heading", () => {
|
||||
it("names the page with a visually hidden h1", () => {
|
||||
render(<PageShell title="Downloads">content</PageShell>);
|
||||
const h1s = document.querySelectorAll("h1");
|
||||
expect(h1s).toHaveLength(1);
|
||||
expect(h1s[0]!.textContent).toBe("Downloads");
|
||||
expect(h1s[0]!.className).toContain("sr-only");
|
||||
});
|
||||
|
||||
it("steps aside while a view renders its own PageHeading, and returns when it goes", () => {
|
||||
const { rerender } = render(
|
||||
<PageShell title="Plex">
|
||||
<PageHeading>Knight Rider</PageHeading>
|
||||
</PageShell>,
|
||||
);
|
||||
expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual([
|
||||
"Knight Rider",
|
||||
]);
|
||||
rerender(<PageShell title="Plex">grid</PageShell>);
|
||||
expect([...document.querySelectorAll("h1")].map((h) => h.textContent)).toEqual(["Plex"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import PageScroller from "./PageScroller";
|
||||
import { PageToolbarSlot } from "./pageToolbarSlot";
|
||||
import { PageHeadingProvider } from "./pageHeading";
|
||||
|
||||
// A module renders its per-module fixed band (filter chips / sort / counts) through this. The
|
||||
// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content
|
||||
@@ -32,6 +34,9 @@ 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.
|
||||
// - `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.
|
||||
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region.
|
||||
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
|
||||
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
|
||||
@@ -41,12 +46,14 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
||||
// The shell renders a fragment so it drops straight into App's `relative` content column — the
|
||||
// floating header keeps resolving its `absolute` position against that same ancestor.
|
||||
export default function PageShell({
|
||||
title,
|
||||
header,
|
||||
banners,
|
||||
children,
|
||||
scrollKey,
|
||||
maxWidth,
|
||||
}: {
|
||||
title?: string;
|
||||
header?: ReactNode;
|
||||
banners?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -126,6 +133,12 @@ export default function PageShell({
|
||||
ro.observe(headerEl);
|
||||
return () => ro.disconnect();
|
||||
}, [headerEl, contentEl]);
|
||||
// How many views below render their own page `h1` right now (see ./pageHeading).
|
||||
const [ownHeadings, setOwnHeadings] = useState(0);
|
||||
const registerOwnHeading = useCallback(() => {
|
||||
setOwnHeadings((n) => n + 1);
|
||||
return () => setOwnHeadings((n) => n - 1);
|
||||
}, []);
|
||||
const measuredHeader = isValidElement(header)
|
||||
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
|
||||
ref: setHeaderEl,
|
||||
@@ -133,17 +146,20 @@ export default function PageShell({
|
||||
: header;
|
||||
return (
|
||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
>
|
||||
{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 value={registerOwnHeading}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
>
|
||||
{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>
|
||||
</PageToolbarSlot.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ domain-free.
|
||||
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), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3d4 (UX-4): "Skip to content" moves the focus to the page's <main> itself. Following its
|
||||
// `#content` fragment would push a history entry, and the app reads every popstate as a page change.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import { CONTENT_ID } from "./landmarks";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SkipLink", () => {
|
||||
it("focuses the content region without touching the address", () => {
|
||||
render(
|
||||
<>
|
||||
<SkipLink label="Skip to content" />
|
||||
<main id={CONTENT_ID} tabIndex={-1}>
|
||||
page
|
||||
</main>
|
||||
</>,
|
||||
);
|
||||
const before = window.location.href;
|
||||
const link = screen.getByRole("link", { name: "Skip to content" });
|
||||
// Off screen until focused, then a full-size button (sr-only / not-sr-only zeroed its padding).
|
||||
expect(link.className).toContain("focus:translate-y-0");
|
||||
expect(link.className).not.toMatch(/sr-only/);
|
||||
fireEvent.click(link);
|
||||
expect(document.activeElement?.id).toBe(CONTENT_ID);
|
||||
expect(window.location.href).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LinkButton } from "./LinkButton";
|
||||
import { CONTENT_ID } from "./landmarks";
|
||||
|
||||
// "Skip to content" (UX-4, WCAG 2.4.1): the first Tab stop of the app, off screen until it has the
|
||||
// focus. Without it the first feed card was the 102nd Tab stop, behind the rail, the filter panel and
|
||||
// the header. It moves the focus itself rather than following its `#content` fragment: a fragment
|
||||
// change is a history entry, and the app's navigation reads every popstate as a page change.
|
||||
export function SkipLink({ label }: { label: string }) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={`#${CONTENT_ID}`}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="fixed left-3 top-3 z-tooltip -translate-y-[calc(100%+1rem)] focus:translate-y-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(CONTENT_ID)?.focus();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import { SectionLabel } from "./Headings";
|
||||
|
||||
// Shared form/settings primitives, factored out of the many panels that each re-declared
|
||||
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
|
||||
@@ -50,8 +51,8 @@ export function Switch({
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled settings block. Default = a plain block with an uppercase caption; `card` wraps it
|
||||
* in a glass card (the admin pages' style). */
|
||||
/** A titled settings block. Default = a plain block with an uppercase caption (the section's `h2`);
|
||||
* `card` wraps it in a glass card (the admin pages' style). */
|
||||
export function Section({
|
||||
title,
|
||||
card,
|
||||
@@ -63,9 +64,7 @@ export function Section({
|
||||
}) {
|
||||
return (
|
||||
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
|
||||
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
|
||||
{title}
|
||||
</div>
|
||||
<SectionLabel className={card ? "mb-3" : "mb-2"}>{title}</SectionLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// The id of the page's content region, `PageScroller`'s <main>: the target of `SkipLink`. One
|
||||
// constant, so the link and its target cannot drift apart.
|
||||
export const CONTENT_ID = "content";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext, useLayoutEffect } from "react";
|
||||
|
||||
// B4 / UX-15: every page has exactly one `h1`. The shell renders the page's name as a visually hidden
|
||||
// one; a view that shows its OWN title (a channel, a Plex show, a playlist) renders that as the page's
|
||||
// `h1` through `PageHeading`, which says so here, and the shell drops its hidden one while it is
|
||||
// mounted. The same shape as ./stickyTop: the element announces itself, no page opts out by hand.
|
||||
const PageHeadingContext = createContext<(() => () => void) | null>(null);
|
||||
|
||||
export const PageHeadingProvider = PageHeadingContext.Provider;
|
||||
|
||||
/** Called by `PageHeading`: while mounted, the shell's hidden page name steps aside. */
|
||||
export function useOwnPageHeading(): void {
|
||||
const register = useContext(PageHeadingContext);
|
||||
useLayoutEffect(() => register?.(), [register]);
|
||||
}
|
||||
Reference in New Issue
Block a user