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,4 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Youtube } from "lucide-react";
|
||||
import type { Me } from "../lib/api";
|
||||
@@ -16,15 +16,18 @@ import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||
// sync chip at the right end.
|
||||
export default function Header({
|
||||
me,
|
||||
onYtSearch,
|
||||
}: {
|
||||
me: Me;
|
||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||
// which can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
}) {
|
||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
||||
// owns both the header slot and the padded content wrapper (E5 S7).
|
||||
const Header = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
me: Me;
|
||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||
// which can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
}
|
||||
>(function Header({ me, onYtSearch }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
@@ -46,27 +49,6 @@ export default function Header({
|
||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
// The header floats (absolute) over the content, which pads its top by `--hdr-h`. That default is
|
||||
// sized for a single row; on a narrow window (esp. mobile) the flex-wrap header grows to 2–3 rows,
|
||||
// so a FIXED pad would let the content toolbar (sort / count / filter chips) slide up under it.
|
||||
// Measure the real header box and publish its height as `--hdr-h` on the content column, so the pad
|
||||
// always tracks the actual (wrapped) header height at any width.
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = headerRef.current;
|
||||
const host = el?.parentElement; // the `relative` content column (PageShell renders a fragment)
|
||||
if (!el || !host) return;
|
||||
const apply = () =>
|
||||
host.style.setProperty("--hdr-h", `${el.offsetTop + el.offsetHeight + 8}px`);
|
||||
apply();
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
host.style.removeProperty("--hdr-h");
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Per-page SearchBar wiring — null for modules without a search.
|
||||
let search: ReactNode = null;
|
||||
if (page === "feed") {
|
||||
@@ -116,7 +98,7 @@ export default function Header({
|
||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||
<div
|
||||
ref={headerRef}
|
||||
ref={ref}
|
||||
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
>
|
||||
<ModuleName
|
||||
@@ -140,4 +122,6 @@ export default function Header({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Header;
|
||||
|
||||
Reference in New Issue
Block a user