Notification center
The navbar bell: an unread count, an All and Unread filter, and a list that survives a reload.
<script setup lang="ts">
import NotificationCenter from '@shared/components/common/NotificationCenter.vue'
</script>
<template>
<div class="flex items-center gap-3 rounded-lg border bg-card px-3 py-2">
<span class="text-sm text-muted-foreground">Navbar</span>
<div class="ms-auto">
<NotificationCenter />
</div>
</div>
</template>Usage
The component takes no props. It reads the notifications store, which is a Pinia store persisted to localStorage under sa-notifications.
features.notifications gates the bell in the navbar. See Global config.
On mount it seeds a small fixed set, and only when the list is empty, so the demo has a count to show without stacking a fresh copy on every reload. The seeded entries carry fixed ids and timestamps measured from when they were first seeded, so the relative labels still read sensibly whenever the demo is opened.
Adding notifications
import { useNotifications } from '@shared/composables/useNotifications'
const store = useNotifications()
store.add({
type: 'success',
title: 'Backup finished',
description: 'Last night’s snapshot is ready to restore from.',
link: '/settings',
})add takes everything except id, timestamp and read, which it sets. A link makes the row navigate when pressed.
<script setup lang="ts">
import NotificationCenter from '@shared/components/common/NotificationCenter.vue'
import { Button } from '@shared/components/ui/button'
import { useNotifications } from '@shared/composables/useNotifications'
const store = useNotifications()
function addSuccess(): void {
store.add({
type: 'success',
title: 'Backup finished',
description: 'Last night\u2019s snapshot is ready to restore from.',
link: '/settings',
})
}
function addError(): void {
store.add({
type: 'error',
title: 'Sync failed',
description: 'The warehouse feed returned a 500. We will retry in ten minutes.',
})
}
</script>
<template>
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" @click="addSuccess">
Add a success
</Button>
<Button size="sm" variant="outline" @click="addError">
Add an error
</Button>
<Button size="sm" variant="outline" @click="store.markAllRead()">
Mark all read
</Button>
<NotificationCenter />
</div>
<p class="text-sm text-muted-foreground">
{{ store.items.length }} in the list, {{ store.unreadCount }} unread
</p>
</div>
</template>The store
| Member | Type | Description |
|---|---|---|
items | Notification[] | Newest first. |
unreadCount | number | How many are unread. |
add(n) | (n: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void | Adds one at the top. |
markRead(id) | (id: string) => void | Marks one read. |
markAllRead() | () => void | Marks everything read. |
remove(id) | (id: string) => void | Drops one. |
seedDemo() | () => void | Seeds the fixed set, only when the list is empty. |
$reset() | () => void | Empties the list. |
Notification
| Key | Type | Description |
|---|---|---|
id | string | Set by add. |
type | 'info' | 'success' | 'warning' | 'error' | Picks the row's icon and tint. |
title | string | A translation key or a literal. |
description | string | The same. |
params | Record<string, string> | Interpolation values, for a keyed title or description. |
link | string | Where pressing the row goes. |
timestamp | Date | Set by add. Rendered as a relative time. |
read | boolean | Set to false by add. |
The filter
All and Unread are a segmented pair rather than a dropdown. There are two states, and a menu to pick between two is a click too many. The unread count rides on its own tab, so the filter doubles as the answer to "how many are left".
The filter is in-session only, and it resets when the popover closes. The store is persisted, and a saved filter would mean opening the app tomorrow to an inbox quietly hiding most of itself.
An empty list shows the empty state, with different words for "nothing at all" and "nothing unread".
The count chip
The badge is the shared count chip, the same shape the chat and the sidebar use, and it caps at 9+. It is not drawn in the destructive color: an unread notification is not an error, and the template keeps that color for things that have broken.
API
Source: src/shared/components/common/NotificationCenter.vue, src/shared/composables/useNotifications.ts
Props
None.
Emits
None.
Slots
None.
Accessibility
- The trigger is named "Notifications", so the bell is not an unnamed icon button.
- The All and Unread tabs carry
aria-pressed, so the active filter is announced. - The list scrolls inside the popover at up to 320 pixels, so a long list never pushes the popover off the screen.
- Escape closes the popover and focus returns to the bell.