refactor(header): move --hdr-h measurement into PageShell (S7 review)
Addresses the S7 mobile-fix code-review findings: - (altitude) The header-height→content-pad contract lived across two files with an implicit DOM coupling: Header measured itself and wrote --hdr-h onto `el.parentElement`, assuming PageShell rendered it as a direct child of the padded column. Move the whole contract into PageShell, which owns BOTH the header slot and the padded content wrapper: Header is now forwardRef and PageShell attaches a ref (via cloneElement), measures the header with a ResizeObserver, and writes --hdr-h straight onto the content wrapper that consumes it — no parent assumption, producer and consumer together. - (simplification) The `+ 8` gap is now documented as the header's own gap-y-2 row gap reused below it, not a bare magic number. - (test-coverage) PageShell.dom.test.tsx: stub ResizeObserver + the header's offset metrics and assert --hdr-h is published onto the content wrapper (and absent with no header). Re-verified live at 440 (mobile, no overlap) and desktop (one row). FE gate: tsc / knip / lint clean, 220 vitest pass.
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import PageScroller from "./PageScroller";
|
||||
|
||||
@@ -52,10 +63,39 @@ export default function PageShell({
|
||||
fadeTop?: boolean;
|
||||
}) {
|
||||
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
|
||||
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
|
||||
// would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto
|
||||
// its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so
|
||||
// the two live together with no assumption about the header's DOM parent.
|
||||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const h = headerRef.current;
|
||||
const c = contentRef.current;
|
||||
if (!h || !c) return;
|
||||
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
|
||||
const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`);
|
||||
apply();
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(h);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
c.style.removeProperty("--hdr-h");
|
||||
};
|
||||
}, []);
|
||||
const measuredHeader = isValidElement(header)
|
||||
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
|
||||
ref: headerRef,
|
||||
})
|
||||
: header;
|
||||
return (
|
||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||
{header}
|
||||
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
>
|
||||
{banners}
|
||||
<div ref={setToolbarSlot} className="shrink-0" />
|
||||
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
||||
|
||||
Reference in New Issue
Block a user