From d00fb4daa41c35f18242df08eea75b9c1aaad5d4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 4 Aug 2026 09:52:34 -0700 Subject: [PATCH 1/2] Paginate skill detail content --- .../src/components/tools/SkillDetailView.tsx | 128 ++++++++++++++---- .../tools/detail-page-recipes.test.tsx | 87 +++++++++++- .../src/components/ui/resource-pagination.tsx | 4 +- 3 files changed, 194 insertions(+), 25 deletions(-) diff --git a/apps/app/src/components/tools/SkillDetailView.tsx b/apps/app/src/components/tools/SkillDetailView.tsx index 26d0908de2..9812832078 100644 --- a/apps/app/src/components/tools/SkillDetailView.tsx +++ b/apps/app/src/components/tools/SkillDetailView.tsx @@ -1,7 +1,8 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { formatHomePathForDisplay } from "@bb/shared-ui/lib/utils"; +import { ResourcePagination } from "@bb/shared-ui/resource-pagination"; import { ResourceDefinitionSection, ResourceDetailCollection, @@ -156,6 +157,103 @@ function SkillFileList({ ); } +function PagedSkillContent({ + path, + content, + markdown, +}: { + path: string; + content: string; + markdown: boolean; +}) { + const [viewport, setViewport] = useState(null); + const [pages, setPages] = useState(null); + const [page, setPage] = useState(0); + const [measurement, setMeasurement] = useState({ + pageHeight: 0, + pageCount: 1, + }); + + useEffect(() => { + if (viewport === null || pages === null) return; + const viewportElement = viewport; + const pagesElement = pages; + + function measure() { + const pageHeight = viewportElement.clientHeight; + if (pageHeight <= 0) return; + const pageCount = Math.max( + 1, + Math.ceil(pagesElement.scrollHeight / pageHeight), + ); + setMeasurement((current) => + current.pageHeight === pageHeight && current.pageCount === pageCount + ? current + : { pageHeight, pageCount }, + ); + } + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(measure); + resizeObserver?.observe(viewportElement); + resizeObserver?.observe(pagesElement); + window.addEventListener("resize", measure); + measure(); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [pages, viewport]); + + const safePage = Math.min(page, measurement.pageCount - 1); + + return ( +
+ +
+
+ +
+
+
+ +
+ ); +} + export function SkillDetailView({ leading, title, @@ -242,28 +340,12 @@ export function SkillDetailView({ ) : ( - - - + ))} diff --git a/apps/app/src/components/tools/detail-page-recipes.test.tsx b/apps/app/src/components/tools/detail-page-recipes.test.tsx index 981b527565..1db9260d31 100644 --- a/apps/app/src/components/tools/detail-page-recipes.test.tsx +++ b/apps/app/src/components/tools/detail-page-recipes.test.tsx @@ -9,7 +9,13 @@ * required section fails here rather than silently drifting. */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { useState, type ComponentProps } from "react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -528,6 +534,85 @@ describe("Skill detail recipe", () => { container.querySelector('[data-icon="CircleX"]')?.getAttribute("class"), ).toContain("text-destructive"); }); + + it("keeps short skill content on one page without pagination chrome", () => { + const { container } = renderSkill(["/skills/writing-voice/SKILL.md"]); + const viewport = container.querySelector( + "[data-skill-content-viewport]", + ); + const content = container.querySelector( + "[data-skill-content-pages]", + ); + expect(viewport).not.toBeNull(); + expect(content).not.toBeNull(); + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 240, + }); + Object.defineProperty(content, "scrollHeight", { + configurable: true, + value: 120, + }); + act(() => window.dispatchEvent(new Event("resize"))); + + expect(viewport?.className).toContain("max-h-[60dvh]"); + expect( + screen.queryByRole("navigation", { name: "Skill content pagination" }), + ).toBeNull(); + }); + + it("pages through long skill content with first and last page controls", () => { + const { container } = renderSkill(["/skills/writing-voice/SKILL.md"]); + const viewport = container.querySelector( + "[data-skill-content-viewport]", + ); + const content = container.querySelector( + "[data-skill-content-pages]", + ); + expect(viewport).not.toBeNull(); + expect(content).not.toBeNull(); + + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 240, + }); + Object.defineProperty(content, "scrollHeight", { + configurable: true, + value: 720, + }); + act(() => window.dispatchEvent(new Event("resize"))); + + const pagination = screen.getByRole("navigation", { + name: "Skill content pagination", + }); + const previous = screen.getByRole("button", { name: /Previous/ }); + const next = screen.getByRole("button", { name: /Next/ }); + expect(pagination.textContent).toContain("Page 1 of 3"); + expect(previous.getAttribute("disabled")).not.toBeNull(); + expect(next.getAttribute("disabled")).toBeNull(); + + fireEvent.click(next); + expect(pagination.textContent).toContain("Page 2 of 3"); + expect(content?.style.transform).toBe("translateY(-240px)"); + + fireEvent.click(next); + expect(pagination.textContent).toContain("Page 3 of 3"); + expect(previous.getAttribute("disabled")).toBeNull(); + expect(next.getAttribute("disabled")).not.toBeNull(); + + Object.defineProperty(viewport, "clientHeight", { + configurable: true, + value: 180, + }); + act(() => window.dispatchEvent(new Event("resize"))); + expect(pagination.textContent).toContain("Page 3 of 4"); + expect(next.getAttribute("disabled")).toBeNull(); + + fireEvent.click(next); + expect(pagination.textContent).toContain("Page 4 of 4"); + expect(content?.style.transform).toBe("translateY(-540px)"); + expect(next.getAttribute("disabled")).not.toBeNull(); + }); }); const AUTOMATION: AutomationResponse = { diff --git a/packages/shared-ui/src/components/ui/resource-pagination.tsx b/packages/shared-ui/src/components/ui/resource-pagination.tsx index fd20d1005d..e6b38c44b6 100644 --- a/packages/shared-ui/src/components/ui/resource-pagination.tsx +++ b/packages/shared-ui/src/components/ui/resource-pagination.tsx @@ -213,6 +213,7 @@ export function ResourcePagination({ onPageChange, scrollTargetId, summary, + ariaLabel = "Results pagination", }: { page: number; pageSize: number; @@ -221,6 +222,7 @@ export function ResourcePagination({ onPageChange: (page: number) => void; scrollTargetId?: string; summary?: ReactNode; + ariaLabel?: string; }) { const pageCount = Math.max(1, Math.ceil(total / pageSize)); if (total <= pageSize) return null; @@ -235,7 +237,7 @@ export function ResourcePagination({ return (