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 ?? }