mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
fix: say the unread count with the rail's Mail link and follow the unread count style
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user