Menu

Change language

GitHub repo

Notification Center

blockcommunication

Full-page notification center: unread badge, mark-all-read, All/Unread/Mentions tabs with functional filtering, day-grouped rows (Today / Earlier) pairing category icon tiles with titles, bodies, relative timestamps, unread dots and hover actions (mark read, dismiss). Per-tab empty states included; swap `notifications` for your source.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/notification-center.json
Manifest:https://uipkge.dev/r/lit/notification-center.jsonInstalls to:src/uip/blocks/notification-center/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

NotificationCenterEntry
interface NotificationCenterEntry {
  id: string
  category: NotificationCenterCategory
  title: string
  body: string
  date: Date
  read: boolean
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-notification-center': UipNotificationCenter
  }
}

Files installed (1)

  • src/uip/blocks/notification-center/notification-center.ts10.2 kB
    import { LitElement, css, html } from 'lit'
    import { BellOff, Check, CheckCheck, MessageSquare, ShieldAlert, UserPlus, X } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/relative-time/relative-time'
    import '../../components/section-card/section-card'
    import '../../components/tabs/tabs'
    
    export type NotificationCenterCategory = 'mention' | 'comment' | 'system' | 'security'
    
    export interface NotificationCenterEntry {
      id: string
      category: NotificationCenterCategory
      title: string
      body: string
      date: Date
      read: boolean
    }
    
    type NotificationFilter = 'all' | 'unread' | 'mentions'
    
    function stubNotifications(): NotificationCenterEntry[] {
      const now = new Date()
      const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000)
      const hoursAgo = (h: number) => new Date(now.getTime() - h * 3_600_000)
      const daysAgo = (d: number) => new Date(now.getTime() - d * 86_400_000)
      return [
        { id: 'n1', category: 'mention', title: 'Priya mentioned you', body: '@amara can you review the onboarding flow?', date: minutesAgo(6), read: false },
        { id: 'n2', category: 'security', title: 'New sign-in from Berlin', body: 'Chrome on macOS · 84.190.201.3', date: minutesAgo(52), read: false },
        { id: 'n3', category: 'comment', title: 'Jonas commented', body: 'Shipped the fix — closing the ticket.', date: hoursAgo(2), read: false },
        { id: 'n4', category: 'system', title: 'Weekly digest ready', body: 'Your workspace summary for last week.', date: hoursAgo(20), read: true },
        { id: 'n5', category: 'mention', title: 'Marcus mentioned you', body: 'Design review moved to 15:00, @amara.', date: daysAgo(1), read: false },
        { id: 'n6', category: 'comment', title: 'Amara commented', body: 'Copy updated per legal review.', date: daysAgo(1), read: true },
        { id: 'n7', category: 'security', title: 'API key rotated', body: 'uipkge_live_…4f2a was rotated automatically.', date: daysAgo(2), read: true },
      ]
    }
    
    function tileClass(category: NotificationCenterCategory): string {
      switch (category) {
        case 'mention':
          return 'bg-primary/10 text-primary'
        case 'comment':
          return 'bg-chart-3/10 text-chart-3'
        case 'security':
          return 'bg-warning/10 text-warning'
        default:
          return 'bg-muted text-muted-foreground'
      }
    }
    
    function categoryIcon(category: NotificationCenterCategory, cls: string) {
      switch (category) {
        case 'mention':
          return icon(UserPlus, 'user-plus', cls)
        case 'comment':
          return icon(MessageSquare, 'message-square', cls)
        case 'security':
          return icon(ShieldAlert, 'shield-alert', cls)
        default:
          return icon(BellOff, 'bell-off', cls)
      }
    }
    
    /**
     * <uip-notification-center> — the registry NotificationCenter block as a web component.
     *
     * Class strings are React's NotificationCenter verbatim. React state becomes
     * @state fields. `notifications` is a settable property (defaults to the
     * stub list; pass an array from JS to override — the demo's per-story arrays
     * are wired via getElementById scripts). Category icons are static per-row
     * markup. Dismiss removes the row (React's Dismiss button only stopped
     * propagation; here it dismisses for a working control).
     */
    export class UipNotificationCenter extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        notifications: { attribute: false },
        items: { state: true },
        filter: { state: true },
      }
    
      notifications?: NotificationCenterEntry[]
      private items: NotificationCenterEntry[] = []
      private filter: NotificationFilter = 'all'
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'notification-center')
        if (this.items.length === 0) this.items = this.notifications ?? stubNotifications()
      }
    
      private patch(id: string, changes: Partial<NotificationCenterEntry>) {
        this.items = this.items.map((n) => (n.id === id ? { ...n, ...changes } : n))
      }
    
      private dismiss(id: string, e: Event) {
        e.stopPropagation()
        this.items = this.items.filter((n) => n.id !== id)
      }
    
      render() {
        const unreadCount = this.items.filter((n) => !n.read).length
        const filtered = this.items.filter((n) => {
          if (this.filter === 'unread') return !n.read
          if (this.filter === 'mentions') return n.category === 'mention'
          return true
        })
        const startOfToday = new Date()
        startOfToday.setHours(0, 0, 0, 0)
        const groups = [
          { label: 'Today', items: filtered.filter((n) => n.date >= startOfToday) },
          { label: 'Earlier', items: filtered.filter((n) => n.date < startOfToday) },
        ].filter((g) => g.items.length > 0)
        return html`
          <uip-section-card
            data-slot="notification-center"
            heading="Notifications"
            description="Everything that needs your attention."
          >
            <div slot="header-action" class="flex flex-wrap items-center gap-2">
              ${unreadCount > 0 ? html`<uip-badge variant="default">${unreadCount} new</uip-badge>` : ''}
              <uip-button
                variant="ghost"
                size="sm"
                ?disabled=${unreadCount === 0}
                @click=${() => {
                  this.items = this.items.map((n) => ({ ...n, read: true }))
                }}
              >
                ${icon(CheckCheck, 'check-check')} Mark all read
              </uip-button>
            </div>
    
            <uip-tabs
              class="mb-2"
              value=${this.filter}
              @value-change=${(e: CustomEvent<{ value: string }>) => {
                this.filter = e.detail.value as NotificationFilter
              }}
            >
              <span slot="trigger" data-value="all">All</span>
              <span slot="trigger" data-value="unread">Unread</span>
              <span slot="trigger" data-value="mentions">Mentions</span>
            </uip-tabs>
    
            ${groups.length === 0
              ? html`
                  <div class="text-muted-foreground flex flex-col items-center gap-2 py-12 text-sm">
                    ${icon(BellOff, 'bell-off', 'size-6 opacity-50')}
                    ${this.filter === 'mentions'
                      ? "No mentions — you're all caught up."
                      : this.filter === 'unread'
                        ? 'Nothing unread. Nice work.'
                        : 'Your inbox is empty.'}
                  </div>
                `
              : ''}
            ${groups.map(
              (group) => html`
                <div class="mb-2">
                  <p class="text-muted-foreground py-1.5 text-xs font-medium tracking-widest uppercase">${group.label}</p>
                  <ul class="divide-y">
                    ${group.items.map(
                      (notification) => html`
                        <li
                          class="group hover:bg-accent/40 -mx-2 flex cursor-pointer items-start gap-3 rounded-md px-2 py-3 transition-colors"
                          @click=${() => this.patch(notification.id, { read: true })}
                        >
                          <span
                            class=${cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md', tileClass(notification.category))}
                            aria-hidden="true"
                          >
                            ${categoryIcon(notification.category, 'size-4')}
                          </span>
                          <div class="min-w-0 flex-1">
                            <div class="flex flex-wrap items-center gap-2">
                              <p class=${cn('truncate text-sm', notification.read ? 'font-normal' : 'font-medium')}>
                                ${notification.title}
                              </p>
                              ${!notification.read
                                ? html`<span class="bg-primary size-1.5 shrink-0 rounded-full" aria-label="Unread"></span>`
                                : ''}
                            </div>
                            <p class="text-muted-foreground truncate text-xs">${notification.body}</p>
                          </div>
                          <div class="flex shrink-0 items-center gap-1">
                            <uip-relative-time
                              date=${notification.date.toISOString()}
                              class="text-muted-foreground text-xs"
                            ></uip-relative-time>
                            <span class="flex opacity-0 transition-opacity group-hover:opacity-100">
                              ${!notification.read
                                ? html`<uip-button
                                    variant="ghost"
                                    size="icon-sm"
                                    aria-label="Mark as read"
                                    @click=${(e: Event) => {
                                      e.stopPropagation()
                                      this.patch(notification.id, { read: true })
                                    }}
                                  >
                                    ${icon(Check, 'check')}
                                  </uip-button>`
                                : ''}
                              <uip-button
                                variant="ghost"
                                size="icon-sm"
                                aria-label="Dismiss"
                                @click=${(e: Event) => this.dismiss(notification.id, e)}
                              >
                                ${icon(X, 'x')}
                              </uip-button>
                            </span>
                          </div>
                        </li>
                      `,
                    )}
                  </ul>
                </div>
              `,
            )}
          </uip-section-card>
        `
      }
    }
    
    customElements.get('uip-notification-center') || customElements.define('uip-notification-center', UipNotificationCenter)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-notification-center': UipNotificationCenter
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/notification-center.json