From a93fde757506b896f8f4fa0820a120a0ce1a0211 Mon Sep 17 00:00:00 2001 From: Abdullah Azmi Date: Mon, 19 Oct 2026 03:55:30 +0000 Subject: [PATCH] feat(frontend): add aria-controls and Escape focus management to NotificationDropdown Fixes #1636 --- .../components/NotificationDropdown.test.tsx | 49 +++++++++++++++++++ .../src/components/NotificationDropdown.tsx | 10 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/NotificationDropdown.test.tsx b/frontend/src/components/NotificationDropdown.test.tsx index 2a8f02b2..eb66442c 100644 --- a/frontend/src/components/NotificationDropdown.test.tsx +++ b/frontend/src/components/NotificationDropdown.test.tsx @@ -87,4 +87,53 @@ describe('NotificationDropdown empty state', () => { expect(await screen.findByText('New stream #7 created')).toBeInTheDocument(); expect(screen.queryByTestId('notifications-empty-state')).not.toBeInTheDocument(); }); + + it('exposes accurate aria-expanded and aria-controls attributes on the bell trigger', async () => { + mockStream(); + render(); + + const bellButton = screen.getByRole('button', { name: /notifications/i }); + expect(bellButton).toHaveAttribute('aria-expanded', 'false'); + expect(bellButton).toHaveAttribute('aria-controls', 'notifications-dropdown-menu'); + + await userEvent.click(bellButton); + expect(bellButton).toHaveAttribute('aria-expanded', 'true'); + expect(document.getElementById('notifications-dropdown-menu')).toBeInTheDocument(); + }); + + it('closes the dropdown on Escape and returns keyboard focus to the bell trigger button', async () => { + mockStream(); + render(); + + const bellButton = screen.getByRole('button', { name: /notifications/i }); + await userEvent.click(bellButton); + expect(screen.getByRole('dialog', { name: 'Notifications' })).toBeInTheDocument(); + + // Move focus away from the bell button to a button inside the dialog + const closeButton = screen.getByRole('button', { name: 'Close notifications' }); + closeButton.focus(); + expect(closeButton).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', { name: 'Notifications' })).not.toBeInTheDocument(); + expect(bellButton).toHaveAttribute('aria-expanded', 'false'); + expect(bellButton).toHaveFocus(); + }); + + it('announces unread notification count via an aria-live polite region', async () => { + mockStream({ + events: [ + { + type: 'created', + data: { streamId: 42 }, + timestamp: 1_700_000_000_000, + }, + ], + }); + const { container } = render(); + + const liveRegion = container.querySelector('[aria-live="polite"]'); + expect(liveRegion).not.toBeNull(); + expect(await screen.findByText('1 new notifications')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index be7c7494..ff8b1fbb 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -25,6 +25,7 @@ export const NotificationDropdown: React.FC = ({ publ const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const containerRef = useRef(null); + const triggerRef = useRef(null); const { events: streamEvents, connected } = useStreamEvents({ userPublicKeys: [publicKey], @@ -122,6 +123,7 @@ export const NotificationDropdown: React.FC = ({ publ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsOpen(false); + triggerRef.current?.focus(); return; } @@ -178,9 +180,11 @@ export const NotificationDropdown: React.FC = ({ publ {unreadCount} new notifications