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:
@@ -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. */}
|
||||
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S7: locks the container-query classes on the shared search box that fix the narrow-header
|
||||
// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column
|
||||
// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these
|
||||
// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import SearchBar from "./SearchBar";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
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(
|
||||
<SearchBar value="" onChange={() => {}} 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");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
|
||||
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
|
||||
Reference in New Issue
Block a user