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)
This commit is contained in:
@@ -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 { useNotifications } from "../../../hooks/use-notifications";
|
||||||
import { NotificationItem } from "./notification-item";
|
import { NotificationItem } from "./notification-item";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
@@ -22,15 +23,30 @@ export function NotificationCenter({
|
|||||||
setIsDropdownOpen,
|
setIsDropdownOpen,
|
||||||
} = useNotifications();
|
} = useNotifications();
|
||||||
|
|
||||||
|
const bellRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (!bellRef.current) return;
|
||||||
|
const rect = bellRef.current.getBoundingClientRect();
|
||||||
|
setDropdownStyle({
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDropdownOpen) return;
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
if (
|
if (
|
||||||
dropdownRef.current &&
|
dropdownRef.current &&
|
||||||
!dropdownRef.current.contains(e.target as Node)
|
!dropdownRef.current.contains(target) &&
|
||||||
|
!bellRef.current?.contains(target)
|
||||||
) {
|
) {
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
}
|
}
|
||||||
@@ -42,14 +58,20 @@ export function NotificationCenter({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, [isDropdownOpen, setIsDropdownOpen]);
|
}, [isDropdownOpen, setIsDropdownOpen, updatePosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDropdownOpen) {
|
if (isDropdownOpen) {
|
||||||
@@ -66,6 +88,7 @@ export function NotificationCenter({
|
|||||||
return (
|
return (
|
||||||
<div className="notification-center">
|
<div className="notification-center">
|
||||||
<button
|
<button
|
||||||
|
ref={bellRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="notification-bell"
|
className="notification-bell"
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
@@ -79,56 +102,59 @@ export function NotificationCenter({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen &&
|
||||||
<div
|
createPortal(
|
||||||
ref={dropdownRef}
|
<div
|
||||||
role="dialog"
|
ref={dropdownRef}
|
||||||
aria-label="Notifications"
|
role="dialog"
|
||||||
className="notification-dropdown"
|
aria-label="Notifications"
|
||||||
>
|
className="notification-dropdown"
|
||||||
<div className="notification-dropdown-header">
|
style={dropdownStyle}
|
||||||
<span>Notifications</span>
|
>
|
||||||
</div>
|
<div className="notification-dropdown-header">
|
||||||
|
<span>Notifications</span>
|
||||||
<ul className="notification-list">
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<li className="notification-empty">No notifications</li>
|
|
||||||
) : (
|
|
||||||
notifications.map((n) => (
|
|
||||||
<NotificationItem
|
|
||||||
key={n.id}
|
|
||||||
notification={n}
|
|
||||||
onMarkRead={markRead}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{notifications.length > 0 && (
|
|
||||||
<div className="notification-dropdown-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="notification-mark-all"
|
|
||||||
onClick={() => {
|
|
||||||
void markAllRead();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Mark all as read
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="notification-clear-all"
|
|
||||||
onClick={() => {
|
|
||||||
void clearAll();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<ul className="notification-list">
|
||||||
)}
|
{notifications.length === 0 ? (
|
||||||
|
<li className="notification-empty">No notifications</li>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onMarkRead={markRead}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<div className="notification-dropdown-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-mark-all"
|
||||||
|
onClick={() => {
|
||||||
|
void markAllRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notification-clear-all"
|
||||||
|
onClick={() => {
|
||||||
|
void clearAll();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4670,9 +4670,7 @@ a:active,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-dropdown {
|
.notification-dropdown {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 6px);
|
|
||||||
right: 0;
|
|
||||||
width: 360px;
|
width: 360px;
|
||||||
max-width: calc(100vw - 2rem);
|
max-width: calc(100vw - 2rem);
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
|
|||||||
Reference in New Issue
Block a user