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:
@@ -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
8
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user