NotificationCard
Preview
Basic
Loading…
Preview
Code
ts
import NotificationCard from '@/components/overlay/NotificationCard';src/components/overlay/NotificationCard.tsx
AI prompt
text
Build a notification card row component in React + TypeScript + Tailwind CSS.
## Look
- Card: `block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors` plus a tint (below).
- Line 1: `flex items-center justify-between gap-2 font-bold text-slate-800 dark:text-slate-100`.
- Left (`flex items-center gap-1.5 min-w-0`): a 14px lucide Bell in the tint's icon colour, then the event label, truncating.
- Right: the relative time, `text-[10px] font-normal text-slate-400 dark:text-slate-500 shrink-0`, with a `title` of the exact "YYYY-MM-DD HH:MM".
- Line 2: `text-[11px] leading-relaxed text-slate-600 dark:text-slate-400`. First the reference, if any, in `font-mono font-semibold text-indigo-600 dark:text-indigo-400` followed by a space. Then "by {actor}", or "by the system" when the actor is null.
- Tint by event type, for UNREAD cards only:
- approved → card `bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900`, icon `text-emerald-600 dark:text-emerald-400`
- rejected or failed → the same recipe in rose
- partial or needs a human → amber
- `request.*` → indigo
- other workflow events → violet
- unknown → neutral
- A READ card is always neutral: `bg-slate-50 border-slate-200 dark:bg-slate-800/40 dark:border-slate-700`, icon `text-slate-400 dark:text-slate-500`. Colour is what marks a card as still wanting attention; keeping it after the read would make it decorative.
## Behaviour
- Label: a type → text map ("request.created" → "Request created", "request.approved" → "Request approved"). Fall back to the raw type string rather than hiding an unstyled event.
- Relative time from the ISO `createdAt`: under a minute "Just now", then "Nm ago", "Nh ago" and "Nd ago" up to 7 days, after that the date (YYYY-MM-DD).
- With `href`: a link (`hover:brightness-95 dark:hover:brightness-110`) with `onClick={onActivate}`. Navigation is never blocked, so `disabled` is ignored.
- Without `href`: a button whose only job is marking itself read. It is disabled when `disabled` is set or the item is already read (`cursor-default`, no hover effect): a control that looks pressable but does nothing is worse than a flat card.
## API
- `item: HeaderNotification`, where `HeaderNotification = { eventId: number; type: string; createdAt: string; actor: string | null; ref: string | null; requestId: number | null; read: boolean }` (exported)
- `href?: string | null`, `onActivate?(): void`, `disabled?` = false
## Demo
Three cards stacked in an opaque card with `divide-y divide-slate-100 dark:divide-slate-800`, each linking to "#":
- REQ-4471, request.approved, by Grace Hopper, unread
- REQ-4470, request.submitted, by Alan Turing, unread
- REQ-4468, request.rejected, by Ada Lovelace, read
## 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';
export type HeaderNotification = {
/** The EVENT id — `notifications.id` is an implementation detail of read state. */
eventId: number;
type: string;
createdAt: string;
actor: string | null;
ref: string | null;
requestId: number | null;
read: boolean;
};
/* Origin: bonus-adjustment (96S2), verbatim. */
import Link from 'next/link';
import { Bell } from 'lucide-react';
import { cn } from '@/lib/cn';
import { eventCardTint, eventLabel, relativeTime } from '@/lib/events';
/**
* One notification, as the mockup's tinted card (docs/dashboard.html →
* `renderNotifications()`): `p-3 <tint> border rounded-lg text-xs space-y-1
* shadow-sm`, a bold title, the relative time on the right, and the ref and
* actor beneath.
*
* Shared by the header bell and the full notifications page. It was the bell's
* inline markup first; the page needs the identical row, and two copies of a
* tint-plus-title recipe drift the moment one of them gains a field.
*
* A card that belongs to a request is a LINK; one that does not is a button
* whose only job is to mark itself read, and it is disabled once it is — a
* control that looks pressable but does nothing is worse than a flat card.
*/
export default function NotificationCard({
item,
href,
onActivate,
disabled = false,
}: {
item: HeaderNotification;
/** Where the card goes when it has somewhere to go. Null renders the button form. */
href?: string | null;
/** Marking read, closing the dropdown — whatever the caller does on a click. */
onActivate?: () => void;
/** Caller's in-flight state. Ignored by the link form: navigation must not block. */
disabled?: boolean;
}) {
const tint = eventCardTint(item.type, item.read);
const body = (
<>
<div className="flex items-center justify-between font-bold text-slate-800 dark:text-slate-100 gap-2">
<span className="flex items-center gap-1.5 min-w-0">
<Bell className={cn('w-3.5 h-3.5 shrink-0', tint.icon)} />
<span className="truncate">{eventLabel(item.type)}</span>
</span>
<span
className="text-[10px] text-slate-400 dark:text-slate-500 font-normal shrink-0"
title={item.createdAt.slice(0, 16).replace('T', ' ')}
>
{relativeTime(item.createdAt)}
</span>
</div>
<p className="text-slate-600 dark:text-slate-400 text-[11px] leading-relaxed">
{item.ref && (
<span className="font-mono font-semibold text-indigo-600 dark:text-indigo-400">
{item.ref}{' '}
</span>
)}
{item.actor ? `by ${item.actor}` : 'by the system'}
</p>
</>
);
// The mockup's card recipe, verbatim.
const cardClasses = cn(
'block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors',
tint.card,
);
if (href) {
return (
<Link
href={href}
className={cn(cardClasses, 'hover:brightness-95 dark:hover:brightness-110')}
onClick={onActivate}
>
{body}
</Link>
);
}
return (
<button
type="button"
disabled={disabled || item.read}
onClick={onActivate}
className={cn(
cardClasses,
item.read ? 'cursor-default' : 'hover:brightness-95 dark:hover:brightness-110',
)}
>
{body}
</button>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
item* | HeaderNotification | — | |
href | string | null | — | Where the card goes when it has somewhere to go. Null renders the button form. |
onActivate | () => void | — | Marking read, closing the dropdown — whatever the caller does on a click. |
disabled | boolean | false | Caller's in-flight state. Ignored by the link form: navigation must not block. |