Menu

Change language

GitHub repo

Notifications Popover

blockcommunication

Header-grade notifications panel. Slot-trigger Popover the consumer wraps around their own Bell button. Filter tabs (All/Unread), Today/Earlier groups, category-coloured accent bars, hover-dismiss, mark-all-read, slide-in animation. Slot receives `unreadCount` so the trigger can show a badge.

Installation

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

Variants

Loading interactive previews…

Schema

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

PopoverNotification
interface PopoverNotification {
  id: string
  title: string
  body: string
  category: PopoverNotificationCategory
  timestamp: Date
  read: boolean
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-notifications-popover': UipNotificationsPopover
  }
}

npm dependencies

Theming

CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.

--border--muted--popover

Files installed (1)

  • src/uip/blocks/notifications-popover/notifications-popover.ts14.9 kB
    import { LitElement, css, html } from 'lit'
    import {
      Archive,
      Bell,
      BellOff,
      Calendar,
      CreditCard,
      FileText,
      GraduationCap,
      Target,
      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/popover/popover'
    
    export type PopoverNotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system'
    
    interface PopoverNotification {
      id: string
      title: string
      body: string
      category: PopoverNotificationCategory
      timestamp: Date
      read: boolean
    }
    
    function seedNotifications(): PopoverNotification[] {
      const now = new Date()
      const min = 60 * 1000
      const hour = 60 * min
      return [
        { id: '1', title: 'Time off approved', body: 'Your annual leave request for Mar 15-18 has been approved by Sarah Connor.', category: 'timeoff', timestamp: new Date(now.getTime() - 12 * min), read: false },
        { id: '2', title: 'New employee onboarded', body: 'Marcus Rivera has joined the Engineering team as Senior Developer.', category: 'hr', timestamp: new Date(now.getTime() - 45 * min), read: false },
        { id: '3', title: 'Performance review due', body: 'Annual performance review for 3 direct reports is due by end of this week.', category: 'performance', timestamp: new Date(now.getTime() - 2 * hour), read: false },
        { id: '4', title: 'Payroll processed', body: 'March 2026 payroll has been processed successfully. 103 employees paid.', category: 'payroll', timestamp: new Date(now.getTime() - 5 * hour), read: true },
        { id: '5', title: 'Training enrollment open', body: 'New course available: "Leadership Fundamentals Q2". Enroll before Mar 20.', category: 'training', timestamp: new Date(now.getTime() - 8 * hour), read: true },
        { id: '6', title: 'Document requires signature', body: 'Updated Employee Handbook 2026 needs your acknowledgement.', category: 'system', timestamp: new Date(now.getTime() - 26 * hour), read: true },
        { id: '7', title: 'Time off request pending', body: 'Alice Johnson requested sick leave for Mar 12. Awaiting your approval.', category: 'timeoff', timestamp: new Date(now.getTime() - 28 * hour), read: true },
        { id: '8', title: 'Benefits enrollment closing', body: "Open enrollment period ends Mar 31. 12 employees haven't enrolled yet.", category: 'hr', timestamp: new Date(now.getTime() - 48 * hour), read: true },
      ]
    }
    
    function accent(category: PopoverNotificationCategory): string {
      switch (category) {
        case 'hr':
          return 'bg-success'
        case 'payroll':
          return 'bg-info'
        case 'timeoff':
          return 'bg-chart-3'
        case 'performance':
          return 'bg-warning'
        case 'training':
          return 'bg-primary'
        default:
          return 'bg-muted-foreground'
      }
    }
    
    function tileBg(category: PopoverNotificationCategory): string {
      switch (category) {
        case 'hr':
          return 'bg-success/10 text-success'
        case 'payroll':
          return 'bg-info/10 text-info'
        case 'timeoff':
          return 'bg-chart-3/10 text-chart-3'
        case 'performance':
          return 'bg-warning/10 text-warning'
        case 'training':
          return 'bg-primary/10 text-primary'
        default:
          return 'bg-muted text-muted-foreground'
      }
    }
    
    function categoryIcon(category: PopoverNotificationCategory, cls: string) {
      switch (category) {
        case 'hr':
          return icon(UserPlus, 'user-plus', cls)
        case 'payroll':
          return icon(CreditCard, 'credit-card', cls)
        case 'timeoff':
          return icon(Calendar, 'calendar', cls)
        case 'performance':
          return icon(Target, 'target', cls)
        case 'training':
          return icon(GraduationCap, 'graduation-cap', cls)
        default:
          return icon(FileText, 'file-text', cls)
      }
    }
    
    function formatTime(date: Date): string {
      const diffMs = Date.now() - date.getTime()
      const diffMin = Math.floor(diffMs / 60000)
      if (diffMin < 1) return 'Just now'
      if (diffMin < 60) return `${diffMin}m ago`
      const diffHrs = Math.floor(diffMin / 60)
      if (diffHrs < 24) return `${diffHrs}h ago`
      const diffDays = Math.floor(diffHrs / 24)
      if (diffDays === 1) return 'Yesterday'
      return `${diffDays}d ago`
    }
    
    /**
     * <uip-notifications-popover> — the registry NotificationsPopover block as a web component.
     *
     * Class strings are React's NotificationsPopover verbatim. React state becomes
     * @state fields. React's `trigger` render-prop becomes a `trigger` slot:
     * put your own Bell button in `slot="trigger"` (it receives the live unread
     * dot via the fallback); with no slotted trigger the default Bell button
     * renders. React's injected `<style>` (scrollbar, hover, notif-slide-in) is
     * copied into `static styles` (the keyframes exception covers the slide-in;
     * the scrollbar/fade rules ride along as React-authored block CSS).
     */
    export class UipNotificationsPopover extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      // Panel + slide-in CSS is copied from React's injected <style> (keyframes exception).
      static styles = [
        tailwind,
        css`:host { display: inline-block; }`,
        css`
          .notification-scroll::-webkit-scrollbar { width: 4px; }
          .notification-scroll::-webkit-scrollbar-track { background: transparent; }
          .notification-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
          .notification-item { cursor: pointer; transition: background-color 0.15s ease; animation: notif-slide-in 0.25s ease both; }
          .notification-item:hover { background-color: var(--muted); }
          @keyframes notif-slide-in {
            from { opacity: 0; transform: translateY(4px); }
            to { opacity: 1; transform: translateY(0); }
          }
          .notification-scroll { position: relative; }
          .notification-scroll::after {
            content: '';
            position: sticky;
            bottom: 0;
            left: 0;
            right: 0;
            display: block;
            height: 24px;
            margin-top: -24px;
            background: linear-gradient(to bottom, transparent, var(--popover));
            pointer-events: none;
          }
        `,
      ]
    
      static properties = {
        notifications: { state: true },
        activeFilter: { state: true },
        isOpen: { state: true },
      }
    
      private notifications: PopoverNotification[] = []
      private activeFilter: 'all' | 'unread' = 'all'
      private isOpen = false
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'notifications-popover')
        if (this.notifications.length === 0) this.notifications = seedNotifications()
      }
    
      private get unreadCount(): number {
        return this.notifications.filter((n) => !n.read).length
      }
    
      private get filtered(): PopoverNotification[] {
        if (this.activeFilter === 'unread') return this.notifications.filter((n) => !n.read)
        return this.notifications
      }
    
      private markAsRead(id: string) {
        this.notifications = this.notifications.map((n) => (n.id === id ? { ...n, read: true } : n))
      }
    
      private markAllRead() {
        this.notifications = this.notifications.map((n) => ({ ...n, read: true }))
      }
    
      private dismiss(id: string, e: Event) {
        e.stopPropagation()
        this.notifications = this.notifications.filter((n) => n.id !== id)
      }
    
      private renderItem(n: PopoverNotification, delay: number) {
        return html`
          <div
            data-slot="notifications-popover"
            class="notification-item group relative"
            style="animation-delay: ${delay}ms"
            @click=${() => this.markAsRead(n.id)}
          >
            <div
              class=${cn(
                'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity',
                !n.read ? accent(n.category) : 'opacity-0',
              )}
            ></div>
            <div class="flex gap-3 px-4 py-3">
              <div class=${cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg', tileBg(n.category))}>
                ${categoryIcon(n.category, 'size-4')}
              </div>
              <div class="min-w-0 flex-1">
                <div class="flex items-start justify-between gap-2">
                  <p class=${cn('text-[13px] leading-snug', !n.read ? 'font-semibold' : 'font-medium')}>${n.title}</p>
                  <div class="flex shrink-0 items-center gap-1.5">
                    <span class="text-muted-foreground text-xs whitespace-nowrap tabular-nums">${formatTime(n.timestamp)}</span>
                    ${!n.read ? html`<span class="bg-primary size-1.5 shrink-0 rounded-full"></span>` : ''}
                  </div>
                </div>
                <p class="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">${n.body}</p>
              </div>
              <button
                class="text-muted-foreground hover:text-foreground mt-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
                title="Dismiss"
                @click=${(e: Event) => this.dismiss(n.id, e)}
              >
                ${icon(X, 'x', 'size-3.5')}
              </button>
            </div>
          </div>
        `
      }
    
      render() {
        const now = new Date()
        // Mirror the live count for slotted custom triggers (React's trigger({ unreadCount })).
        this.setAttribute('unread-count', String(this.unreadCount))
        const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
        const today = this.filtered.filter((n) => n.timestamp >= todayStart)
        const earlier = this.filtered.filter((n) => n.timestamp < todayStart)
        return html`
          <uip-popover
            ?open=${this.isOpen}
            @open-change=${(e: CustomEvent<{ open: boolean }>) => {
              this.isOpen = e.detail.open
            }}
          >
            <slot name="trigger" slot="trigger">
              <uip-button
                variant="ghost"
                size="icon"
                class="text-muted-foreground hover:text-foreground relative size-9 rounded-lg"
                aria-label="Notifications"
              >
                ${icon(Bell, 'bell', 'size-4')}
                ${this.unreadCount > 0
                  ? html`<span class="bg-primary ring-background absolute top-1.5 right-1.5 size-2 rounded-full ring-2"></span>`
                  : ''}
              </uip-button>
            </slot>
            <div class="notification-panel w-full max-w-[380px] overflow-hidden rounded-lg border p-0 shadow-xl">
              <div class="flex items-center justify-between px-4 pt-4 pb-3">
                <div class="flex items-center gap-2.5">
                  <h3 class="text-sm font-semibold tracking-tight">Notifications</h3>
                  ${this.unreadCount > 0
                    ? html`<uip-badge
                        class="bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-xs font-bold tabular-nums"
                        >${this.unreadCount}</uip-badge
                      >`
                    : ''}
                </div>
                ${this.unreadCount > 0
                  ? html`<uip-button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs"
                      @click=${this.markAllRead}
                    >
                      Mark all read
                    </uip-button>`
                  : ''}
              </div>
    
              <div class="border-b px-4">
                <div class="flex gap-0">
                  <button
                    class=${cn(
                      'relative px-3 pb-2.5 text-xs font-medium transition-colors',
                      this.activeFilter === 'all' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
                    )}
                    @click=${() => {
                      this.activeFilter = 'all'
                    }}
                  >
                    All
                    ${this.activeFilter === 'all'
                      ? html`<span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>`
                      : ''}
                  </button>
                  <button
                    class=${cn(
                      'relative px-3 pb-2.5 text-xs font-medium transition-colors',
                      this.activeFilter === 'unread' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
                    )}
                    @click=${() => {
                      this.activeFilter = 'unread'
                    }}
                  >
                    Unread
                    ${this.activeFilter === 'unread'
                      ? html`<span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>`
                      : ''}
                  </button>
                </div>
              </div>
    
              <div class="notification-scroll max-h-[420px] overflow-y-auto">
                ${this.filtered.length === 0
                  ? html`
                      <div class="flex flex-col items-center justify-center py-14 text-center">
                        <div class="bg-muted mb-3 rounded-full p-3">
                          ${icon(BellOff, 'bell-off', 'text-muted-foreground size-5')}
                        </div>
                        <p class="text-sm font-medium">All caught up</p>
                        <p class="text-muted-foreground mt-0.5 text-xs">
                          No ${this.activeFilter === 'unread' ? 'unread ' : ''}notifications
                        </p>
                      </div>
                    `
                  : html`
                      ${today.length > 0
                        ? html`
                            <div class="px-4 pt-3 pb-1">
                              <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Today</span>
                            </div>
                            ${today.map((n, i) => this.renderItem(n, i * 30))}
                          `
                        : ''}
                      ${earlier.length > 0
                        ? html`
                            <div class="px-4 pt-3 pb-1">
                              <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase"
                                >Earlier</span
                              >
                            </div>
                            ${earlier.map((n, i) => this.renderItem(n, (today.length + i) * 30))}
                          `
                        : ''}
                    `}
              </div>
    
              <div class="border-t px-4 py-2.5">
                <a
                  href="#"
                  class="text-muted-foreground hover:text-foreground flex items-center justify-center gap-1.5 text-xs transition-colors"
                  @click=${() => {
                    this.isOpen = false
                  }}
                >
                  ${icon(Archive, 'archive', 'size-3')} View all notifications
                </a>
              </div>
            </div>
          </uip-popover>
        `
      }
    }
    
    customElements.get('uip-notifications-popover') || customElements.define('uip-notifications-popover', UipNotificationsPopover)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-notifications-popover': UipNotificationsPopover
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/notifications-popover.json