{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "notifications-popover",
  "title": "Notifications Popover",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-svelte/blocks/notifications-popover/NotificationsPopover.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Component, Snippet } from 'svelte'\n\n  export interface NotificationsPopoverProps {\n    /** The consumer's own trigger (e.g. their Bell button). Receives the\n     *  trigger `props` (spread them onto your element) and the live\n     *  `unreadCount` for the badge. */\n    children?: Snippet<[{ props: Record<string, unknown>; unreadCount: number }]>\n  }\n</script>\n\n<script lang=\"ts\">\n  import {\n    Archive,\n    BellOff,\n    Calendar,\n    CreditCard,\n    FileText,\n    GraduationCap,\n    Target,\n    UserPlus,\n    X,\n  } from '@lucide/svelte'\n  import { Badge } from '$lib/components/ui/badge'\n  import { Button } from '$lib/components/ui/button'\n  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover'\n\n  let { children }: NotificationsPopoverProps = $props()\n\n  type NotificationCategory = 'hr' | 'payroll' | 'timeoff' | 'performance' | 'training' | 'system'\n\n  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  const categoryConfig: Record<NotificationCategory, { icon: Component; 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\n  const now = new Date()\n\n  let notifications = $state<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\n  let activeFilter = $state<'all' | 'unread'>('all')\n  let isOpen = $state(false)\n\n  const unreadCount = $derived(notifications.filter((n) => !n.read).length)\n\n  const filteredNotifications = $derived(\n    activeFilter === 'unread' ? notifications.filter((n) => !n.read) : notifications,\n  )\n\n  const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n\n  const groupedNotifications = $derived.by(() => {\n    const today = filteredNotifications.filter((n) => n.timestamp >= todayStart)\n    const earlier = filteredNotifications.filter((n) => n.timestamp < todayStart)\n    return { today, earlier }\n  })\n\n  function 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  function markAsRead(id: string) {\n    const n = notifications.find((n) => n.id === id)\n    if (n) n.read = true\n  }\n\n  function markAllRead() {\n    notifications.forEach((n) => (n.read = true))\n  }\n\n  function dismissNotification(id: string) {\n    notifications = notifications.filter((n) => n.id !== id)\n  }\n</script>\n\n{#snippet notificationRow(n: Notification, index: number)}\n  {@const config = categoryConfig[n.category]}\n  {@const RowIcon = config.icon}\n  <div\n    class=\"notification-item group relative\"\n    style=\"animation-delay: {index * 30}ms\"\n    onclick={() => markAsRead(n.id)}\n    onkeydown={(e) => e.key === 'Enter' && markAsRead(n.id)}\n    role=\"button\"\n    tabindex=\"0\"\n  >\n    <div\n      class=\"absolute top-2 bottom-2 left-0 w-[3px] rounded-r-full transition-opacity {!n.read\n        ? config.accent\n        : 'opacity-0'}\"\n    ></div>\n\n    <div class=\"flex gap-3 px-4 py-3\">\n      <div class=\"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg {config.bg}\">\n        <RowIcon 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=\"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            {/if}\n          </div>\n        </div>\n        <p class=\"text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed\">\n          {n.body}\n        </p>\n      </div>\n\n      <button\n        class=\"text-muted-foreground hover:text-foreground mt-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\"\n        title=\"Dismiss\"\n        onclick={(e) => {\n          e.stopPropagation()\n          dismissNotification(n.id)\n        }}\n      >\n        <X class=\"size-3.5\" />\n      </button>\n    </div>\n  </div>\n{/snippet}\n\n<Popover bind:open={isOpen}>\n  <PopoverTrigger>\n    {#snippet child({ props })}\n      {@render children?.({ props, unreadCount })}\n    {/snippet}\n  </PopoverTrigger>\n  <!-- data-slot lives on the panel: Popover itself renders no element. -->\n  <PopoverContent\n    data-slot=\"notifications-popover\"\n    align=\"end\"\n    sideOffset={8}\n    class=\"notification-panel w-96 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          <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          </Badge>\n        {/if}\n      </div>\n      {#if unreadCount > 0}\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          class=\"text-muted-foreground hover:text-foreground -mr-1 h-7 px-2 text-xs\"\n          onclick={markAllRead}\n        >\n          Mark all read\n        </Button>\n      {/if}\n    </div>\n\n    <div class=\"border-b px-4\">\n      <div class=\"flex gap-0\">\n        <button\n          class=\"relative px-3 pb-2.5 text-xs font-medium transition-colors {activeFilter === 'all'\n            ? 'text-foreground'\n            : 'text-muted-foreground hover:text-foreground'}\"\n          onclick={() => (activeFilter = 'all')}\n        >\n          All\n          {#if activeFilter === 'all'}\n            <span class=\"bg-primary absolute right-0 bottom-0 left-0 h-px rounded-t-full\"></span>\n          {/if}\n        </button>\n        <button\n          class=\"relative px-3 pb-2.5 text-xs font-medium transition-colors {activeFilter === 'unread'\n            ? 'text-foreground'\n            : 'text-muted-foreground hover:text-foreground'}\"\n          onclick={() => (activeFilter = 'unread')}\n        >\n          Unread\n          {#if activeFilter === 'unread'}\n            <span class=\"bg-primary absolute right-0 bottom-0 left-0 h-px rounded-t-full\"></span>\n          {/if}\n        </button>\n      </div>\n    </div>\n\n    <div class=\"notification-scroll max-h-[26.25rem] overflow-y-auto\">\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            <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          {#each groupedNotifications.today as n, index (n.id)}\n            {@render notificationRow(n, index)}\n          {/each}\n        {/if}\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          {#each groupedNotifications.earlier as n, index (n.id)}\n            {@render notificationRow(n, groupedNotifications.today.length + index)}\n          {/each}\n        {/if}\n      {/if}\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        onclick={() => (isOpen = false)}\n      >\n        <Archive class=\"size-3\" />\n        View all notifications\n      </a>\n    </div>\n  </PopoverContent>\n</Popover>\n\n<style>\n  /* Thin scrollbar, staggered slide-in rows, and the bottom fade that hints\n     at more content. Keyframes, scrollbar, and ::after fade can't be\n     Tailwind utilities, so they live here. */\n  .notification-panel {\n    --scrollbar-size: 4px;\n  }\n\n  .notification-scroll::-webkit-scrollbar {\n    width: var(--scrollbar-size);\n  }\n\n  .notification-scroll::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  .notification-scroll::-webkit-scrollbar-thumb {\n    background: var(--border);\n    border-radius: 2px;\n  }\n\n  .notification-item {\n    cursor: pointer;\n    transition: background-color 0.15s ease;\n    animation: notif-slide-in 0.25s ease both;\n  }\n\n  .notification-item:hover {\n    background-color: var(--muted);\n  }\n\n  @keyframes notif-slide-in {\n    from {\n      opacity: 0;\n      transform: translateY(4px);\n    }\n    to {\n      opacity: 1;\n      transform: translateY(0);\n    }\n  }\n\n  .notification-scroll {\n    position: relative;\n  }\n\n  .notification-scroll::after {\n    content: '';\n    position: sticky;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    display: block;\n    height: 24px;\n    margin-top: -24px;\n    background: linear-gradient(to bottom, transparent, var(--popover));\n    pointer-events: none;\n  }\n</style>\n",
      "type": "registry:block",
      "target": "~/src/lib/components/blocks/notifications-popover/NotificationsPopover.svelte"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/button.json",
    "https://uipkge.dev/r/svelte/badge.json",
    "https://uipkge.dev/r/svelte/popover.json"
  ],
  "description": "Header-grade notifications panel. Snippet-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. Children receive trigger `props` plus `unreadCount` so the trigger can show a badge.",
  "categories": [
    "communication",
    "dashboard",
    "overlay"
  ]
}