mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 09:19:19 +00:00
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:
parent
dc2565e3fe
commit
727588ab52
3 changed files with 144 additions and 92 deletions
|
|
@ -15,7 +15,7 @@ type CompDocDocumentFrameProps = {
|
||||||
sections: readonly ReactNode[];
|
sections: readonly ReactNode[];
|
||||||
/** Greyed-out pages, as for a document marked not required. */
|
/** Greyed-out pages, as for a document marked not required. */
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
/** Rendered over the preview, outside the dimmed layer. */
|
/** Stamped over the first sheet, outside the dimmed layer. */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
/** Page being read and page count, updated on layout and on scroll. */
|
/** Page being read and page count, updated on layout and on scroll. */
|
||||||
onPageChange?: (position: PagePosition) => void;
|
onPageChange?: (position: PagePosition) => void;
|
||||||
|
|
@ -39,85 +39,83 @@ export function CompDocDocumentFrame({
|
||||||
useCompDocPagination(sections.length, onPageChange);
|
useCompDocPagination(sections.length, onPageChange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div
|
||||||
<div
|
ref={scrollerRef}
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
onScroll={onScroll}
|
||||||
style={{
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
opacity: dimmed ? 0.5 : 1,
|
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
|
||||||
filter: dimmed ? "grayscale(0.4)" : "none",
|
data-testid="comp-doc-document"
|
||||||
}}
|
>
|
||||||
>
|
<div ref={sheetsRef} className="relative">
|
||||||
<div
|
{pages.map((blockIndices, pageIndex) => (
|
||||||
ref={scrollerRef}
|
<div
|
||||||
onScroll={onScroll}
|
key={pageIndex}
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
ref={(el) => {
|
||||||
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
|
pageRefs.current[pageIndex] = el;
|
||||||
data-testid="comp-doc-document"
|
}}
|
||||||
>
|
data-testid="comp-doc-page"
|
||||||
<div ref={sheetsRef} className="relative">
|
className="relative flex flex-col break-words"
|
||||||
{pages.map((blockIndices, pageIndex) => (
|
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
|
<div
|
||||||
key={pageIndex}
|
data-testid="comp-doc-page-top-bar"
|
||||||
ref={(el) => {
|
|
||||||
pageRefs.current[pageIndex] = el;
|
|
||||||
}}
|
|
||||||
data-testid="comp-doc-page"
|
|
||||||
className="relative flex flex-col break-words"
|
|
||||||
style={{
|
style={{
|
||||||
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
|
height: PAGE_TOP_BAR_PX,
|
||||||
minHeight: pageHeight,
|
flexShrink: 0,
|
||||||
background: "#ffffff",
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
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
|
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
|
||||||
data-testid="comp-doc-page-top-bar"
|
{pageIndex === 0 && (
|
||||||
style={{
|
<div ref={headerRef} style={flowRoot}>
|
||||||
height: PAGE_TOP_BAR_PX,
|
{header}
|
||||||
flexShrink: 0,
|
</div>
|
||||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
)}
|
||||||
}}
|
{blockIndices.map((blockIndex) => (
|
||||||
/>
|
<div
|
||||||
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
|
key={blockIndex}
|
||||||
{pageIndex === 0 && (
|
ref={(el) => {
|
||||||
<div ref={headerRef} style={flowRoot}>
|
blockRefs.current[blockIndex] = el;
|
||||||
{header}
|
}}
|
||||||
</div>
|
style={flowRoot}
|
||||||
)}
|
>
|
||||||
{blockIndices.map((blockIndex) => (
|
{sections[blockIndex]}
|
||||||
<div
|
</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>
|
</div>
|
||||||
))}
|
<div
|
||||||
</div>
|
data-testid="comp-doc-page-bottom-bar"
|
||||||
</div>
|
style={{
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,23 +63,16 @@ export function useCompDocPagination(
|
||||||
measure();
|
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 onPageChangeRef = useRef(onPageChange);
|
||||||
|
const totalRef = useRef(pages.length);
|
||||||
const lastReported = useRef<PagePosition | null>(null);
|
const lastReported = useRef<PagePosition | null>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
onPageChangeRef.current = onPageChange;
|
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 tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
|
||||||
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
|
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
|
||||||
const last = lastReported.current;
|
const last = lastReported.current;
|
||||||
|
|
@ -88,12 +81,23 @@ export function useCompDocPagination(
|
||||||
onPageChangeRef.current?.({ current, total });
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
report(total);
|
report();
|
||||||
}, [report, total]);
|
});
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
pages,
|
pages,
|
||||||
|
|
@ -103,6 +107,6 @@ export function useCompDocPagination(
|
||||||
headerRef,
|
headerRef,
|
||||||
blockRefs,
|
blockRefs,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
onScroll,
|
onScroll: report,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const SHEET_WIDTH = 420;
|
||||||
const LETTERHEAD_HEIGHT = 200;
|
const LETTERHEAD_HEIGHT = 200;
|
||||||
const BLOCK_HEIGHT = 300;
|
const BLOCK_HEIGHT = 300;
|
||||||
const SHEET_STRIDE = 614;
|
const SHEET_STRIDE = 614;
|
||||||
|
let sheetStride = SHEET_STRIDE;
|
||||||
|
|
||||||
function isLetterhead(el: HTMLElement) {
|
function isLetterhead(el: HTMLElement) {
|
||||||
return el.querySelector('img[alt="Seahaven Industries"]') !== null;
|
return el.querySelector('img[alt="Seahaven Industries"]') !== null;
|
||||||
|
|
@ -33,12 +34,13 @@ function stubRect(this: HTMLElement): DOMRect {
|
||||||
const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
|
const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sheetStride = SHEET_STRIDE;
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect);
|
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect);
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get(this: HTMLElement) {
|
get(this: HTMLElement) {
|
||||||
if (this.dataset.testid !== "comp-doc-page") return 0;
|
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(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
|
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 = {
|
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", () => {
|
it("keeps a document without comments on a single sheet", () => {
|
||||||
render(
|
render(
|
||||||
<CompDocPreview
|
<CompDocPreview
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue