{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "notifications-popover",
  "title": "Notifications Popover",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/notifications-popover/notifications-popover.component.ts",
      "content": "import {\n  Component,\n  ContentChild,\n  Injector,\n  TemplateRef,\n  ViewChild,\n  computed,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NgTemplateOutlet } from '@angular/common'\nimport {\n  Archive,\n  BellOff,\n  Calendar,\n  CreditCard,\n  FileText,\n  GraduationCap,\n  LucideAngularModule,\n  Target,\n  UserPlus,\n  X,\n  type LucideIconData,\n} from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiBadgeComponent } from '@/ui/badge/badge.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport {\n  UiPopoverComponent,\n  UiPopoverContentComponent,\n  UiPopoverTriggerComponent,\n} from '@/ui/popover/popover.component'\n\nexport type NotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system'\n\nexport interface Notification {\n  id: string\n  title: string\n  body: string\n  category: NotificationCategory\n  timestamp: Date\n  read: boolean\n  actionUrl?: string\n  actor?: string\n}\n\n/** Context of the trigger template: `let-unreadCount=\"unreadCount\"` (or `let-count` for $implicit). */\nexport interface NotificationsPopoverTriggerContext {\n  $implicit: number\n  unreadCount: number\n}\n\nconst categoryConfig: Record<NotificationCategory, { icon: LucideIconData; accent: string; bg: string }> = {\n  hr: {\n    icon: UserPlus,\n    accent: 'bg-success',\n    bg: 'bg-success/10 text-success',\n  },\n  payroll: {\n    icon: CreditCard,\n    accent: 'bg-info',\n    bg: 'bg-info/10 text-info',\n  },\n  timeoff: {\n    icon: Calendar,\n    accent: 'bg-chart-3',\n    bg: 'bg-chart-3/10 text-chart-3',\n  },\n  performance: {\n    icon: Target,\n    accent: 'bg-warning',\n    bg: 'bg-warning/10 text-warning',\n  },\n  training: {\n    icon: GraduationCap,\n    accent: 'bg-primary',\n    bg: 'bg-primary/10 text-primary',\n  },\n  system: {\n    icon: FileText,\n    accent: 'bg-muted-foreground',\n    bg: 'bg-muted text-muted-foreground',\n  },\n}\n\nconst now = new Date()\n\nconst initialNotifications: Notification[] = [\n  {\n    id: '1',\n    title: 'Time off approved',\n    body: 'Your annual leave request for Mar 15-18 has been approved by Sarah Connor.',\n    category: 'timeoff',\n    timestamp: new Date(now.getTime() - 12 * 60 * 1000),\n    read: false,\n    actor: 'Sarah Connor',\n  },\n  {\n    id: '2',\n    title: 'New employee onboarded',\n    body: 'Marcus Rivera has joined the Engineering team as Senior Developer.',\n    category: 'hr',\n    timestamp: new Date(now.getTime() - 45 * 60 * 1000),\n    read: false,\n    actor: 'Marcus Rivera',\n  },\n  {\n    id: '3',\n    title: 'Performance review due',\n    body: 'Annual performance review for 3 direct reports is due by end of this week.',\n    category: 'performance',\n    timestamp: new Date(now.getTime() - 2 * 60 * 60 * 1000),\n    read: false,\n  },\n  {\n    id: '4',\n    title: 'Payroll processed',\n    body: 'March 2026 payroll has been processed successfully. 103 employees paid.',\n    category: 'payroll',\n    timestamp: new Date(now.getTime() - 5 * 60 * 60 * 1000),\n    read: true,\n  },\n  {\n    id: '5',\n    title: 'Training enrollment open',\n    body: 'New course available: \"Leadership Fundamentals Q2\". Enroll before Mar 20.',\n    category: 'training',\n    timestamp: new Date(now.getTime() - 8 * 60 * 60 * 1000),\n    read: true,\n  },\n  {\n    id: '6',\n    title: 'Document requires signature',\n    body: 'Updated Employee Handbook 2026 needs your acknowledgement.',\n    category: 'system',\n    timestamp: new Date(now.getTime() - 26 * 60 * 60 * 1000),\n    read: true,\n    actionUrl: '/documents',\n  },\n  {\n    id: '7',\n    title: 'Time off request pending',\n    body: 'Alice Johnson requested sick leave for Mar 12. Awaiting your approval.',\n    category: 'timeoff',\n    timestamp: new Date(now.getTime() - 28 * 60 * 60 * 1000),\n    read: true,\n    actor: 'Alice Johnson',\n  },\n  {\n    id: '8',\n    title: 'Benefits enrollment closing',\n    body: \"Open enrollment period ends Mar 31. 12 employees haven't enrolled yet.\",\n    category: 'hr',\n    timestamp: new Date(now.getTime() - 48 * 60 * 60 * 1000),\n    read: true,\n  },\n]\n\nconst todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n\nfunction formatTime(date: Date): string {\n  const diffMs = now.getTime() - date.getTime()\n  const diffMin = Math.floor(diffMs / 60000)\n  if (diffMin < 1) return 'Just now'\n  if (diffMin < 60) return `${diffMin}m ago`\n  const diffHrs = Math.floor(diffMin / 60)\n  if (diffHrs < 24) return `${diffHrs}h ago`\n  const diffDays = Math.floor(diffHrs / 24)\n  if (diffDays === 1) return 'Yesterday'\n  return `${diffDays}d ago`\n}\n\n/**\n * NotificationsPopover -- the React block, 1:1. A header-grade notifications panel:\n * All / Unread filter tabs, Today / Earlier groups, category-coloured accent bars,\n * hover-dismiss, mark-all-read and a staggered slide-in.\n *\n * Bring your own trigger (React's `trigger` render prop, Vue's scoped slot): project an\n * `<ng-template>` whose context carries the live `unreadCount`, and put\n * `ui-popover-trigger` on the element -- the Radix `asChild` equivalent, so the button\n * itself gets aria-expanded / data-state and focus returns to it on close:\n *\n * ```html\n * <ui-notifications-popover>\n *   <ng-template let-unreadCount=\"unreadCount\">\n *     <button ui-button ui-popover-trigger variant=\"ghost\" size=\"icon\" aria-label=\"Notifications\">\n *       <lucide-icon [img]=\"Bell\" class=\"size-4\" />\n *       @if (unreadCount > 0) { <span class=\"bg-primary absolute top-1.5 right-1.5 size-2 rounded-full\"></span> }\n *     </button>\n *   </ng-template>\n * </ui-notifications-popover>\n * ```\n *\n * The React block injects a small stylesheet (scrollbar, hover row, slide-in keyframe,\n * bottom fade); here the same rules are Tailwind utilities, the keyframe coming from\n * tw-animate-css (`animate-in fade-in-0 slide-in-from-bottom-1`), which Popover already uses.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-notifications-popover, [ui-notifications-popover]',\n  standalone: true,\n  // React renders no wrapper (Popover root has no element): keep the host out of layout.\n  host: { class: 'contents' },\n  imports: [\n    NgTemplateOutlet,\n    LucideAngularModule,\n    UiBadgeComponent,\n    UiButtonComponent,\n    UiPopoverComponent,\n    UiPopoverTriggerComponent,\n    UiPopoverContentComponent,\n  ],\n  template: `\n    <ui-popover [open]=\"isOpen()\" (openChange)=\"isOpen.set($event)\">\n      @if (triggerTemplate) {\n        <ng-container\n          *ngTemplateOutlet=\"\n            triggerTemplate;\n            context: { $implicit: unreadCount(), unreadCount: unreadCount() };\n            injector: triggerInjector\n          \"\n        />\n      } @else {\n        <span ui-popover-trigger></span>\n      }\n      <!-- React says w-full, but Radix's popper wrapper has min-width: max-content, so the panel\n           is content-sized up to 380px (the empty state is narrow). Our Popover positions the\n           panel itself, where w-full would mean the viewport: w-max reproduces React's width. -->\n      <ui-popover-content\n        align=\"end\"\n        [sideOffset]=\"8\"\n        class=\"notification-panel w-max max-w-[380px] overflow-hidden rounded-lg border p-0 shadow-xl\"\n      >\n        <div class=\"flex items-center justify-between px-4 pt-4 pb-3\">\n          <div class=\"flex items-center gap-2.5\">\n            <h3 class=\"text-sm font-semibold tracking-tight\">Notifications</h3>\n            @if (unreadCount() > 0) {\n              <span\n                ui-badge\n                class=\"bg-primary/15 text-primary hover:bg-primary/15 h-5 rounded-full px-1.5 text-xs font-bold tabular-nums\"\n              >\n                {{ unreadCount() }}\n              </span>\n            }\n          </div>\n          @if (unreadCount() > 0) {\n            <button\n              ui-button\n              variant=\"ghost\"\n              size=\"sm\"\n              class=\"text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs\"\n              (click)=\"markAllRead()\"\n            >\n              Mark all read\n            </button>\n          }\n        </div>\n\n        <div class=\"border-b px-4\">\n          <div class=\"flex gap-0\">\n            <button [class]=\"tabClass('all')\" (click)=\"activeFilter.set('all')\">\n              All\n              @if (activeFilter() === 'all') {\n                <span class=\"bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full\"></span>\n              }\n            </button>\n            <button [class]=\"tabClass('unread')\" (click)=\"activeFilter.set('unread')\">\n              Unread\n              @if (activeFilter() === 'unread') {\n                <span class=\"bg-primary absolute right-0 bottom-0 left-0 h-[2px] rounded-t-full\"></span>\n              }\n            </button>\n          </div>\n        </div>\n\n        <div\n          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\"\n        >\n          @if (filteredNotifications().length === 0) {\n            <div class=\"flex flex-col items-center justify-center py-14 text-center\">\n              <div class=\"bg-muted mb-3 rounded-full p-3\">\n                <lucide-icon [img]=\"BellOff\" class=\"text-muted-foreground size-5\" />\n              </div>\n              <p class=\"text-sm font-medium\">All caught up</p>\n              <p class=\"text-muted-foreground mt-0.5 text-xs\">\n                No {{ activeFilter() === 'unread' ? 'unread ' : '' }}notifications\n              </p>\n            </div>\n          } @else {\n            @if (groupedNotifications().today.length > 0) {\n              <div class=\"px-4 pt-3 pb-1\">\n                <span class=\"text-muted-foreground text-xs font-semibold tracking-widest uppercase\">Today</span>\n              </div>\n              @for (n of groupedNotifications().today; track n.id; let index = $index) {\n                <ng-container *ngTemplateOutlet=\"item; context: { $implicit: n, delay: index * 30 }\" />\n              }\n            }\n\n            @if (groupedNotifications().earlier.length > 0) {\n              <div class=\"px-4 pt-3 pb-1\">\n                <span class=\"text-muted-foreground text-xs font-semibold tracking-widest uppercase\">Earlier</span>\n              </div>\n              @for (n of groupedNotifications().earlier; track n.id; let index = $index) {\n                <ng-container\n                  *ngTemplateOutlet=\"\n                    item;\n                    context: { $implicit: n, delay: (groupedNotifications().today.length + index) * 30 }\n                  \"\n                />\n              }\n            }\n          }\n        </div>\n\n        <div class=\"border-t px-4 py-2.5\">\n          <a\n            href=\"#\"\n            class=\"text-muted-foreground hover:text-foreground flex items-center justify-center gap-1.5 text-xs transition-colors\"\n            (click)=\"isOpen.set(false)\"\n          >\n            <lucide-icon [img]=\"Archive\" class=\"size-3\" />\n            View all notifications\n          </a>\n        </div>\n      </ui-popover-content>\n    </ui-popover>\n\n    <ng-template #item let-n let-delay=\"delay\">\n      <div\n        data-slot=\"notifications-popover\"\n        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]\"\n        [style.animation-delay.ms]=\"delay\"\n        (click)=\"markAsRead(n.id)\"\n      >\n        <div\n          [class]=\"\n            cn(\n              'absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity',\n              !n.read ? categoryStyle(n).accent : 'opacity-0'\n            )\n          \"\n        ></div>\n\n        <div class=\"flex gap-3 px-4 py-3\">\n          <div [class]=\"cn('mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg', categoryStyle(n).bg)\">\n            <lucide-icon [img]=\"categoryStyle(n).icon\" class=\"size-4\" />\n          </div>\n\n          <div class=\"min-w-0 flex-1\">\n            <div class=\"flex items-start justify-between gap-2\">\n              <p [class]=\"cn('text-[13px] leading-snug', !n.read ? 'font-semibold' : 'font-medium')\">\n                {{ n.title }}\n              </p>\n              <div class=\"flex shrink-0 items-center gap-1.5\">\n                <span class=\"text-muted-foreground text-xs whitespace-nowrap tabular-nums\">\n                  {{ formatTime(n.timestamp) }}\n                </span>\n                @if (!n.read) {\n                  <span class=\"bg-primary size-1.5 shrink-0 rounded-full\"></span>\n                }\n              </div>\n            </div>\n            <p class=\"text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed\">{{ n.body }}</p>\n          </div>\n\n          <button\n            type=\"button\"\n            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\"\n            title=\"Dismiss\"\n            aria-label=\"Dismiss\"\n            (click)=\"$event.stopPropagation(); dismissNotification(n.id)\"\n          >\n            <lucide-icon [img]=\"X\" class=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n        </div>\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiNotificationsPopoverComponent {\n  /** Your trigger: an `<ng-template>` receiving `unreadCount`; mark its element `ui-popover-trigger`. */\n  @ContentChild(TemplateRef) triggerTemplate?: TemplateRef<NotificationsPopoverTriggerContext>\n  @ViewChild(UiPopoverComponent, { static: true }) private popover!: UiPopoverComponent\n\n  /** Lets `ui-popover-trigger` inside the consumer's template find this block's Popover root. */\n  readonly triggerInjector = Injector.create({\n    providers: [{ provide: UiPopoverComponent, useFactory: () => this.popover }],\n  })\n\n  protected readonly Archive = Archive\n  protected readonly BellOff = BellOff\n  protected readonly X = X\n  protected readonly config = categoryConfig\n  protected readonly cn = cn\n  protected readonly formatTime = formatTime\n\n  readonly notifications = signal<Notification[]>(initialNotifications)\n  readonly activeFilter = signal<'all' | 'unread'>('all')\n  readonly isOpen = signal(false)\n\n  readonly unreadCount = computed(() => this.notifications().filter((n) => !n.read).length)\n\n  readonly filteredNotifications = computed(() => {\n    if (this.activeFilter() === 'unread') {\n      return this.notifications().filter((n) => !n.read)\n    }\n    return this.notifications()\n  })\n\n  /**\n   * The row template's `let-n` is untyped (`any`) in an ng-template context, and indexing the\n   * config Record with it fails a strict consumer app (TS7053). A typed accessor keeps it typed.\n   */\n  protected categoryStyle(n: Notification) {\n    return this.config[n.category]\n  }\n\n  readonly groupedNotifications = computed(() => {\n    const today = this.filteredNotifications().filter((n) => n.timestamp >= todayStart)\n    const earlier = this.filteredNotifications().filter((n) => n.timestamp < todayStart)\n    return { today, earlier }\n  })\n\n  protected tabClass(filter: 'all' | 'unread'): string {\n    return cn(\n      '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',\n      this.activeFilter() === filter ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',\n    )\n  }\n\n  markAsRead(id: string): void {\n    this.notifications.update((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))\n  }\n\n  markAllRead(): void {\n    this.notifications.update((prev) => prev.map((n) => ({ ...n, read: true })))\n  }\n\n  dismissNotification(id: string): void {\n    this.notifications.update((prev) => prev.filter((n) => n.id !== id))\n  }\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/notifications-popover/notifications-popover.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/notifications-popover/index.ts",
      "content": "export {\n  UiNotificationsPopoverComponent,\n  type Notification,\n  type NotificationCategory,\n  type NotificationsPopoverTriggerContext,\n} from './notifications-popover.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/notifications-popover/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/badge.json",
    "https://uipkge.dev/r/angular/popover.json"
  ],
  "description": "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.",
  "categories": [
    "communication",
    "dashboard",
    "overlay"
  ]
}