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:
Marco
2026-08-01 06:17:09 +00:00
parent 5c4a0e011d
commit e49dacf057
4 changed files with 90 additions and 63 deletions
+40 -33
View File
@@ -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()}>