From 9a4cc751158693dd027b04154e7747182ae89b0e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 22:29:11 +0200 Subject: [PATCH] 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) --- src/app/components.css | 18 +++++ src/components/StatusSelect.tsx | 107 ++++++++++++------------- src/components/TopNav.tsx | 101 ++++++++++-------------- src/components/ui/Modal.tsx | 28 +++++-- src/components/ui/Popover.tsx | 135 ++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 124 deletions(-) create mode 100644 src/components/ui/Popover.tsx diff --git a/src/app/components.css b/src/app/components.css index 8903897..c8f6f67 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -69,6 +69,24 @@ opacity: 0.85; } + /** + * Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge. + * + * Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal + * was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter + * durch. backdrop-filter hilft hier nicht: liegt das Element in einem + * Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die + * Weichzeichnung wirkungslos. + */ + .popover { + background: rgba(9, 11, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 0.9rem; + box-shadow: + 0 1px 0 0 rgba(255, 255, 255, 0.07) inset, + 0 28px 70px -24px rgba(0, 0, 0, 0.95); + } + /* ── Text & Akzente ───────────────────────────────────────── */ .text-gradient { diff --git a/src/components/StatusSelect.tsx b/src/components/StatusSelect.tsx index a5af657..db591fd 100644 --- a/src/components/StatusSelect.tsx +++ b/src/components/StatusSelect.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, ChevronDown, Loader2 } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; +import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status"; import { cn } from "@/lib/utils"; @@ -22,6 +23,7 @@ export function StatusSelect({ onChanged, }: StatusSelectProps) { const router = useRouter(); + const triggerRef = useRef(null); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(status); const [pending, startTransition] = useTransition(); @@ -54,10 +56,12 @@ export function StatusSelect({ } return ( -
+ <> - {open ? ( - <> - - ); - })} -
- - ) : null} - + setOpen(false)} + role="listbox" + width={264} + > + {STATUS_ORDER.map((value) => { + const option = STATUS_META[value]; + const active = value === current; + return ( + + ); + })} + + ); } diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 0a1ceeb..d0c4066 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -12,6 +12,7 @@ import { Sparkles, UserRound, } from "lucide-react"; +import { Popover } from "@/components/ui/Popover"; import type { SessionUser } from "@/lib/auth/session"; import { cn } from "@/lib/utils"; @@ -85,23 +86,7 @@ function UserMenu({ user }: { user: SessionUser }) { const router = useRouter(); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (!open) return; - function onPointerDown(event: MouseEvent) { - if (!containerRef.current?.contains(event.target as Node)) setOpen(false); - } - function onKey(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + const triggerRef = useRef(null); async function logout() { setBusy(true); @@ -115,8 +100,9 @@ function UserMenu({ user }: { user: SessionUser }) { const initials = label.slice(0, 2).toUpperCase(); return ( -
+ <> - {open ? ( -
-
-

{label}

-

- @{user.username} - {user.role === "ADMIN" ? " · Administrator" : ""} -

-
+ setOpen(false)} width={240}> +
+

{label}

+

+ @{user.username} + {user.role === "ADMIN" ? " · Administrator" : ""} +

+
+ setOpen(false)} + className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + > + + Mein Konto + + + {user.role === "ADMIN" ? ( setOpen(false)} - className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" > - - Mein Konto + + Admin-Einstellungen + ) : null} - {user.role === "ADMIN" ? ( - setOpen(false)} - className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" - > - - Admin-Einstellungen - - ) : null} - - -
- ) : null} -
+ + + ); } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 89a1bc5..8877c14 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -14,6 +15,14 @@ interface ModalProps { width?: "md" | "lg" | "xl"; } +/** + * Dialog per Portal am body. + * + * Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten + * heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen + * Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am + * Fenster. + */ export function Modal({ open, title, @@ -23,6 +32,10 @@ export function Modal({ footer, width = "lg", }: ModalProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { @@ -37,22 +50,22 @@ export function Modal({ }; }, [open, onClose]); - if (!open) return null; + if (!mounted || !open) return null; - return ( -
+ return createPortal( +
-
+ , + document.body, ); } diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx new file mode 100644 index 0000000..243597a --- /dev/null +++ b/src/components/ui/Popover.tsx @@ -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; + 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(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( +
+ {children} +
, + document.body, + ); +}