From 3575237382c59e56cb465c5f5ac98d9b827081e1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 17 Aug 2026 21:31:22 +0200 Subject: [PATCH] fix(header): dynamic --hdr-h so a wrapped mobile header can't overlap content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 5 +++- frontend/src/components/Header.tsx | 28 +++++++++++++++++-- .../src/components/SearchBar.dom.test.tsx | 28 +++++++++++++++++++ frontend/src/components/SearchBar.tsx | 11 ++++---- 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/SearchBar.dom.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69bbe884..c55b675c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -353,7 +353,10 @@ export default function App() { )} {/* `@container/appmain`: the floating header + its search box adapt to THIS column's width (which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint - can't see the sidebar-driven narrowing (E5 S7). */} + can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies + `contain: layout`, making this column a containing block for `position: fixed` descendants + too — safe today because floating-ui menus render via FloatingPortal (to document.body) and + the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
{channelView ? ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 3d2498a1..f607e265 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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(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. -
+
{ + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +describe("SearchBar responsive wrapper (E5 S7)", () => { + it("carries the container-query classes that make it wrap instead of collapsing", () => { + const { container } = render( + {}} placeholder="Search…" />, + ); + const cls = (container.firstElementChild as HTMLElement).className; + // A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0: + expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]"); + // On a narrow column it takes a full line of its own and sorts last (small pills share the row above): + expect(cls).toContain("@max-[640px]/appmain:basis-full"); + expect(cls).toContain("@max-[640px]/appmain:order-last"); + }); +}); diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 364bee6a..dd8b7a0d 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -55,12 +55,13 @@ export default function SearchBar({ const fire = () => { if (onGo && !goDisabled) onGo(); }; + // Responsive rules on the SHARED search box (every module's header, not just Plex), keyed on the + // content column's width via `@container/appmain` (E5 S7): `min-w-0` by default so it never forces + // overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes + // the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on + // a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the + // small pills (module name + Plex scope toggle / feed sync chip) share the line above it. return ( - // `min-w-0` by default so it never forces overflow on a truly tiny column; once the content - // column (@container/appmain) has room (≥420px) enforce a usable floor so the box WRAPS to its - // own line via the header's flex-wrap instead of shrinking the input to ~0. On a narrow column - // (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the small pills - // (module name + Plex scope toggle) share the line above it — a tidy two-row header (E5 S7).
{leading ?? }