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