v1.0

NotificationBell

Preview

Basic

Loading…

Preview

Code

ts
import NotificationBell from '@/components/overlay/NotificationBell';

src/components/overlay/NotificationBell.tsx

AI prompt

text
Build a header notification bell component (unread badge plus dropdown of notification cards) in React + TypeScript + Tailwind CSS.

## Look
- Root `relative`. Trigger: `relative p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors` around a 16px Bell. While open it keeps `bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200`.
- Badge (only when unread > 0): `absolute -top-1 -right-1 min-w-4 h-4 px-1 flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] font-bold leading-none tabular-nums`, showing "99+" above 99.
- Dropdown: `absolute right-0 mt-2 w-80 z-50 overflow-hidden p-0` on the opaque floating surface.
- Header: `p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between`. Left: text-sm font-bold slate-800 / dark slate-100, `flex items-center gap-2`, with a 16px Bell in indigo-600 / dark indigo-400, "Notifications", and "{n} unread" in 10px font-normal slate-400 / dark slate-500. Right, when unread > 0: a "Mark all read" button with a 12px CheckCheck icon, `text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50`.
- List: `<ol class="max-h-96 overflow-y-auto p-4 space-y-3">` of cards. Empty: `p-6 text-xs text-center text-slate-400 dark:text-slate-500` "Nothing yet. Releases, submissions and approvals land here."
- Footer, ALWAYS present (the dropdown only holds the latest few, so the way to the rest is a fixed part of it), outside the scroller: `border-t p-2` with a full-width link "See all notifications" plus a 12px ArrowRight, `flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800`. Clicking it closes the dropdown.

## The card (build it in the same file)
- `block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors` plus a tint.
- Line 1 (`flex justify-between gap-2 font-bold slate-800 / dark slate-100`): a 14px Bell in the tint's icon colour, the event label (truncating), and on the right the relative time (10px font-normal slate-400 / dark slate-500, with `title` set to "YYYY-MM-DD HH:MM").
- Line 2 (11px leading-relaxed slate-600 / dark slate-400): the reference in `font-mono font-semibold text-indigo-600 dark:text-indigo-400`, then "by {actor}" or "by the system".
- Tints for UNREAD cards, by type: approved → `bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900` (icon emerald-600 / dark emerald-400); rejected or failed → the same in rose; needs a human → amber; `request.*` → indigo; other workflow events → violet. READ cards, and unknown types, are neutral: `bg-slate-50 border-slate-200 dark:bg-slate-800/40 dark:border-slate-700`, icon slate-400 / dark slate-500. Colour means "still wants attention".
- The label comes from a type → text map ("request.approved" → "Request approved"), falling back to the raw type. Relative time: "Just now", "12m ago", "3h ago", "2d ago", then the ISO date after 7 days.
- A card with somewhere to go is a link (`hover:brightness-95 dark:hover:brightness-110`). One without is a button whose only job is marking itself read, disabled once read or while an update is pending.

## Behaviour
- Toggle on click. Clicking a card closes the dropdown if the card links somewhere, and if the card is unread calls `onMarkRead([eventId])`. "Mark all read" calls `onMarkAllRead()`.
- Run both inside `useTransition`: while pending, the buttons are disabled. If the promise resolves `{ error }`, show an error toast, then refresh the data (e.g. `router.refresh()`). The `unread` count comes from the server; without the callbacks the bell is display-only.

## API
- `items: HeaderNotification[]`, where `HeaderNotification = { eventId: number; type: string; createdAt: string; actor: string | null; ref: string | null; requestId: number | null; read: boolean }`. The card links to the request list when `requestId` is set.
- `unread: number`
- `onMarkRead?(ids: number[]): Promise<{ error?: string } | void>`, `onMarkAllRead?(): Promise<{ error?: string } | void>`

## Accessibility
- Trigger: `aria-label` "Notifications, 2 unread" (or just "Notifications"), `aria-expanded`, `title="Notifications"`.

## Demo
A right-aligned bell with `unread={2}` and four items:
- REQ-4471, request.approved, by Grace Hopper, unread
- REQ-4470, request.submitted, by Alan Turing, unread
- REQ-4468, request.rejected, by Ada Lovelace, read
- BATCH-88, batch.released, by the system, read, with no request

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react';
import { ArrowRight, Bell, CheckCheck } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import NotificationCard from './NotificationCard';
import type { HeaderNotification } from '@/components/overlay/NotificationCard';

