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
122 lines
3.1 KiB
Svelte
122 lines
3.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { clickOutside } from '$lib/actions/clickOutside';
|
|
import { notificationStore } from '$lib/stores/notifications.svelte';
|
|
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
|
|
|
let open = $state(false);
|
|
let bellButtonEl: HTMLButtonElement | null = null;
|
|
|
|
const stream = notificationStore;
|
|
|
|
async function toggleDropdown() {
|
|
open = !open;
|
|
if (open) {
|
|
await stream.fetchNotifications();
|
|
setTimeout(() => {
|
|
const firstBtn = document.querySelector<HTMLButtonElement>(
|
|
'[role="dialog"] button, [role="dialog"] a'
|
|
);
|
|
firstBtn?.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
function closeDropdown() {
|
|
open = false;
|
|
bellButtonEl?.focus();
|
|
}
|
|
|
|
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
|
await stream.markRead(notification);
|
|
const url = buildCommentHref(
|
|
notification.documentId,
|
|
notification.referenceId,
|
|
notification.annotationId
|
|
);
|
|
closeDropdown();
|
|
goto(url);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && open) {
|
|
event.stopPropagation();
|
|
closeDropdown();
|
|
}
|
|
}
|
|
|
|
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 () => {
|
|
bellButtonEl = null;
|
|
};
|
|
}
|
|
|
|
onMount(() => {
|
|
stream.init();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
stream.destroy();
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
|
|
<!-- Bell button -->
|
|
<button
|
|
{@attach attachBellButton}
|
|
type="button"
|
|
onclick={toggleDropdown}
|
|
aria-label={bellLabel}
|
|
title={bellLabel}
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
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"
|
|
class="h-5 w-5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
/>
|
|
</svg>
|
|
|
|
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
|
|
<span
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
|
|
>
|
|
{stream.unreadCount}
|
|
</span>
|
|
</button>
|
|
|
|
{#if open}
|
|
<NotificationDropdown
|
|
notifications={stream.notifications}
|
|
onMarkRead={handleMarkRead}
|
|
onMarkAllRead={stream.markAllRead}
|
|
onClose={closeDropdown}
|
|
/>
|
|
{/if}
|
|
</div>
|