Fix search overlay CSS trap and blog filter navigation race
SearchOverlay was rendered as a <header> descendant, so the header's conditional backdrop-blur-md (once scrolled) made it the containing block for the overlay's fixed positioning, clipping the opaque background to the header's height and letting page content show through underneath. Now rendered as a header sibling, same pattern already used for NewsletterModal. Blog category chips are now a client component gating navigation behind useTransition, disabling the chips while a navigation is pending so rapid clicks can't fire overlapping RSC navigations that commit out of order and briefly show an empty result.
This commit is contained in:
+2
-28
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { BlogCategoryFilter } from "../components/BlogCategoryFilter";
|
||||
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
|
||||
import { formatDate } from "../lib/format";
|
||||
|
||||
@@ -31,11 +32,6 @@ function distinctCategories(posts: { categories: string[] }[]): string[] {
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
function buildCategoryHref(active: string[], category: string): string {
|
||||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||||
}
|
||||
|
||||
export default async function BlogOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -93,29 +89,7 @@ export default async function BlogOverviewPage({
|
||||
under the description keeps them in view immediately, on
|
||||
every width, no scrolling past the photo needed. */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<Link
|
||||
key={category}
|
||||
href={buildCategoryHref(activeCategories, category)}
|
||||
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
|
||||
// on bg-base (#f8f5f1) is a ~2% lightness difference,
|
||||
// nearly invisible as a pill outline; a filled muted
|
||||
// background makes the chip read as a discrete control
|
||||
// regardless of the border's own low contrast.
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function buildCategoryHref(active: string[], category: string): string {
|
||||
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
|
||||
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
|
||||
}
|
||||
|
||||
// Client-side navigation (not a plain <Link>) specifically so `isPending`
|
||||
// can gate the chips: rapid clicks used to fire multiple concurrent RSC
|
||||
// navigations with no guarantee they'd commit in the order they were
|
||||
// requested, so a slower, stale navigation could land after a newer one and
|
||||
// briefly show the wrong (sometimes empty) result. Disabling the chips
|
||||
// while a navigation is in flight makes that ordering race impossible —
|
||||
// only one navigation can ever be outstanding at a time.
|
||||
export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{allCategories.map((category) => {
|
||||
const active = activeCategories.includes(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => startTransition(() => router.push(buildCategoryHref(activeCategories, category)))}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors disabled:opacity-60 disabled:pointer-events-none ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { useWishlist } from "../lib/useWishlist";
|
||||
import { SearchButton } from "./SearchOverlay";
|
||||
import { SearchButton, SearchOverlay } from "./SearchOverlay";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
@@ -302,6 +302,7 @@ export function Navbar({
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [newsletterOpen, setNewsletterOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -619,7 +620,7 @@ export function Navbar({
|
||||
separates this group from the CTA-buttons/hamburger group
|
||||
that follows. */}
|
||||
<div className="flex items-center">
|
||||
{searchEnabled && <SearchButton />}
|
||||
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
|
||||
<AccountLink />
|
||||
{wishlistEnabled && <WishlistLink />}
|
||||
<CartLink />
|
||||
@@ -813,6 +814,7 @@ export function Navbar({
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,41 +7,33 @@ import type { SearchResult } from "../api/search/route";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
export function SearchButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
|
||||
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
|
||||
// rendered as a <header> *sibling* instead of a descendant. Rendering it
|
||||
// inside <header> put it under the header's conditional `backdrop-blur-md`
|
||||
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
|
||||
// `backdrop-filter` makes its element a new containing block for
|
||||
// `position: fixed` descendants — the overlay's `fixed inset-0` then
|
||||
// resolved against the ~100px header instead of the viewport, clipping its
|
||||
// opaque background to that band while the input/results overflowed past
|
||||
// it, letting the page content underneath show through.
|
||||
export function SearchButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Suche öffnen"
|
||||
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && <SearchOverlay onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label="Suche öffnen"
|
||||
className="flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,8 +41,21 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
if (!open) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
@@ -74,6 +79,8 @@ function SearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const products = results.filter((r) => r.type === "product");
|
||||
const posts = results.filter((r) => r.type === "post");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
|
||||
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
Reference in New Issue
Block a user