Closes #344 ## What was implemented ### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell` - Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively - Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states - Added `cursor-pointer` to the bell button's class list - Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue) - Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3 ### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys` - Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages - Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it - Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution Touch target size was descoped per the Decision Queue. ## Decision Queue resolutions (from issue #344) - **cursor-pointer scope**: global via `@layer base` ✅ - **ThemeToggle scope**: fixed in this issue ✅ - **Touch target**: descoped ✅ ## Test results All 5 `NotificationBell` tests pass. Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
import { cleanup, render } from 'vitest-browser-svelte';
|
|
import type { NotificationItem } from '$lib/utils/notifications';
|
|
import NotificationBell from './NotificationBell.svelte';
|
|
|
|
const gotoMock = vi.hoisted(() => vi.fn());
|
|
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
|
|
|
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
|
|
|
vi.mock('$lib/stores/notifications.svelte', () => ({
|
|
notificationStore: {
|
|
get notifications() {
|
|
return mockNotificationList.value;
|
|
},
|
|
get unreadCount() {
|
|
return mockNotificationList.value.length;
|
|
},
|
|
markRead: mockMarkRead,
|
|
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
|
init: vi.fn(),
|
|
destroy: vi.fn(),
|
|
markAllRead: vi.fn()
|
|
}
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
gotoMock.mockClear();
|
|
mockMarkRead.mockClear();
|
|
mockNotificationList.value = [];
|
|
});
|
|
|
|
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
|
id: 'n1',
|
|
type: 'REPLY',
|
|
documentId: 'doc-1',
|
|
referenceId: 'ref-1',
|
|
annotationId: null,
|
|
read: false,
|
|
createdAt: '2026-04-21T10:00:00Z',
|
|
actorName: 'Anna',
|
|
documentTitle: 'Test Doc',
|
|
...overrides
|
|
});
|
|
|
|
async function openDropdownAndClickFirstNotification() {
|
|
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
bellButton.click();
|
|
await vi.waitFor(() => {
|
|
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
|
});
|
|
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
|
|
notifButton.click();
|
|
}
|
|
|
|
describe('NotificationBell — cursor and tooltip', () => {
|
|
it('bell button has cursor-pointer class', async () => {
|
|
render(NotificationBell);
|
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
|
});
|
|
|
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
|
mockNotificationList.value = [];
|
|
render(NotificationBell);
|
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
|
});
|
|
|
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
|
mockNotificationList.value = [
|
|
makeNotification({ id: 'n1' }),
|
|
makeNotification({ id: 'n2' }),
|
|
makeNotification({ id: 'n3' })
|
|
];
|
|
render(NotificationBell);
|
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
|
});
|
|
});
|
|
|
|
describe('NotificationBell', () => {
|
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
|
render(NotificationBell);
|
|
|
|
await openDropdownAndClickFirstNotification();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(gotoMock).toHaveBeenCalledWith(
|
|
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
|
|
mockNotificationList.value = [makeNotification({ annotationId: null })];
|
|
render(NotificationBell);
|
|
|
|
await openDropdownAndClickFirstNotification();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
|
|
});
|
|
});
|
|
});
|