/**
 * The header bell, rendering the mockup's notification cards
 * (docs/dashboard.html → `renderNotifications()`): a tinted, bordered card per
 * row with a bold title, the relative time on the right, and the detail beneath
 * — not a flat list.
 *
 * Rows are `events`, the audit trail that replaced Lark, and "unread" is this
 * user's own read state, so two people clearing their bells don't affect each
 * other. A row links to its request where it has one; there is no request
 * detail page yet (DASHBOARD-SPEC §4), so that link goes to the list.
 */
export default function NotificationBell({
  items,
  unread,
  onMarkRead,
  onMarkAllRead,
}: {
  items: HeaderNotification[];
  unread: number;
  /** Persist a read. Omit for a purely optimistic bell. */
  onMarkRead?: (eventIds: number[]) => Promise<{ error?: string } | void>;
  onMarkAllRead?: () => Promise<{ error?: string } | void>;
}) {
  const [open, setOpen] = useState(false);
  const [pending, startTransition] = useTransition();
  const ref = useRef<HTMLDivElement>(null);
  const router = useRouter();

  useDismiss(ref, open, () => setOpen(false));

  const readOne = (eventId: number) => {
    startTransition(async () => {
      const result = (await onMarkRead?.([eventId])) ?? {};
      if ('error' in result) toast.error(result.error);
      router.refresh();
    });
  };

  const readAll = () => {
    startTransition(async () => {
      const result = (await onMarkAllRead?.()) ?? {};
      if ('error' in result) toast.error(result.error);
      router.refresh();
    });
  };

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        title="Notifications"
        aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
        aria-expanded={open}
        className={cn(
          'relative p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors',
          open && 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-200',
        )}
      >
        <Bell className="w-4 h-4" />
        {unread > 0 && (
          <span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] font-bold leading-none tabular-nums">
            {unread > 99 ? '99+' : unread}
          </span>
        )}
      </button>

      {/* `panel-solid` is `.panel` without the translucency: this floats over
          page content rather than sitting on the gradient ground, and at 60%
          white the rows behind it read straight through the list. Shared with
          the appearance and account menus so the three cannot drift apart. */}
      {open && (
        <div className="absolute right-0 mt-2 w-80 panel panel-solid p-0 z-50 overflow-hidden">
          {/* The mockup's drawer header, kept verbatim in shape: bell, bold
              title, and the dismissive control on the right. */}
          <div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
            <span className="font-bold text-slate-800 dark:text-slate-100 text-sm flex items-center gap-2">
              <Bell className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
              Notifications
              {unread > 0 && (
                <span className="text-[10px] font-normal text-slate-400 dark:text-slate-500">
                  {unread} unread
                </span>
              )}
            </span>
            {unread > 0 && (
              <button
                type="button"
                onClick={readAll}
                disabled={pending}
                className="flex items-center gap-1 text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
              >
                <CheckCheck className="w-3 h-3" />
                Mark all read
              </button>
            )}
          </div>

          {items.length === 0 ? (
            <p className="p-6 text-xs text-center text-slate-400 dark:text-slate-500">
              Nothing yet. Releases, submissions and approvals land here.
            </p>
          ) : (
            <ol className="max-h-96 overflow-y-auto custom-scrollbar p-4 space-y-3">
              {items.map((n) => (
                <li key={n.eventId}>
                  <NotificationCard
                    item={n}
                    // There is no request detail page yet (DASHBOARD-SPEC §4),
                    // so a card carrying a request goes to the list.
                    href={n.requestId ? '/requests' : null}
                    disabled={pending}
                    onActivate={() => {
                      if (n.requestId) setOpen(false);
                      if (!n.read) readOne(n.eventId);
                    }}
                  />
                </li>
              ))}
            </ol>
          )}

          {/* Always present, including on an empty bell: the dropdown holds the
              latest NOTIFICATION_LIMIT only, so the way to the rest has to be a
              fixed part of the surface rather than something that appears once
              there is enough history to need it. Outside the scrolling <ol> so
              it stays pinned to the bottom edge. */}
          <div className="border-t border-slate-200 dark:border-slate-700 p-2">
            <Link
              href="/notifications"
              onClick={() => setOpen(false)}
              className="flex items-center justify-center gap-1.5 w-full rounded-lg px-3 py-2 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
            >
              See all notifications
              <ArrowRight className="w-3 h-3" />
            </Link>
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
items*HeaderNotification[]—
unread*number—
onMarkRead(eventIds: number[]) => Promise<{ error?: string } | void>—Persist a read. Omit for a purely optimistic bell.
onMarkAllRead() => Promise<{ error?: string } | void>—