fix(ui): Menues und Dialoge ueber Portal ausgeben

Das Statusmenue auf der Projektseite wurde von den Schaltflaechen
"Bearbeiten" und "Loeschen" ueberdeckt, war unten abgeschnitten und der
Fortschrittswert schien hindurch. Drei Ursachen, alle mit derselben Wurzel:

1. Die Karten setzen overflow-hidden fuer den Akzentstreifen und den
   Farbverlaufsrand. Ein absolut positioniertes Kind wird daran
   abgeschnitten.
2. .glass setzt backdrop-filter, die Projektkarte bekommt beim Hovern
   zusaetzlich ein transform. Beides erzeugt einen Stacking-Context: das
   z-index des Menues galt nur noch innerhalb der Karte und verlor gegen
   spaeter gezeichnete Geschwister. Aus demselben Grund bezog sich das
   "position: fixed" der Klickflaeche zum Schliessen auf die Karte statt
   auf das Fenster - ein Klick daneben schloss das Menue nicht.
3. .glass hat 3,5 % Deckkraft. Als Kartenflaeche gut, als Overlay
   unbrauchbar. Der eigene backdrop-filter half nicht, weil er innerhalb
   eines Vorfahren mit backdrop-filter wirkungslos bleibt.

Behoben durch eine neue Popover-Komponente, die per createPortal am body
haengt, ihre Position aus dem Ankerelement berechnet und beim Scrollen und
bei Groessenaenderungen nachfuehrt. Reicht der Platz nach unten nicht, klappt
sie nach oben. Dazu die Klasse .popover mit nahezu deckendem Hintergrund.

Betrifft Statusauswahl, Benutzermenue und - aus demselben Grund - den
Modal-Dialog: der wird aus Karten heraus geoeffnet und haette sich sonst an
der Karte statt am Fenster ausgerichtet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 22:29:11 +02:00
co-authored by Claude Opus 5
parent ec7cb02a9a
commit 9a4cc75115
5 changed files with 265 additions and 124 deletions
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* Schwebendes Menue, das per Portal am body haengt.
*
* Warum nicht einfach ein absolut positioniertes Kind?
*
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
* selbst position: fixed bezieht sich auf die Karte statt auf das
* Fenster.
*
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
*/
export function Popover({
anchorRef,
open,
onClose,
children,
align = "end",
width = 240,
className,
labelledBy,
role = "menu",
}: {
anchorRef: React.RefObject<HTMLElement | null>;
open: boolean;
onClose: () => void;
children: React.ReactNode;
/** "end" richtet die rechte Kante am Anker aus. */
align?: "start" | "end";
width?: number;
className?: string;
labelledBy?: string;
role?: "menu" | "listbox" | "dialog";
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
useEffect(() => setMounted(true), []);
const place = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const margin = 8;
const gap = 6;
let left = align === "end" ? rect.right - width : rect.left;
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let top = rect.bottom + gap;
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
const fitsAbove = rect.top - gap - panelHeight >= margin;
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
setPosition({ top, left });
}, [anchorRef, align, width]);
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
place();
}, [open, place]);
useEffect(() => {
if (!open) return;
// capture: true, damit auch das Scrollen innerer Container ankommt.
const onScroll = () => place();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (panelRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open, place, onClose, anchorRef]);
if (!mounted || !open) return null;
return createPortal(
<div
ref={panelRef}
role={role}
aria-labelledby={labelledBy}
style={{
position: "fixed",
top: position?.top ?? -9999,
left: position?.left ?? -9999,
width,
// Bis die Position steht, unsichtbar halten – sonst blitzt das
// Menue kurz an der falschen Stelle auf.
visibility: position ? "visible" : "hidden",
}}
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
>
{children}
</div>,
document.body,
);
}