fix: say the unread count with the rail's Mail link and follow the unread count style

This commit is contained in:
Linus Rath
2026-10-29 17:53:36 +01:00
parent 7c282f1f40
commit 80a2a212a4
2 changed files with 49 additions and 9 deletions
@@ -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<string, unknown>) =>
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(<NavigationRail {...props} />);
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(<NavigationRail {...props} />);
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(<NavigationRail {...props} />);
const link = screen.getAllByRole('link', { name: 'mail' })[0];
expect(screen.queryByTestId('rail-unread-dot')).toBeNull();
expect(link.textContent).not.toContain('2');
});
});
+22 -7
View File
@@ -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)}
>
<div className={cn(
"relative",
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
)}>
<Icon className="w-5 h-5" />
{item.badge != null && item.badge > 0 && (
{badgeShown(item) && (unreadCountStyle === 'dot' ? (
<span aria-hidden="true" data-testid="rail-unread-dot" className="absolute -top-0.5 -end-1 w-2.5 h-2.5 rounded-full bg-red-500" />
) : (
<span aria-hidden="true" className="absolute -top-1.5 -end-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
{item.badge > 99 ? "99+" : item.badge}
{item.badge! > 99 ? "99+" : item.badge}
</span>
)}
))}
{isActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
@@ -612,20 +622,25 @@ export function NavigationRail({
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
aria-label={t(item.labelKey)}
aria-label={itemName(item)}
title={collapsed ? t(item.labelKey) : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
{badgeShown(item) && (unreadCountStyle === 'dot' ? (
<span aria-hidden="true" data-testid="rail-unread-dot" className={cn(
"absolute w-2.5 h-2.5 rounded-full bg-red-500",
collapsed ? "top-1 end-1" : "end-2.5"
)} />
) : (
<span aria-hidden="true" className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -end-0.5" : "end-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
{item.badge! > 99 ? "99+" : item.badge}
</span>
)}
))}
</Link>
);
})}