fix(comp-doc): keep the page pill current on re-layout and stamp NOT REQUIRED on the sheet

The page being read is re-derived after every render and every observed
resize, not only when the page count changes. The NOT REQUIRED stamp is
rendered inside the first sheet, above its greyed-out content, instead of
floating over the scroll panel.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 18:49:13 -03:00
parent dc2565e3fe
commit 727588ab52
3 changed files with 144 additions and 92 deletions

View file

@ -15,7 +15,7 @@ type CompDocDocumentFrameProps = {
sections: readonly ReactNode[];
/** Greyed-out pages, as for a document marked not required. */
dimmed?: boolean;
/** Rendered over the preview, outside the dimmed layer. */
/** Stamped over the first sheet, outside the dimmed layer. */
overlay?: ReactNode;
/** Page being read and page count, updated on layout and on scroll. */
onPageChange?: (position: PagePosition) => void;
@ -39,85 +39,83 @@ export function CompDocDocumentFrame({
useCompDocPagination(sections.length, onPageChange);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
className="flex min-h-0 flex-1 flex-col"
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
ref={scrollerRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
data-testid="comp-doc-document"
>
<div ref={sheetsRef} className="relative">
{pages.map((blockIndices, pageIndex) => (
<div
ref={scrollerRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
data-testid="comp-doc-document"
>
<div ref={sheetsRef} className="relative">
{pages.map((blockIndices, pageIndex) => (
<div
key={pageIndex}
ref={(el) => {
pageRefs.current[pageIndex] = el;
}}
data-testid="comp-doc-page"
className="relative flex flex-col break-words"
style={{
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
minHeight: pageHeight,
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
className="flex flex-1 flex-col"
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
key={pageIndex}
ref={(el) => {
pageRefs.current[pageIndex] = el;
}}
data-testid="comp-doc-page"
className="relative flex flex-col break-words"
data-testid="comp-doc-page-top-bar"
style={{
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
minHeight: pageHeight,
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
height: PAGE_TOP_BAR_PX,
flexShrink: 0,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
>
<div
data-testid="comp-doc-page-top-bar"
style={{
height: PAGE_TOP_BAR_PX,
flexShrink: 0,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
{pageIndex === 0 && (
<div ref={headerRef} style={flowRoot}>
{header}
</div>
)}
{blockIndices.map((blockIndex) => (
<div
key={blockIndex}
ref={(el) => {
blockRefs.current[blockIndex] = el;
}}
style={flowRoot}
>
{sections[blockIndex]}
</div>
))}
</div>
<div
data-testid="comp-doc-page-bottom-bar"
style={{
height: PAGE_BOTTOM_BAR_PX,
flexShrink: 0,
marginTop: "auto",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
/>
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
{pageIndex === 0 && (
<div ref={headerRef} style={flowRoot}>
{header}
</div>
)}
{blockIndices.map((blockIndex) => (
<div
key={blockIndex}
ref={(el) => {
blockRefs.current[blockIndex] = el;
}}
style={flowRoot}
>
{sections[blockIndex]}
</div>
))}
</div>
))}
</div>
</div>
</div>
<div
data-testid="comp-doc-page-bottom-bar"
style={{
height: PAGE_BOTTOM_BAR_PX,
flexShrink: 0,
marginTop: "auto",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{overlay}
{pageIndex === 0 && overlay}
</div>
))}
</div>
</div>
);
}

View file

@ -63,23 +63,16 @@ export function useCompDocPagination(
measure();
});
// Width changes, late font/image loads and reflow do not re-render; observe them.
const layoutKey = pages.map((page) => page.join(",")).join("|");
useEffect(() => {
const observer = new ResizeObserver(() => measure());
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
(el) => el && observer.observe(el),
);
return () => observer.disconnect();
}, [measure, blockCount, layoutKey]);
const onPageChangeRef = useRef(onPageChange);
const totalRef = useRef(pages.length);
const lastReported = useRef<PagePosition | null>(null);
useLayoutEffect(() => {
onPageChangeRef.current = onPageChange;
totalRef.current = pages.length;
});
const report = useCallback((total: number) => {
const report = useCallback(() => {
const total = totalRef.current;
const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
const last = lastReported.current;
@ -88,12 +81,23 @@ export function useCompDocPagination(
onPageChangeRef.current?.({ current, total });
}, []);
const total = pages.length;
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
useLayoutEffect(() => {
report(total);
}, [report, total]);
report();
});
const onScroll = useCallback(() => report(total), [report, total]);
// Width changes, late font/image loads and reflow do not re-render; observe them.
const layoutKey = pages.map((page) => page.join(",")).join("|");
useEffect(() => {
const observer = new ResizeObserver(() => {
measure();
report();
});
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
(el) => el && observer.observe(el),
);
return () => observer.disconnect();
}, [measure, report, blockCount, layoutKey]);
return {
pages,
@ -103,6 +107,6 @@ export function useCompDocPagination(
headerRef,
blockRefs,
pageRefs,
onScroll,
onScroll: report,
};
}

View file

@ -15,6 +15,7 @@ const SHEET_WIDTH = 420;
const LETTERHEAD_HEIGHT = 200;
const BLOCK_HEIGHT = 300;
const SHEET_STRIDE = 614;
let sheetStride = SHEET_STRIDE;
function isLetterhead(el: HTMLElement) {
return el.querySelector('img[alt="Seahaven Industries"]') !== null;
@ -33,12 +34,13 @@ function stubRect(this: HTMLElement): DOMRect {
const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
beforeEach(() => {
sheetStride = SHEET_STRIDE;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect);
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
if (this.dataset.testid !== "comp-doc-page") return 0;
return Array.from(this.parentElement?.children ?? []).indexOf(this) * SHEET_STRIDE;
return Array.from(this.parentElement?.children ?? []).indexOf(this) * sheetStride;
},
});
});
@ -155,6 +157,33 @@ describe("Completion template preview pagination", () => {
expect(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument();
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
});
it("re-reads the page being read when an edit moves the sheets but keeps the page count", () => {
const { rerender } = render(
<CompletionTemplatePreview
values={{ name: "Edits", extraSafetyNote: null, procedures }}
isEditing
/>,
);
scrollDocumentTo(SHEET_STRIDE + 5);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3");
// The edit makes sheet 1 taller: sheet 2 now starts below the reader's position.
sheetStride = 800;
rerender(
<CompletionTemplatePreview
values={{
name: "Edits",
extraSafetyNote: null,
procedures: [{ ...procedures[0], description: "Longer text" }, ...procedures.slice(1)],
}}
isEditing
/>,
);
expect(sheets()).toHaveLength(3);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
});
});
const site: CompDocSiteInfo = {
@ -206,6 +235,27 @@ describe("Work order completion document preview pagination", () => {
);
});
it("stamps NOT REQUIRED on the first sheet, above the greyed-out content", () => {
render(
<CompDocPreview
row={row}
site={site}
notes="Replaced the filter on unit 3."
generatedAt=""
isGenerated={false}
isNotRequired
/>,
);
const [first, second] = sheets();
const stamp = within(first).getByText(/NOT REQUIRED/);
expect(stamp.closest('[style*="opacity: 0.5"]')).toBeNull();
expect(within(first).getByTestId("comp-doc-page-top-bar").parentElement).toHaveStyle({
opacity: "0.5",
});
expect(within(second).queryByText(/NOT REQUIRED/)).not.toBeInTheDocument();
});
it("keeps a document without comments on a single sheet", () => {
render(
<CompDocPreview