From 80a2a212a421da34b7a063c307bc52d42a98c59a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:58:57 +0100 Subject: [PATCH] fix: say the unread count with the rail's Mail link and follow the unread count style --- .../navigation-rail-badge-name.test.tsx | 29 +++++++++++++++++-- components/layout/navigation-rail.tsx | 29 ++++++++++++++----- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/components/layout/__tests__/navigation-rail-badge-name.test.tsx b/components/layout/__tests__/navigation-rail-badge-name.test.tsx index 4a3a18fd5..c3baba3de 100644 --- a/components/layout/__tests__/navigation-rail-badge-name.test.tsx +++ b/components/layout/__tests__/navigation-rail-badge-name.test.tsx @@ -6,6 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // rail the entry has no visible text, so the badge digits used to become the // link's whole accessible name ("2" instead of "Mail"). +// "Mail, 3 unread" (SR-10): the count is said with the name. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => + key === 'name_unread' && params ? `${params.name}, ${params.count} unread` : key, + useLocale: () => 'en', +})); vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null })); @@ -21,10 +27,12 @@ vi.mock('@/i18n/navigation', () => ({ const { NavigationRail } = await import('../navigation-rail'); const { useUpdateStore } = await import('@/stores/update-store'); const { useEmailStore } = await import('@/stores/email-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); beforeEach(() => { useUpdateStore.setState({ startPolling: vi.fn() } as never); useEmailStore.setState({ mailboxes: [{ id: 'a', role: 'inbox', unreadEmails: 2 }] } as never); + useSettingsStore.setState({ unreadCountStyle: 'count' }); }); describe.each([ @@ -32,9 +40,26 @@ describe.each([ ['expanded rail', { orientation: 'vertical', collapsed: false }], ['bottom bar', { orientation: 'horizontal', collapsed: false }], ] as const)('%s', (_name, props) => { - it('names the Mail entry by its label, not by its unread count', () => { + it('names the Mail entry by its label and its unread count', () => { render(); - expect(screen.getAllByRole('link', { name: 'mail' }).length).toBeGreaterThan(0); + const link = screen.getAllByRole('link', { name: 'mail, 2 unread' })[0]; expect(screen.queryByRole('link', { name: '2' })).toBeNull(); + expect(link.textContent).toContain('2'); + }); + + it('shows a dot instead of the number and keeps the count in the name', () => { + useSettingsStore.setState({ unreadCountStyle: 'dot' }); + render(); + const link = screen.getAllByRole('link', { name: 'mail, 2 unread' })[0]; + expect(link.querySelector('[data-testid="rail-unread-dot"]')).not.toBeNull(); + expect(link.textContent).not.toContain('2'); + }); + + it('hides the count, and drops it from the name, when counts are hidden', () => { + useSettingsStore.setState({ unreadCountStyle: 'hidden' }); + render(); + const link = screen.getAllByRole('link', { name: 'mail' })[0]; + expect(screen.queryByTestId('rail-unread-dot')).toBeNull(); + expect(link.textContent).not.toContain('2'); }); }); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 62766defc..4c576d91d 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -257,6 +257,13 @@ export function NavigationRail({ useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]); const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated'; const tShell = useTranslations("shell"); + // Settings > Accessibility: the unread count as a number, as a dot, or not + // at all. A dot keeps the count in the link's name; hidden drops it too. + const unreadCountStyle = useSettingsStore((s) => s.unreadCountStyle); + const badgeShown = (item: NavItem) => item.badge != null && item.badge > 0 && unreadCountStyle !== 'hidden'; + // "Mail, 3 unread": the badge itself is hidden from screen readers. + const itemName = (item: NavItem) => + badgeShown(item) ? tShell("name_unread", { name: t(item.labelKey), count: item.badge ?? 0 }) : t(item.labelKey); // Said with the admin entry; its dot alone is not announced. const updateLabel = hasUpdate ? tShell(updateImportant ? "update_important" : "update_available") : null; @@ -397,17 +404,20 @@ export function NavigationRail({ : "text-muted-foreground hover:text-foreground" )} aria-current={isActive ? "page" : undefined} + aria-label={itemName(item)} >
- {item.badge != null && item.badge > 0 && ( + {badgeShown(item) && (unreadCountStyle === 'dot' ? ( +