fix(dashboard): topbar dropdowns no longer sit under module chrome

The Offline-notifications popup was covered by Smart Gallery's toolbar. Raising
its z-index could not have worked: .dash-topbar is `position: sticky; z-index: 20`,
which opens a stacking context, so .tm-menu-pop's z-index: 30 only ranked inside
the topbar. Against module content the whole topbar competed at 20 — and the
vendored photo-gallery SDK stacks up to 1400 (its own README documents this).

Adds AnchoredPopover, which portals to <body> — out of the topbar's stacking
context entirely — positions from the trigger's viewport rect, clamps to the
window edges, and closes on scroll/resize/Escape/outside-click. Applied to the
notification bell AND the user menu, which had the identical trap and would have
been reported next.

Portalling rather than z-index escalation is the fix that survives a module
raising its own z-index later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:22:08 +05:30
parent 9a619e0be9
commit ab78c739e0
3 changed files with 95 additions and 17 deletions
@@ -0,0 +1,79 @@
"use client";
// A dropdown that renders into <body> 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 <body> 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<HTMLElement | null>;
open: boolean;
onClose: () => void;
children: ReactNode;
width?: number;
}) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const panelRef = useRef<HTMLDivElement>(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(
<>
<div style={{ position: "fixed", inset: 0, zIndex: POPOVER_Z }} onMouseDown={onClose} />
<div
ref={panelRef}
role="menu"
className="tm-menu-pop"
style={{ position: "fixed", top: pos.top, left: pos.left, width, zIndex: POPOVER_Z + 1, right: "auto" }}
onMouseDown={(e) => e.stopPropagation()}
>
{children}
</div>
</>,
document.body,
);
}
@@ -4,13 +4,15 @@
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't // 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). // available (demo mode, or a browser without ServiceWorker/PushManager).
import { useState } from "react"; import { useRef, useState } from "react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { AnchoredPopover } from "./anchored-popover";
import { usePushNotifications } from "@/lib/push-notifications"; import { usePushNotifications } from "@/lib/push-notifications";
export function NotificationBell() { export function NotificationBell() {
const push = usePushNotifications(); const push = usePushNotifications();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
if (!push.supported) return null; if (!push.supported) return null;
@@ -19,6 +21,7 @@ export function NotificationBell() {
return ( return (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button <button
ref={btnRef}
className="ic-btn" className="ic-btn"
aria-label="Notifications" aria-label="Notifications"
aria-haspopup="menu" aria-haspopup="menu"
@@ -40,10 +43,8 @@ export function NotificationBell() {
}} }}
/> />
</button> </button>
{open && ( <AnchoredPopover anchorRef={btnRef} open={open} onClose={() => setOpen(false)} width={260}>
<> <div style={{ padding: 14 }}>
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div> <div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}> <p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
{push.subscribed {push.subscribed
@@ -67,8 +68,7 @@ export function NotificationBell() {
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>} {push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
</div> </div>
</> </AnchoredPopover>
)}
</div> </div>
); );
} }
+9 -10
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react"; import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react"; import { useAuth } from "@abe-kap/appshell-sdk/react";
import { GlobalSearch } from "./global-search"; import { GlobalSearch } from "./global-search";
import { NotificationBell } from "./notification-bell"; import { NotificationBell } from "./notification-bell";
import { AnchoredPopover } from "./anchored-popover";
import { user } from "./account-data"; import { user } from "./account-data";
function initialsOf(name: string): string { function initialsOf(name: string): string {
@@ -18,6 +19,7 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme
const router = useRouter(); const router = useRouter();
const { user: me, logout, context } = useAuth(); const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const userBtnRef = useRef<HTMLButtonElement>(null);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : ""; const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name; const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials; const initials = me ? initialsOf(me.displayName) : user.initials;
@@ -42,7 +44,7 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme
</button> </button>
<NotificationBell /> <NotificationBell />
<div className="top-user-wrap" style={{ position: "relative" }}> <div className="top-user-wrap" style={{ position: "relative" }}>
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}> <button ref={userBtnRef} className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span> <span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ textAlign: "left", minWidth: 0 }}> <div style={{ textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div> <div className="nm">{name}</div>
@@ -50,14 +52,11 @@ export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme
</div> </div>
<ChevronDown size={16} /> <ChevronDown size={16} />
</button> </button>
{menuOpen && ( {/* 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). */}
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} /> <AnchoredPopover anchorRef={userBtnRef} open={menuOpen} onClose={() => setMenuOpen(false)} width={190}>
<div className="tm-menu-pop" role="menu"> <button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button> </AnchoredPopover>
</div>
</>
)}
</div> </div>
</div> </div>
</header> </header>