From 6aea83bf17c4a9304765ddd4421b30a5db6fe0c0 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 5 Jun 2026 08:45:36 +0000 Subject: [PATCH] fix: render notification dropdown via portal for true always-on-top The notification dropdown was trapped inside .shell-header's stacking context (created by backdrop-filter). Even with z-index: 9999, it remained below any element with a higher root-level z-index such as modal overlays (1000), dialog overlays (1000), and fullscreen terminals (1000). - Render the dropdown via ReactDOM.createPortal into document.body so it escapes all parent stacking contexts. - Dynamically measure the bell button's bounding rect to position the dropdown correctly with position: fixed. - Update click-outside handler to also ignore clicks on the bell button itself. - Add window resize listener to keep dropdown aligned. - Change .notification-dropdown from position: absolute to fixed. Quality gates: tsc --noEmit (pass), build (pass) --- .../notification/notification-center.tsx | 130 +++++++++++------- apps/web/src/styles.css | 4 +- 2 files changed, 79 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/features/notification/notification-center.tsx b/apps/web/src/components/features/notification/notification-center.tsx index 18e6b4b..a4ffedb 100644 --- a/apps/web/src/components/features/notification/notification-center.tsx +++ b/apps/web/src/components/features/notification/notification-center.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; import { useNotifications } from "../../../hooks/use-notifications"; import { NotificationItem } from "./notification-item"; import { Icon } from "../../icon"; @@ -22,15 +23,30 @@ export function NotificationCenter({ setIsDropdownOpen, } = useNotifications(); + const bellRef = useRef(null); const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); + + const updatePosition = useCallback(() => { + if (!bellRef.current) return; + const rect = bellRef.current.getBoundingClientRect(); + setDropdownStyle({ + top: rect.bottom + 6, + right: window.innerWidth - rect.right, + }); + }, []); useEffect(() => { if (!isDropdownOpen) return; + updatePosition(); + const handleMouseDown = (e: MouseEvent) => { + const target = e.target as Node; if ( dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) + !dropdownRef.current.contains(target) && + !bellRef.current?.contains(target) ) { setIsDropdownOpen(false); } @@ -42,14 +58,20 @@ export function NotificationCenter({ } }; + const handleResize = () => { + updatePosition(); + }; + document.addEventListener("mousedown", handleMouseDown); document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", handleResize); return () => { document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", handleResize); }; - }, [isDropdownOpen, setIsDropdownOpen]); + }, [isDropdownOpen, setIsDropdownOpen, updatePosition]); useEffect(() => { if (isDropdownOpen) { @@ -66,6 +88,7 @@ export function NotificationCenter({ return (
- {isDropdownOpen && ( -
-
- Notifications -
- -
    - {notifications.length === 0 ? ( -
  • No notifications
  • - ) : ( - notifications.map((n) => ( - - )) - )} -
- - {notifications.length > 0 && ( -
- - + {isDropdownOpen && + createPortal( +
+
+ Notifications
- )} -
- )} + +
    + {notifications.length === 0 ? ( +
  • No notifications
  • + ) : ( + notifications.map((n) => ( + + )) + )} +
+ + {notifications.length > 0 && ( +
+ + +
+ )} +
, + document.body, + )}
); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 38c14d8..7a0dc25 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -4670,9 +4670,7 @@ a:active, } .notification-dropdown { - position: absolute; - top: calc(100% + 6px); - right: 0; + position: fixed; width: 360px; max-width: calc(100vw - 2rem); max-height: 480px;