Skip to content

Commit 6a743a7

Browse files
authored
Merge pull request #1364 from enny791/fix/issue-1228
Fix issue 1228: Add keyboard dismissal, focus trap, and click-outside to NotificationDropdown
2 parents 4f6be44 + 3a0e0f9 commit 6a743a7

1 file changed

Lines changed: 50 additions & 2 deletions

File tree

frontend/src/components/NotificationDropdown.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React, { useState, useEffect, useCallback } from "react";
3+
import React, { useState, useEffect, useCallback, useRef } from "react";
44
import { useRouter } from "next/navigation";
55
import { useStreamEvents } from "@/hooks/useStreamEvents";
66
import { formatAmount } from "@/utils/amount";
@@ -24,6 +24,7 @@ export const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ publ
2424
const [isOpen, setIsOpen] = useState(false);
2525
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
2626
const [unreadCount, setUnreadCount] = useState(0);
27+
const containerRef = useRef<HTMLDivElement>(null);
2728

2829
const { events: streamEvents, connected } = useStreamEvents({
2930
userPublicKeys: [publicKey],
@@ -111,6 +112,53 @@ export const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ publ
111112
});
112113
}, [streamEvents, isOpen, formatEventMessage]);
113114

115+
useEffect(() => {
116+
const handleClickOutside = (event: MouseEvent) => {
117+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
118+
setIsOpen(false);
119+
}
120+
};
121+
122+
const handleKeyDown = (event: KeyboardEvent) => {
123+
if (event.key === "Escape") {
124+
setIsOpen(false);
125+
return;
126+
}
127+
128+
if (event.key === "Tab" && containerRef.current && isOpen) {
129+
const focusableElements = containerRef.current.querySelectorAll<HTMLElement>(
130+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
131+
);
132+
if (focusableElements.length === 0) return;
133+
134+
const firstElement = focusableElements[0];
135+
const lastElement = focusableElements[focusableElements.length - 1];
136+
137+
if (event.shiftKey) {
138+
if (document.activeElement === firstElement) {
139+
event.preventDefault();
140+
lastElement?.focus();
141+
}
142+
} else {
143+
if (document.activeElement === lastElement) {
144+
event.preventDefault();
145+
firstElement?.focus();
146+
}
147+
}
148+
}
149+
};
150+
151+
if (isOpen) {
152+
document.addEventListener("mousedown", handleClickOutside);
153+
document.addEventListener("keydown", handleKeyDown);
154+
}
155+
156+
return () => {
157+
document.removeEventListener("mousedown", handleClickOutside);
158+
document.removeEventListener("keydown", handleKeyDown);
159+
};
160+
}, [isOpen]);
161+
114162
const handleDropdownOpen = useCallback(() => {
115163
setIsOpen((prev) => !prev);
116164

@@ -121,7 +169,7 @@ export const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ publ
121169
}, [isOpen]);
122170

123171
return (
124-
<div className="relative">
172+
<div className="relative" ref={containerRef}>
125173
<button
126174
onClick={handleDropdownOpen}
127175
aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ""}`}

0 commit comments

Comments
 (0)