fix: portal sidebar dropdowns to escape backdrop-filter clipping

Bump @ciphera-net/ui to 0.2.14 which portals AppLauncher and UserMenu
dropdowns via createPortal when anchor="right". Apply the same fix to
NotificationCenter. This escapes the sidebar's backdrop-filter
containing block that was clipping all fixed-positioned dropdowns.
This commit is contained in:
Usman Baig
2026-03-19 00:35:51 +01:00
parent 3e67af5646
commit 1755dcb9dc
3 changed files with 139 additions and 126 deletions

View File

@@ -5,6 +5,7 @@
*/ */
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Link from 'next/link' import Link from 'next/link'
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications' import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -53,6 +54,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null) const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
@@ -107,7 +109,11 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { const target = e.target as Node
if (
dropdownRef.current && !dropdownRef.current.contains(target) &&
(!panelRef.current || !panelRef.current.contains(target))
) {
setOpen(false) setOpen(false)
} }
} }
@@ -186,130 +192,137 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
)} )}
</button> </button>
{open && ( {(() => {
<div const panel = open ? (
id="notification-dropdown" <div
role="dialog" ref={panelRef}
aria-label="Notifications" id="notification-dropdown"
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${ role="dialog"
anchor === 'right' aria-label="Notifications"
? 'fixed w-96 origin-top-left' className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${
: 'fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96' anchor === 'right'
}`} ? 'fixed w-96 origin-top-left'
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined} : 'fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96'
> }`}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700"> style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3> >
{unreadCount > 0 && ( <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<button <h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
type="button" {unreadCount > 0 && (
onClick={handleMarkAllRead} <button
aria-label="Mark all notifications as read" type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
className="text-sm text-brand-orange hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
)}
{!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No notifications yet
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(notifications ?? []).map((n) => (
<li key={n.id}>
{n.link_url ? (
<Link
href={n.link_url}
onClick={() => handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</button>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="text-sm text-brand-orange hover:underline" className="text-sm text-brand-orange hover:underline"
> >
Mark all read View all
</button> </Link>
)} <Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>
</div> </div>
) : null
<div className="max-h-80 overflow-y-auto"> return anchor === 'right' && panel && typeof document !== 'undefined'
{loading && ( ? createPortal(panel, document.body)
<div className="p-3 space-y-1"> : panel
{Array.from({ length: 4 }).map((_, i) => ( })()}
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
)}
{!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No notifications yet
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(notifications ?? []).map((n) => (
<li key={n.id}>
{n.link_url ? (
<Link
href={n.link_url}
onClick={() => handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</button>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="text-sm text-brand-orange hover:underline"
>
View all
</Link>
<Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>
</div>
)}
</div> </div>
) )
} }

8
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.15.0-alpha", "version": "0.15.0-alpha",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.2.13", "@ciphera-net/ui": "^0.2.14",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
@@ -1670,9 +1670,9 @@
} }
}, },
"node_modules/@ciphera-net/ui": { "node_modules/@ciphera-net/ui": {
"version": "0.2.13", "version": "0.2.14",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.13/710acb1221e5906b8fd622ff6c77b469781c449d", "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.14/6502f8587e7e841c507c670c212f801e047a85a9",
"integrity": "sha512-pHo0DkkjAr7qlnPy1GBtHm3XFQlUC8b/FQM5PCu9JvW3pHg6znufyCP/8Wqbu6jOIt4C+c6CSKvMKzeYPKi42Q==", "integrity": "sha512-Jb2tG4DNXEwYMLdyQg+BwBpUKcMtmsAnkrtbenhQEiIngRpwgS7YyjtJVa0aDMPkFD4uq6nVMLoSRTMNgxekMg==",
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -12,7 +12,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.2.13", "@ciphera-net/ui": "^0.2.14",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",