994b1cf3b7
- Use useMobileViewport to detect mobile and position dropdown centered with left/right margins instead of right-aligned, which caused overflow on small screens. - Add a semi-transparent backdrop overlay on mobile so tapping outside the dropdown naturally closes it. - Update mobile CSS: notification-dropdown fills screen width with 0.75rem margins, max-height capped at 70vh for reachability. - Remove the 360px max-width cap on mobile so the dropdown uses available screen space properly. Quality gates: tsc --noEmit (pass), build (pass)
180 lines
4.2 KiB
TypeScript
180 lines
4.2 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useNotifications } from "../../../hooks/use-notifications";
|
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
|
import { NotificationItem } from "./notification-item";
|
|
import { Icon } from "../../icon";
|
|
|
|
interface NotificationCenterProps {
|
|
isMobileTerminal?: boolean;
|
|
}
|
|
|
|
export function NotificationCenter({
|
|
isMobileTerminal = false,
|
|
}: NotificationCenterProps) {
|
|
const {
|
|
notifications,
|
|
unreadCount,
|
|
markRead,
|
|
markAllRead,
|
|
clearAll,
|
|
dismiss,
|
|
refreshList,
|
|
isDropdownOpen,
|
|
setIsDropdownOpen,
|
|
} = useNotifications();
|
|
|
|
const isMobile = useMobileViewport();
|
|
const bellRef = useRef<HTMLButtonElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
|
|
|
const updatePosition = useCallback(() => {
|
|
if (!bellRef.current) return;
|
|
const rect = bellRef.current.getBoundingClientRect();
|
|
if (isMobile) {
|
|
setDropdownStyle({
|
|
top: rect.bottom + 6,
|
|
left: "1rem",
|
|
right: "1rem",
|
|
});
|
|
} else {
|
|
setDropdownStyle({
|
|
top: rect.bottom + 6,
|
|
right: window.innerWidth - rect.right,
|
|
});
|
|
}
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
if (!isDropdownOpen) return;
|
|
|
|
updatePosition();
|
|
|
|
const handleMouseDown = (e: MouseEvent) => {
|
|
const target = e.target as Node;
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(target) &&
|
|
!bellRef.current?.contains(target)
|
|
) {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
};
|
|
|
|
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, updatePosition]);
|
|
|
|
useEffect(() => {
|
|
if (isDropdownOpen) {
|
|
void refreshList();
|
|
}
|
|
}, [isDropdownOpen, refreshList]);
|
|
|
|
if (isMobileTerminal) {
|
|
return null;
|
|
}
|
|
|
|
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
|
|
|
|
return (
|
|
<div className="notification-center">
|
|
<button
|
|
ref={bellRef}
|
|
type="button"
|
|
className="notification-bell"
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
aria-label="Notifications"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={isDropdownOpen}
|
|
>
|
|
<Icon name="bell" size="md" />
|
|
{unreadCount > 0 && (
|
|
<span className="nav-badge notification-badge">{badgeText}</span>
|
|
)}
|
|
</button>
|
|
|
|
{isDropdownOpen &&
|
|
createPortal(
|
|
<>
|
|
{isMobile && (
|
|
<div
|
|
className="notification-dropdown-backdrop"
|
|
onClick={() => setIsDropdownOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<div
|
|
ref={dropdownRef}
|
|
role="dialog"
|
|
aria-label="Notifications"
|
|
className="notification-dropdown"
|
|
style={dropdownStyle}
|
|
>
|
|
<div className="notification-dropdown-header">
|
|
<span>Notifications</span>
|
|
</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>
|
|
);
|
|
}
|