diff --git a/src/components/dashboard/anchored-popover.tsx b/src/components/dashboard/anchored-popover.tsx new file mode 100644 index 0000000..d199a19 --- /dev/null +++ b/src/components/dashboard/anchored-popover.tsx @@ -0,0 +1,79 @@ +"use client"; + +// A dropdown that renders into instead of next to its trigger. +// +// Why: `.dash-topbar` is `position: sticky; z-index: 20`, which opens a stacking context — any +// popover inside it is trapped at the topbar's level no matter how high its own z-index goes. +// Module content can legitimately stack above that (the vendored photo-gallery SDK reaches 1400), +// so an in-topbar dropdown gets covered. Portalling to takes the popover out of that +// context entirely, which is the only fix that survives a module raising its own z-index later. +// +// The panel is positioned from the trigger's viewport rect and closes on scroll/resize rather than +// tracking it — a menu that follows the page while you scroll reads as broken. + +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { createPortal } from "react-dom"; + +/** Above the vendored gallery SDK's maximum (1400) so module chrome can never cover a global menu. */ +const POPOVER_Z = 2000; + +export function AnchoredPopover({ + anchorRef, + open, + onClose, + children, + width = 260, +}: { + anchorRef: RefObject; + open: boolean; + onClose: () => void; + children: ReactNode; + width?: number; +}) { + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const panelRef = useRef(null); + + // Measure before paint so the panel never flashes at the wrong spot. + useLayoutEffect(() => { + if (!open) return; + const el = anchorRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + // Right-align to the trigger, clamped so it can't run off either edge. + const left = Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8)); + setPos({ top: r.bottom + 8, left }); + }, [open, anchorRef, width]); + + useEffect(() => { + if (!open) return; + const close = () => onClose(); + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + // `true` = capture, so a scroll inside any nested container closes it too. + window.addEventListener("scroll", close, true); + window.addEventListener("resize", close); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("scroll", close, true); + window.removeEventListener("resize", close); + window.removeEventListener("keydown", onKey); + }; + }, [open, onClose]); + + if (!open || !pos || typeof document === "undefined") return null; + + return createPortal( + <> +
+
e.stopPropagation()} + > + {children} +
+ , + document.body, + ); +} diff --git a/src/components/dashboard/notification-bell.tsx b/src/components/dashboard/notification-bell.tsx index b76973d..d13dffd 100644 --- a/src/components/dashboard/notification-bell.tsx +++ b/src/components/dashboard/notification-bell.tsx @@ -4,13 +4,15 @@ // for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't // available (demo mode, or a browser without ServiceWorker/PushManager). -import { useState } from "react"; +import { useRef, useState } from "react"; import { Icon } from "./ui"; +import { AnchoredPopover } from "./anchored-popover"; import { usePushNotifications } from "@/lib/push-notifications"; export function NotificationBell() { const push = usePushNotifications(); const [open, setOpen] = useState(false); + const btnRef = useRef(null); if (!push.supported) return null; @@ -19,6 +21,7 @@ export function NotificationBell() { return (
- {open && ( - <> -
setOpen(false)} /> -
+ setOpen(false)} width={260}> +
Offline notifications

{push.subscribed @@ -67,8 +68,7 @@ export function NotificationBell() { {push.error &&

{push.error}

}
- - )} +
); } diff --git a/src/components/dashboard/topbar.tsx b/src/components/dashboard/topbar.tsx index 0fd3af8..1b1622d 100644 --- a/src/components/dashboard/topbar.tsx +++ b/src/components/dashboard/topbar.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Sun, Moon, ChevronDown, LogOut } from "lucide-react"; import { useAuth } from "@abe-kap/appshell-sdk/react"; import { GlobalSearch } from "./global-search"; import { NotificationBell } from "./notification-bell"; +import { AnchoredPopover } from "./anchored-popover"; import { user } from "./account-data"; function initialsOf(name: string): string { @@ -18,6 +19,7 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme const router = useRouter(); const { user: me, logout, context } = useAuth(); const [menuOpen, setMenuOpen] = useState(false); + const userBtnRef = useRef(null); const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : ""; const name = me?.displayName || user.name; const initials = me ? initialsOf(me.displayName) : user.initials; @@ -42,7 +44,7 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme
- - {menuOpen && ( - <> -
setMenuOpen(false)} /> -
- -
- - )} + {/* Portalled for the same reason as the bell: the sticky topbar traps any in-place + dropdown below module chrome (the gallery SDK stacks up to 1400). */} + setMenuOpen(false)} width={190}> + +