feat(nav): add tooltip and cursor:pointer to notification bell, fix ThemeToggle i18n (#344) #351
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Briefwechsel",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||
"btn_save": "Speichern",
|
||||
"btn_cancel": "Abbrechen",
|
||||
"btn_confirm": "Bestätigen",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Letters",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
"theme_toggle_to_light": "Switch to light mode",
|
||||
"theme_toggle_to_dark": "Switch to dark mode",
|
||||
"btn_save": "Save",
|
||||
"btn_cancel": "Cancel",
|
||||
"btn_confirm": "Confirm",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Cartas",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Cerrar sesión",
|
||||
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||
"btn_save": "Guardar",
|
||||
"btn_cancel": "Cancelar",
|
||||
"btn_confirm": "Confirmar",
|
||||
|
||||
@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
const bellLabel = $derived(
|
||||
stream.unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||
: m.notification_bell_label()
|
||||
);
|
||||
|
||||
function attachBellButton(node: HTMLButtonElement) {
|
||||
bellButtonEl = node;
|
||||
return () => {
|
||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
||||
{@attach attachBellButton}
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
aria-label={stream.unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||
: m.notification_bell_label()}
|
||||
aria-label={bellLabel}
|
||||
title={bellLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
||||
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' })];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
@@ -19,6 +20,10 @@ onMount(() => {
|
||||
theme = resolveInitialTheme();
|
||||
});
|
||||
|
||||
const themeLabel = $derived(
|
||||
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
theme = theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', theme);
|
||||
@@ -29,8 +34,8 @@ function toggle() {
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
aria-label={themeLabel}
|
||||
title={themeLabel}
|
||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#if theme === 'dark'}
|
||||
|
||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.removeItem('theme');
|
||||
});
|
||||
|
||||
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('theme', 'light');
|
||||
});
|
||||
|
||||
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||
render(ThemeToggle);
|
||||
const btn = await page.getByRole('button').element();
|
||||
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||
});
|
||||
|
||||
it('title equals aria-label in light mode', async () => {
|
||||
render(ThemeToggle);
|
||||
const btn = await page.getByRole('button').element();
|
||||
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
});
|
||||
|
||||
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||
render(ThemeToggle);
|
||||
const btn = await page.getByRole('button').element();
|
||||
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||
});
|
||||
|
||||
it('title equals aria-label in dark mode', async () => {
|
||||
render(ThemeToggle);
|
||||
const btn = await page.getByRole('button').element();
|
||||
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||
});
|
||||
});
|
||||
@@ -365,6 +365,11 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--c-focus-ring);
|
||||
|
||||
Reference in New Issue
Block a user