diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 87a2a243..f8dfa6b7 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -2336,11 +2336,15 @@ Parts: `ca-bottom-nav__more` `ca-bottom-nav__more-btn` `ca-bottom-nav__more-btn- | Prop | Type | Default | Notes | |---|---|---|---| | `notifications?` | `NotificationItem[]` | `[]` | | +| `open?` | `boolean` | `false` | `bind:` Bindable. Whether the panel is open. | +| `onOpenChange?` | `(open: boolean) => void` | | Called when the user opens or closes the panel, not when the parent sets open. | +| `count?` | `number` | | Unread total. Defaults to the unread notifications in the list. | +| `loading?` | `boolean` | `false` | Shows loading text while keeping loaded notifications visible. | | `onRead?` | `(id: string) => void` | | | | `onReadAll?` | `() => void` | | | | `onClear?` | `() => void` | | | | `class?` | `string` | `''` | | -| `labels?` | `{ title?: string; markAll?: string; clear?: string; empty?: string; }` | `{}` | Label overrides for i18n. Defaults to English strings. | +| `labels?` | `{ title?: string; markAll?: string; clear?: string; empty?: string; loading?: string; }` | `{}` | Label overrides for i18n. Defaults to English strings. | Snippets: `children` diff --git a/.changeset/notification-bell-state.md b/.changeset/notification-bell-state.md new file mode 100644 index 00000000..68768037 --- /dev/null +++ b/.changeset/notification-bell-state.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +Add bindable open state, user open-change callbacks, an independent unread count and loading feedback to NotificationBell for lazy notification lists. Announce unread count changes politely. diff --git a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte index 35b24b89..2acd8609 100644 --- a/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte +++ b/packages/ui/src/lib/components/NotificationBell/NotificationBell.svelte @@ -3,10 +3,11 @@ import { notificationBell } from '../../i18n/groups/notificationBell.js'; import { Bell } from '@lucide/svelte'; import { uid } from '../../utils/index.js'; + import { announce } from '../../utils/announce.js'; import FloatingLayer from '../Popover/FloatingLayer.svelte'; import { createTriggerProps, tabIntoPopup } from '../Popover/trigger.js'; import Badge from '../Badge/Badge.svelte'; - import type { Snippet } from 'svelte'; + import { type Snippet, untrack } from 'svelte'; const t = useMessages(notificationBell); export interface NotificationItem { @@ -20,6 +21,14 @@ interface Props { notifications?: NotificationItem[]; + /** Bindable. Whether the panel is open. */ + open?: boolean; + /** Called when the user opens or closes the panel, not when the parent sets open. */ + onOpenChange?: (open: boolean) => void; + /** Unread total. Defaults to the unread notifications in the list. */ + count?: number; + /** Shows loading text while keeping loaded notifications visible. */ + loading?: boolean; onRead?: (id: string) => void; onReadAll?: () => void; onClear?: () => void; @@ -31,11 +40,16 @@ markAll?: string; clear?: string; empty?: string; + loading?: string; }; } let { notifications = [], + open = $bindable(false), + onOpenChange, + count, + loading = false, onRead, onReadAll, onClear, @@ -44,20 +58,37 @@ labels = {}, }: Props = $props(); - let unread = $derived(notifications.filter(n => !n.read).length); + let unread = $derived(count ?? notifications.filter(n => !n.read).length); + let lastUnread = untrack(() => unread); + $effect(() => { + const next = unread; + if (next === lastUnread) return; + lastUnread = next; + announce(t().notificationBell.label(next)); + }); const id = uid('notif'); - let open = $state(false); let anchor = $state(null); let panel = $state(null); + // Restore focus before a closing panel removes its focused child. + $effect.pre(() => { + if (!open && panel?.contains(document.activeElement)) anchor?.focus({ preventScroll: true }); + }); + + function setOpen(next: boolean) { + if (next === open) return; + open = next; + onOpenChange?.(next); + } + const makeTrigger = createTriggerProps((el) => (anchor = el)); const triggerAttrs = $derived( - makeTrigger({ id: `${id}-trigger`, popup: 'dialog', open, controls: `${id}-panel`, onToggle: () => (open = !open), onKeydown: (e) => open && tabIntoPopup(e, panel) }) + makeTrigger({ id: `${id}-trigger`, popup: 'dialog', open, controls: `${id}-panel`, onToggle: () => setOpen(!open), onKeydown: (e) => open && tabIntoPopup(e, panel) }) ); function close(restoreFocus: boolean) { - open = false; + setOpen(false); if (restoreFocus) anchor?.focus({ preventScroll: true }); } @@ -107,8 +138,13 @@ + {#if loading} +
{labels.loading ?? t().notificationBell.loading}
+ {/if} {#if notifications.length === 0} -
{labels.empty ?? t().notificationBell.empty}
+ {#if !loading} +
{labels.empty ?? t().notificationBell.empty}
+ {/if} {:else}