← alle entries

notification-popover

FC-0054notificationsblockv1.0.0

Bel 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.

zelfde component — andere tokens

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); }