Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/viewer/e2e/keyboard.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@ try {
skip("modal focus-trap (no clear-all in this environment)");
}

// --- Pages stepper: arrow-key nav + nested isolation -----------------------------------------
await push("pager", "component", JSON.stringify({
type: "Pages",
props: { pages: [
{ title: "One", node: { type: "Pages", props: { pages: [
{ title: "i1", node: { type: "Text", children: ["inner 1"] } },
{ title: "i2", node: { type: "Text", children: ["inner 2"] } },
] } } },
{ title: "Two", node: { type: "Text", children: ["outer two"] } },
{ title: "Three", node: { type: "Text", children: ["outer three"] } },
] },
}));
await p.evaluate(() => { location.hash = "#kbd%2Fpager"; });
await p.waitForSelector(".tc-pages .tc-pages", { timeout: 8000 });
const bodyHas = (s) => p.evaluate((t) => (document.body.textContent || "").includes(t), s);

await p.evaluate(() => document.querySelector('.tc-pages > .tc-pages-bar [aria-label="next page"]')?.focus());
await p.keyboard.press("ArrowRight");
ok("ArrowRight steps the focused pager forward", await bodyHas("outer two"));
await p.keyboard.press("ArrowLeft");
ok("ArrowLeft steps it back", await bodyHas("inner 1"));

await p.evaluate(() => document.querySelector('.tc-pages .tc-pages [aria-label="next page"]')?.focus());
await p.keyboard.press("ArrowRight");
ok("nested pager arrows step the inner pager only", (await bodyHas("inner 2")) && !(await bodyHas("outer two")));

await p.evaluate(() => document.querySelector('.tc-pages > .tc-pages-bar [aria-label="next page"]')?.focus());
await p.keyboard.press("End");
ok("End jumps the pager to the last page", await bodyHas("outer three"));
await p.keyboard.press("Home");
ok("Home jumps back to the first page", await bodyHas("inner 1"));

// --- Regression: Escape with nothing open is harmless ---------------------------------------
await p.keyboard.press("Escape");
ok("Escape with no overlay does not throw", true);
Expand Down
200 changes: 179 additions & 21 deletions packages/viewer/src/client/renderers/pages.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Group, Pagination, Text } from "@mantine/core";
import { useState } from "react";
import { Button, Text } from "@mantine/core";
import { createContext, useContext, useState } from "react";
import { injectStyle } from "./inject-style.js";
import { resolve } from "./component-resolver.js";

/**
* `Pages` — top-level board pagination: one page rendered at a
* time with a Pagination control above and below the content.
* time with a stepper control above and below the content.
* Off-page trees are never resolved, so their images are never
* fetched — a 400-node board pays only for the page in view.
*
Expand All @@ -16,38 +17,195 @@ import { resolve } from "./component-resolver.js";
* `pages` is a plain-data prop (the MapDetail.items convention),
* so the resolver leaves the trees raw and this component
* resolves only the active one via `resolve()`.
*
* The control is built for step-through use (algorithm debuggers
* arrow through stages, often with a Pages nested inside a page):
* labeled Prev/Next with end-disabled states, the current title
* shown prominently, arrow-key navigation while the bar has
* focus, and a progress affordance that scales — clickable dots
* up to 16 pages, a slim bar plus a jump-by-title select beyond.
* Nested pagers read a depth context and render smaller + inset
* so outer/inner controls are visually distinct.
*/
export interface PageEntry {
title?: string;
node?: unknown;
}

/** Dot-per-page stops scaling past this; switch to progress bar + jump select. */
const DOTS_MAX = 16;

const PagesDepth = createContext(0);

const CSS = `
.tc-pages-bar { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
.tc-pages-mid { flex: 1 1 0; min-width: 0; display: flex; align-items: baseline; gap: 8px; }
.tc-pages-dots { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin: 4px 0 8px; }
.tc-pages-dot { width: 8px; height: 8px; border-radius: 4px; border: none; padding: 0; cursor: pointer;
background: var(--mantine-color-default-border, #adb5bd);
transition: width 120ms ease, background-color 120ms ease; }
.tc-pages-dot:hover { background: var(--mantine-color-dimmed, #868e96); }
.tc-pages-dot[aria-current="page"] { width: 22px; background: var(--mantine-primary-color-filled, #228be6); }
.tc-pages-dot:focus-visible { outline: 2px solid var(--mantine-primary-color-filled, #228be6); outline-offset: 2px; }
.tc-pages-track { height: 4px; border-radius: 2px; margin: 4px 0 8px;
background: var(--mantine-color-default-border, #dee2e6); overflow: hidden; }
.tc-pages-fill { height: 100%; border-radius: 2px; background: var(--mantine-primary-color-filled, #228be6);
transition: width 160ms ease; }
.tc-pages-jump { flex: 0 1 auto; min-width: 0; font: inherit; font-weight: 600; font-size: 13px;
color: inherit; background: transparent; cursor: pointer;
border: 1px solid var(--mantine-color-default-border, #ced4da); border-radius: 6px; padding: 2px 6px; }
.tc-pages-jump:focus-visible { outline: 2px solid var(--mantine-primary-color-filled, #228be6); outline-offset: 1px; }
.tc-pages-page { animation: tc-pages-in 160ms ease-out; }
@keyframes tc-pages-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* nested pagers are subordinate: inset with a rule, tighter bars, smaller dots */
.tc-pages .tc-pages { padding-left: 10px; border-left: 2px solid var(--mantine-color-default-border, #dee2e6); }
.tc-pages .tc-pages .tc-pages-bar { margin: 6px 0; }
.tc-pages .tc-pages .tc-pages-dot { width: 6px; height: 6px; }
.tc-pages .tc-pages .tc-pages-dot[aria-current="page"] { width: 18px; }
@media (prefers-reduced-motion: reduce) {
.tc-pages-page { animation: none; }
.tc-pages-dot, .tc-pages-fill { transition: none; }
}
`;

export function Pages(props: { pages?: PageEntry[] }) {
const pages = Array.isArray(props.pages) ? props.pages : [];
const depth = useContext(PagesDepth);
const [page, setPage] = useState(1);
if (pages.length === 0) return null;
const idx = Math.min(Math.max(page, 1), pages.length) - 1;
injectStyle("tc-pages", CSS);

const total = pages.length;
const idx = Math.min(Math.max(page, 1), total) - 1; // clamp: live patches may shrink `pages`
const entry = pages[idx] ?? {};
const title = String(entry.title ?? `Page ${idx + 1}`);
const control = (where: string) => (
<Group gap="md" my="sm" wrap="wrap" key={where}>
<Pagination
total={pages.length}
value={idx + 1}
onChange={setPage}
size="sm"
siblings={2}
/>
<Text size="sm" c="dimmed">
{idx + 1}/{pages.length} · {title}
</Text>
</Group>
const titleOf = (i: number) => String(pages[i]?.title ?? `Page ${i + 1}`);
const title = titleOf(idx);
const go = (n: number) => setPage(Math.min(Math.max(n, 1), total));

const nested = depth > 0;
const btnSize = nested ? "compact-xs" : "compact-sm";
const txtSize = nested ? "xs" : "sm";

const content = (
<PagesDepth.Provider value={depth + 1}>
<div key={idx} className="tc-pages-page">
{resolve(entry.node)}
</div>
</PagesDepth.Provider>
);

if (total === 1) {
return (
<div className="tc-pages">
<Text size={txtSize} fw={600} my="xs" title={title}>
{title}
</Text>
{content}
</div>
);
}

// Arrow-step while focus is anywhere in a bar. stopPropagation keeps a nested
// pager's keys from also driving the outer one (and board-level handlers).
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if ((e.target as HTMLElement).tagName === "SELECT") return; // jump select owns its keys
const next =
e.key === "ArrowLeft" ? idx : e.key === "ArrowRight" ? idx + 2 : e.key === "Home" ? 1 : e.key === "End" ? total : 0;
if (next === 0) return;
e.preventDefault();
e.stopPropagation();
go(next);
};

const bar = (where: "top" | "bottom") => (
<div
key={where}
className="tc-pages-bar"
role="group"
aria-label={`pager, page ${idx + 1} of ${total}`}
onKeyDown={onKeyDown}
>
{/* data-disabled (not disabled) keeps the button focusable at the ends,
so arrow-key stepping survives reaching page 1 / page N */}
<Button
variant="default"
size={btnSize}
data-disabled={idx === 0 || undefined}
aria-disabled={idx === 0 || undefined}
onClick={idx === 0 ? (e) => e.preventDefault() : () => go(idx)}
title={idx > 0 ? titleOf(idx - 1) : undefined}
aria-label="previous page"
>
‹ Prev
</Button>
{/* polite live region (top bar only) so a step change is announced once */}
<div className="tc-pages-mid" aria-live={where === "top" ? "polite" : undefined}>
{total > DOTS_MAX ? (
<select
className="tc-pages-jump"
value={idx}
onChange={(e) => go(Number(e.currentTarget.value) + 1)}
aria-label="jump to page"
>
{pages.map((_, i) => (
<option key={i} value={i}>
{`${i + 1}/${total} · ${titleOf(i)}`}
</option>
))}
</select>
) : (
<>
<Text size={txtSize} fw={600} truncate style={{ minWidth: 0 }} title={title}>
{title}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{idx + 1} of {total}
</Text>
</>
)}
</div>
<Button
variant="default"
size={btnSize}
data-disabled={idx === total - 1 || undefined}
aria-disabled={idx === total - 1 || undefined}
onClick={idx === total - 1 ? (e) => e.preventDefault() : () => go(idx + 2)}
title={idx < total - 1 ? titleOf(idx + 1) : undefined}
aria-label="next page"
>
Next ›
</Button>
</div>
);

const rail =
total > DOTS_MAX ? (
<div className="tc-pages-track" aria-hidden="true">
<div className="tc-pages-fill" style={{ width: `${((idx + 1) / total) * 100}%` }} />
</div>
) : (
<div className="tc-pages-dots" role="group" aria-label="pages">
{pages.map((_, i) => (
<button
key={i}
type="button"
className="tc-pages-dot"
aria-current={i === idx ? "page" : undefined}
aria-label={`page ${i + 1}: ${titleOf(i)}`}
title={titleOf(i)}
onClick={() => go(i + 1)}
/>
))}
</div>
);

return (
<div className="tc-pages">
{control("top")}
<div key={idx}>{resolve(entry.node)}</div>
{control("bottom")}
{bar("top")}
{rail}
{content}
{bar("bottom")}
</div>
);
}
74 changes: 74 additions & 0 deletions packages/viewer/test/pages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MantineProvider } from "@mantine/core";
import { resolve, componentNames } from "../src/client/renderers/component-resolver.js";

const wrap = (node: unknown) =>
renderToStaticMarkup(createElement(MantineProvider, { defaultColorScheme: "dark" }, resolve(node) as never));

const mkPages = (n: number, prefix = "Step") => ({
type: "Pages",
props: {
pages: Array.from({ length: n }, (_, i) => ({
title: `${prefix} ${i + 1}`,
node: { type: "Text", children: [`content of page ${i + 1}`] },
})),
},
});

describe("Pages", () => {
it("is registered and renders labeled prev/next, current title, count, and a dot per page", () => {
expect(componentNames()).toContain("Pages");
const html = wrap(mkPages(5));
expect(html).toContain("‹ Prev");
expect(html).toContain("Next ›");
expect(html).toContain("Step 1"); // current title, prominent
expect(html).toContain("1 of 5");
expect((html.match(/class="tc-pages-dot"/g) || []).length).toBe(5);
expect(html).toContain('aria-current="page"');
expect(html).toContain('aria-label="previous page"');
expect(html).toContain('aria-label="next page"');
});

it("renders only the active page's tree (off-page content stays unresolved)", () => {
const html = wrap(mkPages(5));
expect(html).toContain("content of page 1");
expect(html).not.toContain("content of page 2");
});

it("marks Prev disabled-but-focusable on the first page (arrow nav must survive the ends)", () => {
const html = wrap(mkPages(3));
const prev = html.slice(html.indexOf("previous page") - 400, html.indexOf("previous page") + 40);
expect(prev).toContain('data-disabled="true"');
expect(prev).toContain('aria-disabled="true"');
expect(prev).not.toContain(" disabled="); // real `disabled` would drop keyboard focus
});

it("swaps dots for a progress bar + jump-by-title select past 16 pages", () => {
const html = wrap(mkPages(20));
expect(html).not.toContain("tc-pages-dot\"");
expect(html).toContain("tc-pages-track");
expect(html).toContain("tc-pages-fill");
expect(html).toContain("tc-pages-jump");
expect(html).toContain("3/20 · Step 3"); // options carry position + title
});

it("renders a single page with its title but no stepper chrome", () => {
const html = wrap(mkPages(1, "Only"));
expect(html).toContain("Only 1");
expect(html).toContain("content of page 1");
expect(html).not.toContain("Next ›");
expect(html).not.toContain("tc-pages-dot");
});

it("renders nothing for an empty pages array and falls back titles for untitled pages", () => {
expect(wrap({ type: "Pages", props: { pages: [] } })).not.toContain("tc-pages");
const html = wrap({
type: "Pages",
props: { pages: [{ node: { type: "Text", children: ["a"] } }, { node: { type: "Text", children: ["b"] } }] },
});
expect(html).toContain("Page 1");
});
});
Loading