fix(header): dynamic --hdr-h so a wrapped mobile header can't overlap content

The floating header pads the content below it by a FIXED `--hdr-h` (4.25rem,
sized for one row). On mobile the flex-wrap header grows to 2–3 rows, so the
content toolbar (sort / count / filter chips) slid up UNDER the header — the top
of the page was a pile of overlapping controls.

- Header.tsx: measure the real header box with a ResizeObserver and publish its
  height (offsetTop + offsetHeight + gap) as `--hdr-h` on the content column, so
  the content pad always tracks the actual (wrapped) header height at any width.
  Verified live at 440 (mobile) and desktop: the toolbar now sits cleanly below
  the header, no overlap; desktop unchanged (still ~4rem, one row).

Also addresses the S7 /code-review findings:
- App.tsx: note that `container-type: inline-size` makes the column a containing
  block for `position: fixed` descendants too (safe today — floating-ui menus
  body-portal, toasts are absolute).
- SearchBar.tsx: move the responsive-rules comment above `return` and note the
  rules are on the SHARED search box (every module, not just Plex).
- SearchBar.dom.test.tsx: lock the container-query classes (min-w floor +
  basis-full/order-last) so the wrap-not-collapse fix can't silently regress.

FE gate: tsc / knip / lint clean, 218 vitest pass.
This commit is contained in:
2026-08-17 21:31:22 +02:00
parent 14913a6447
commit 3575237382
4 changed files with 64 additions and 8 deletions
+26 -2
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import type { Me } from "../lib/api";
@@ -46,6 +46,27 @@ 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") {
@@ -94,7 +115,10 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// 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 className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2">
<div
ref={headerRef}
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
label={t(moduleLabelKey[page])}
labels={moduleLabels}