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 uipkge-ng@latest add notifications-popover
Manifest:https://uipkge.dev/r/angular/notifications-popover.jsonInstalls to:src/app/components/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.

Notification
interface Notification {
  id: string
  title: string
  body: string
  category: NotificationCategory
  timestamp: Date
  read: boolean
  actionUrl?: string
  actor?: string
}
NotificationsPopoverTriggerContext
interface NotificationsPopoverTriggerContext {
  $implicit: number
  unreadCount: number
}

npm dependencies

Theming

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

--popover

Files installed (2)

  • src/app/components/blocks/notifications-popover/notifications-popover.component.ts15.7 kB
    import {
      Component,
      ContentChild,
      Injector,
      TemplateRef,
      ViewChild,
      computed,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import { NgTemplateOutlet } from '@angular/common'
    import {
      Archive,
      BellOff,
      Calendar,
      CreditCard,
      FileText,
      GraduationCap,
      LucideAngularModule,
      Target,
      UserPlus,
      X,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiPopoverComponent,
      UiPopoverContentComponent,
      UiPopoverTriggerComponent,
    } from '@/ui/popover/popover.component'
    
    export type NotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system'
    
    export interface Notification {
      id: string
      title: string
      body: string
      category: NotificationCategory
      timestamp: Date
      read: boolean
      actionUrl?: string
      actor?: string
    }
    
    /** Context of the trigger template: `let-unreadCount="unreadCount"` (or `let-count` for $implicit). */
    export interface NotificationsPopoverTriggerContext {
      $implicit: number
      unreadCount: number
    }
    
    const categoryConfig: Record<NotificationCategory, { icon: LucideIconData; accent: string; bg: string }> = {
      hr: {
        icon: UserPlus,
        accent: 'bg-success',
        bg: 'bg-success/10 text-success',
      },
      payroll: {
        icon: CreditCard,
        accent: 'bg-info',
        bg: 'bg-info/10 text-info',
      },
      timeoff: {
        icon: Calendar,
        accent: 'bg-chart-3',
        bg: 'bg-chart-3/10 text-chart-3',
      },
      performance: {
        icon: Target,
        accent: 'bg-warning',
        bg: 'bg-warning/10 text-warning',
      },
      training: {
        icon: GraduationCap,
        accent: 'bg-primary',
        bg: 'bg-primary/10 text-primary',
      },
      system: {
        icon: FileText,
        accent: 'bg-muted-foreground',
        bg: 'bg-muted text-muted-foreground',
      },
    }
    
    const now = new Date()
    
    const initialNotifications: Notification[] = [
      {
        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 * 60 * 1000),
        read: false,
        actor: 'Sarah Connor',
      },
      {
        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 * 60 * 1000),
        read: false,
        actor: 'Marcus Rivera',
      },
      {
        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 * 60 * 60 * 1000),
        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 * 60 * 60 * 1000),
        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 * 60 * 60 * 1000),
        read: true,
      },
      {
        id: '6',
        title: 'Document requires signature',
        body: 'Updated Employee Handbook 2026 needs your acknowledgement.',
        category: 'system',
        timestamp: new Date(now.getTime() - 26 * 60 * 60 * 1000),
        read: true,
        actionUrl: '/documents',
      },
      {
        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 * 60 * 60 * 1000),
        read: true,
        actor: 'Alice Johnson',
      },
      {
        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 * 60 * 60 * 1000),
        read: true,
      },
    ]
    
    const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
    
    function formatTime(date: Date): string {
      const diffMs = now.getTime() - 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`
    }
    
    /**
     * NotificationsPopover -- the React block, 1:1. A header-grade notifications panel:
     * All / Unread filter tabs, Today / Earlier groups, category-coloured accent bars,
     * hover-dismiss, mark-all-read and a staggered slide-in.
     *
     * Bring your own trigger (React's `trigger` render prop, Vue's scoped slot): project an
     * `<ng-template>` whose context carries the live `unreadCount`, and put
     * `ui-popover-trigger` on the element -- the Radix `asChild` equivalent, so the button
     * itself gets aria-expanded / data-state and focus returns to it on close:
     *
     * ```html
     * <ui-notifications-popover>
     *   <ng-template let-unreadCount="unreadCount">
     *     <button ui-button ui-popover-trigger variant="ghost" size="icon" aria-label="Notifications">
     *       <lucide-icon [img]="Bell" class="size-4" />
     *       @if (unreadCount > 0) { <span class="bg-primary absolute top-1.5 right-1.5 size-2 rounded-full"></span> }
     *     </button>
     *   </ng-template>
     * </ui-notifications-popover>
     * ```
     *
     * The React block injects a small stylesheet (scrollbar, hover row, slide-in keyframe,
     * bottom fade); here the same rules are Tailwind utilities, the keyframe coming from
     * tw-animate-css (`animate-in fade-in-0 slide-in-from-bottom-1`), which Popover already uses.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-notifications-popover, [ui-notifications-popover]',
      standalone: true,
      // React renders no wrapper (Popover root has no element): keep the host out of layout.
      host: { class: 'contents' },
      imports: [
        NgTemplateOutlet,
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiPopoverComponent,
        UiPopoverTriggerComponent,
        UiPopoverContentComponent,
      ],
      template: `
        <ui-popover [open]="isOpen()" (openChange)="isOpen.set($event)">
          @if (triggerTemplate) {
            <ng-container
              *ngTemplateOutlet="
                triggerTemplate;
                context: { $implicit: unreadCount(), unreadCount: unreadCount() };
                injector: triggerInjector
              "
            />
          } @else {
            <span ui-popover-trigger></span>
          }
          <!-- React says w-full, but Radix's popper wrapper has min-width: max-content, so the panel
               is content-sized up to 380px (the empty state is narrow). Our Popover positions the
               panel itself, where w-full would mean the viewport: w-max reproduces React's width. -->
          <ui-popover-content
            align="end"
            [sideOffset]="8"
            class="notification-panel w-max 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>
                @if (unreadCount() > 0) {
                  <span
                    ui-badge
                    class="bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-xs font-bold tabular-nums"
                  >
                    {{ unreadCount() }}
                  </span>
                }
              </div>
              @if (unreadCount() > 0) {
                <button
                  ui-button
                  variant="ghost"
                  size="sm"
                  class="text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs"
                  (click)="markAllRead()"
                >
                  Mark all read
                </button>
              }
            </div>
    
            <div class="border-b px-4">
              <div class="flex gap-0">
                <button [class]="tabClass('all')" (click)="activeFilter.set('all')">
                  All
                  @if (activeFilter() === 'all') {
                    <span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>
                  }
                </button>
                <button [class]="tabClass('unread')" (click)="activeFilter.set('unread')">
                  Unread
                  @if (activeFilter() === 'unread') {
                    <span class="bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full"></span>
                  }
                </button>
              </div>
            </div>
    
            <div
              class="notification-scroll [&::-webkit-scrollbar-thumb]:bg-border relative max-h-[420px] overflow-y-auto after:pointer-events-none after:sticky after:right-0 after:bottom-0 after:left-0 after:-mt-6 after:block after:h-6 after:bg-[linear-gradient(to_bottom,transparent,var(--popover))] after:content-[''] [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:rounded-[2px] [&::-webkit-scrollbar-track]:bg-transparent"
            >
              @if (filteredNotifications().length === 0) {
                <div class="flex flex-col items-center justify-center py-14 text-center">
                  <div class="bg-muted mb-3 rounded-full p-3">
                    <lucide-icon [img]="BellOff" class="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 {{ activeFilter() === 'unread' ? 'unread ' : '' }}notifications
                  </p>
                </div>
              } @else {
                @if (groupedNotifications().today.length > 0) {
                  <div class="px-4 pt-3 pb-1">
                    <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Today</span>
                  </div>
                  @for (n of groupedNotifications().today; track n.id; let index = $index) {
                    <ng-container *ngTemplateOutlet="item; context: { $implicit: n, delay: index * 30 }" />
                  }
                }
    
                @if (groupedNotifications().earlier.length > 0) {
                  <div class="px-4 pt-3 pb-1">
                    <span class="text-muted-foreground text-xs font-semibold tracking-widest uppercase">Earlier</span>
                  </div>
                  @for (n of groupedNotifications().earlier; track n.id; let index = $index) {
                    <ng-container
                      *ngTemplateOutlet="
                        item;
                        context: { $implicit: n, delay: (groupedNotifications().today.length + index) * 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)="isOpen.set(false)"
              >
                <lucide-icon [img]="Archive" class="size-3" />
                View all notifications
              </a>
            </div>
          </ui-popover-content>
        </ui-popover>
    
        <ng-template #item let-n let-delay="delay">
          <div
            data-slot="notifications-popover"
            class="notification-item group animate-in fade-in-0 slide-in-from-bottom-1 animation-duration-250 fill-mode-both [&:hover]:bg-muted relative cursor-pointer transition-[background-color] duration-150 ease-[ease]"
            [style.animation-delay.ms]="delay"
            (click)="markAsRead(n.id)"
          >
            <div
              [class]="
                cn(
                  'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity',
                  !n.read ? categoryStyle(n).accent : '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', categoryStyle(n).bg)">
                <lucide-icon [img]="categoryStyle(n).icon" class="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>
                    @if (!n.read) {
                      <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
                type="button"
                class="text-muted-foreground hover:text-foreground focus-visible:ring-ring mt-0.5 shrink-0 rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:outline-none"
                title="Dismiss"
                aria-label="Dismiss"
                (click)="$event.stopPropagation(); dismissNotification(n.id)"
              >
                <lucide-icon [img]="X" class="size-3.5" aria-hidden="true" />
              </button>
            </div>
          </div>
        </ng-template>
      `,
    })
    export class UiNotificationsPopoverComponent {
      /** Your trigger: an `<ng-template>` receiving `unreadCount`; mark its element `ui-popover-trigger`. */
      @ContentChild(TemplateRef) triggerTemplate?: TemplateRef<NotificationsPopoverTriggerContext>
      @ViewChild(UiPopoverComponent, { static: true }) private popover!: UiPopoverComponent
    
      /** Lets `ui-popover-trigger` inside the consumer's template find this block's Popover root. */
      readonly triggerInjector = Injector.create({
        providers: [{ provide: UiPopoverComponent, useFactory: () => this.popover }],
      })
    
      protected readonly Archive = Archive
      protected readonly BellOff = BellOff
      protected readonly X = X
      protected readonly config = categoryConfig
      protected readonly cn = cn
      protected readonly formatTime = formatTime
    
      readonly notifications = signal<Notification[]>(initialNotifications)
      readonly activeFilter = signal<'all' | 'unread'>('all')
      readonly isOpen = signal(false)
    
      readonly unreadCount = computed(() => this.notifications().filter((n) => !n.read).length)
    
      readonly filteredNotifications = computed(() => {
        if (this.activeFilter() === 'unread') {
          return this.notifications().filter((n) => !n.read)
        }
        return this.notifications()
      })
    
      /**
       * The row template's `let-n` is untyped (`any`) in an ng-template context, and indexing the
       * config Record with it fails a strict consumer app (TS7053). A typed accessor keeps it typed.
       */
      protected categoryStyle(n: Notification) {
        return this.config[n.category]
      }
    
      readonly groupedNotifications = computed(() => {
        const today = this.filteredNotifications().filter((n) => n.timestamp >= todayStart)
        const earlier = this.filteredNotifications().filter((n) => n.timestamp < todayStart)
        return { today, earlier }
      })
    
      protected tabClass(filter: 'all' | 'unread'): string {
        return cn(
          'focus-visible:ring-ring relative rounded-t px-3 pb-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.activeFilter() === filter ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      markAsRead(id: string): void {
        this.notifications.update((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
      }
    
      markAllRead(): void {
        this.notifications.update((prev) => prev.map((n) => ({ ...n, read: true })))
      }
    
      dismissNotification(id: string): void {
        this.notifications.update((prev) => prev.filter((n) => n.id !== id))
      }
    }
    
  • src/app/components/blocks/notifications-popover/index.ts0.2 kB

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