notification-popover
FC-0054notificationsblockv1.0.0Bel met teller voor ongelezen meldingen; een klik opent een paneel met de lijst, 'alles gelezen' bovenin, elke melding gelezen zodra je hem aanraakt. Componeert popover en button.
2 ongelezen — de gelezen-staat bewaart het project (hier in de demo-state).
blocks/notification-popover/notification-popover.tsx
/**
* NotificationPopover — de bel met een teller voor ongelezen meldingen; een
* klik opent een paneel met de lijst, 'alles gelezen' bovenin, en elke
* melding wordt gelezen zodra je hem aanraakt. Meldingen komen één voor één
* scherp in beeld (blur + schuiven).
*
* Componeert popover (FC-0007, bottom-end) en button (FC-0003). De bron
* had #11111198-glas, witte tekst en een eigen absolute panel; hier tokens,
* de datum via Intl met locale, en een gecontroleerd datamodel: het project
* bewaart de gelezen-staat (onChange). Lucide-bel → inline SVG.
*/
"use client";
import { useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Button } from "../../primitives/ui/button";
import { Popover } from "../../primitives/ui/popover";
import { usePress } from "../../primitives/ui/press";
export interface Notification {
id: string;
title: string;
description?: string;
/** ISO-string of Date. */
timestamp: string | Date;
read: boolean;
}
export interface NotificationPopoverProps {
notifications: readonly Notification[];
onChange: (notifications: Notification[]) => void;
locale?: string;
labels?: { title?: string; markAll?: string; open?: string; empty?: string };
dateFormat?: Intl.DateTimeFormatOptions;
className?: string;
}
function Bel() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 0112 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 003.4 0" />
</svg>
);
}
function Melding({ n, i, tijd, onLees, reduced }: { n: Notification; i: number; tijd: string; onLees: () => void; reduced: boolean }) {
const veer = useSpringTransition("default", { mass: "content" });
const { pressProps } = usePress({ onPress: onLees, disabled: n.read });
return (
<motion.li
{...pressProps}
className="fp-notif__item"
data-gelezen={n.read ? "" : undefined}
initial={reduced ? false : { opacity: 0, x: 20, filter: "blur(10px)" }}
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
transition={{ ...veer, delay: reduced ? 0 : i * 0.08 }}
>
<span className="fp-notif__kop">
<span className="fp-notif__titel">
{!n.read && <span aria-hidden="true" className="fp-notif__stip" />}
{n.title}
</span>
<time className="fp-notif__tijd">{tijd}</time>
</span>
{n.description && <span className="fp-notif__tekst">{n.description}</span>}
</motion.li>
);
}
export function NotificationPopover({ notifications, onChange, locale = "nl-NL", labels, dateFormat = { day: "numeric", month: "short" }, className }: NotificationPopoverProps) {
const t = { title: "Meldingen", markAll: "Alles gelezen", open: "Meldingen openen", empty: "Geen meldingen.", ...labels };
const reduced = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const ongelezen = notifications.filter((n) => !n.read).length;
const fmt = new Intl.DateTimeFormat(locale, dateFormat);
const tijd = (v: string | Date) => fmt.format(typeof v === "string" ? new Date(v) : v);
return (
<div className={["fp-notif", className].filter(Boolean).join(" ")}>
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-end"
aria-label={t.title}
className="fp-notif__popover"
anchor={
<Button variant="ghost" className="fp-notif__bel" aria-label={`${t.open}${ongelezen ? ` (${ongelezen} ongelezen)` : ""}`} aria-expanded={open} onPress={() => setOpen(!open)}>
<Bel />
{ongelezen > 0 && <span aria-hidden="true" className="fp-notif__teller">{ongelezen}</span>}
</Button>
}
>
<div className="fp-notif__paneel">
<div className="fp-notif__balk">
<h3 className="fp-notif__h">{t.title}</h3>
<Button variant="ghost" className="fp-notif__alles" disabled={ongelezen === 0} onPress={() => onChange(notifications.map((n) => ({ ...n, read: true })))}>
{t.markAll}
</Button>
</div>
<ul className="fp-notif__lijst">
{notifications.length === 0 && <li className="fp-notif__leeg">{t.empty}</li>}
{notifications.map((n, i) => (
<Melding key={n.id} n={n} i={i} tijd={tijd(n.timestamp)} reduced={reduced} onLees={() => onChange(notifications.map((x) => (x.id === n.id ? { ...x, read: true } : x)))} />
))}
</ul>
</div>
</Popover>
</div>
);
}
blocks/notification-popover/notification-popover.css
/** NotificationPopover — importeren in de app-root (registry-site: app/globals.css). */
.fp-notif { display: inline-block; }
.fp-notif__bel { position: relative; width: 2.5rem; height: 2.5rem; padding: 0; justify-content: center; }
.fp-notif__teller {
position: absolute; top: -0.25rem; right: -0.25rem;
display: flex; align-items: center; justify-content: center;
min-width: 1.25rem; height: 1.25rem; padding: 0 0.25rem;
font-size: 0.6875rem; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--primary-foreground); background: var(--primary);
border: 2px solid var(--background); border-radius: 999px;
}
.fp-notif__popover .fp-popover { width: 20rem; max-height: 25rem; overflow-y: auto; padding: 0; }
.fp-notif__paneel { display: flex; flex-direction: column; }
.fp-notif__balk { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.fp-notif__h { margin: 0; font: inherit; font-size: 0.875rem; font-weight: 500; }
.fp-notif__alles { font-size: 0.75rem; padding: 0.25rem 0.5rem; }
.fp-notif__lijst { list-style: none; margin: 0; padding: 0; }
.fp-notif__item {
display: flex; flex-direction: column; gap: 0.25rem;
padding: 1rem; cursor: pointer;
border-bottom: 1px solid var(--border);
transition: background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-notif__item:last-child { border-bottom: 0; }
.fp-notif__item:hover { background: var(--muted); }
.fp-notif__item[data-gelezen] { cursor: default; }
.fp-notif__item[data-gelezen] .fp-notif__titel { color: var(--muted-foreground); font-weight: 400; }
.fp-notif__kop { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.5rem; }
.fp-notif__titel { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; font-weight: 500; }
.fp-notif__stip { width: 0.375rem; height: 0.375rem; flex-shrink: 0; border-radius: 50%; background: var(--primary); }
.fp-notif__tijd { flex-shrink: 0; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-notif__tekst { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-notif__leeg { padding: 1.5rem; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }