← alle entries

chat-layout

FC-0059chatblockv1.0.0

Driedelige berichten-indeling: navigatie, gesprekkenlijst met zoekveld, en het gesprek met kop, berichtenvlak (slot) en typbalk met bijlage-menu. Smal: lijst óf gesprek met terugknop. Componeert search-field, field, button en popover.

zelfde component — andere tokens
Femke de GrootPraktijk Zon

Hoi! Kun je het ontwerp nog eens sturen?

Ja, komt eraan.

blocks/chat-layout/chat-layout.tsx
/**
 * ChatLayout — de driedelige berichten-indeling: een smalle navigatie links,
 * de gesprekkenlijst met zoekveld, en het gesprek zelf met kop (wie, acties),
 * berichtenvlak en een balk om te typen (bijlage-menu, tekst, versturen).
 * Berichten zijn een slot: hoe een bericht eruitziet en waar het vandaan
 * komt, bepaalt het project (FORMA heeft zijn eigen berichtensysteem).
 *
 * De bron stapelde shadcn sidebar, resizable-panels, dropdown-menu, avatar,
 * scroll-area en dertig lucide-iconen, met twintig ingebakken contacten.
 * Hier: CSS-grid (geen sleepbare scheiding), search-field (FC-0048), field +
 * button (FC-0005/0003) voor de typbalk, popover (FC-0007) voor het bijlage-
 * menu; alles verder slots en contracten. Op smalle schermen toont hij lijst
 * óf gesprek, met een terugknop.
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { Field } from "../../primitives/ui/input";
import { Popover } from "../../primitives/ui/popover";
import { usePress } from "../../primitives/ui/press";
import { SearchField } from "../../primitives/ui/search-field";

export interface ChatContact {
  id: string;
  name: string;
  preview?: string;
  avatar?: string;
  /** Regel onder de naam in de kop ("Online", "Klant sinds 2024"). */
  subtitle?: string;
  unread?: number;
}

export interface ChatNavItem {
  id: string;
  label: string;
  icon: React.ReactNode;
  onPress: () => void;
  active?: boolean;
}

export interface ChatAttachAction {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
}

export interface ChatLayoutProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  contacts: readonly ChatContact[];
  activeId?: string | null;
  onSelectContact: (contact: ChatContact) => void;
  /** Het berichtenvlak van het actieve gesprek. */
  children?: React.ReactNode;
  onSend: (text: string) => void | Promise<void>;
  nav?: readonly ChatNavItem[];
  /** Acties rechts in de kop (bellen, zoeken …). */
  headerActions?: React.ReactNode;
  attachActions?: readonly ChatAttachAction[];
  labels?: { chats?: string; search?: string; type?: string; send?: string; attach?: string; back?: string; empty?: string };
}

function Portret({ c, maat = 48 }: { c: ChatContact; maat?: number }) {
  return c.avatar ? (
    // eslint-disable-next-line @next/next/no-img-element
    <img className="fp-chat__portret" src={c.avatar} alt="" width={maat} height={maat} loading="lazy" />
  ) : (
    <span className="fp-chat__portret fp-chat__portret--letter" aria-hidden="true">{c.name.charAt(0)}</span>
  );
}

function ContactRij({ c, actief, onPress }: { c: ChatContact; actief: boolean; onPress: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <li>
      <button {...pressProps} type="button" className="fp-chat__contact" aria-current={actief ? "true" : undefined} data-pressed={pressed ? "" : undefined}>
        <Portret c={c} />
        <span className="fp-chat__contact-tekst">
          <span className="fp-chat__contact-naam">{c.name}</span>
          {c.preview && <span className="fp-chat__contact-preview">{c.preview}</span>}
        </span>
        {c.unread ? <span className="fp-chat__ongelezen">{c.unread}</span> : null}
      </button>
    </li>
  );
}

function NavKnop({ n }: { n: ChatNavItem }) {
  const { pressed, pressProps } = usePress({ onPress: n.onPress });
  return (
    <button {...pressProps} type="button" className="fp-chat__navknop" aria-label={n.label} aria-current={n.active ? "page" : undefined} data-pressed={pressed ? "" : undefined}>
      {n.icon}
    </button>
  );
}

function BijlageItem({ a, onDone }: { a: ChatAttachAction; onDone: () => void }) {
  const { pressProps } = usePress({ onPress: () => { a.onSelect(); onDone(); } });
  return (
    <li {...pressProps} role="menuitem" className="fp-chat__bijlage-item">
      {a.icon && <span className="fp-chat__bijlage-icoon">{a.icon}</span>}
      {a.label}
    </li>
  );
}

export function ChatLayout({ contacts, activeId, onSelectContact, children, onSend, nav = [], headerActions, attachActions = [], labels, className, ...rest }: ChatLayoutProps) {
  const t = { chats: "Gesprekken", search: "Zoek of begin een gesprek", type: "Typ een bericht", send: "Versturen", attach: "Bijlage", back: "Terug naar gesprekken", empty: "Kies een gesprek.", ...labels };
  const [zoek, setZoek] = useState("");
  const [tekst, setTekst] = useState("");
  const [bijlageOpen, setBijlageOpen] = useState(false);
  const [toonGesprek, setToonGesprek] = useState(false);
  const actief = contacts.find((c) => c.id === activeId) ?? null;
  const zichtbaar = contacts.filter((c) => c.name.toLowerCase().includes(zoek.trim().toLowerCase()));
  const terug = usePress({ onPress: () => setToonGesprek(false) });

  const verstuur = (e: React.FormEvent) => {
    e.preventDefault();
    const v = tekst.trim();
    if (!v) return;
    void onSend(v);
    setTekst("");
  };

  return (
    <div {...rest} className={["fp-chat", nav.length ? "fp-chat--met-nav" : "", toonGesprek ? "fp-chat--gesprek" : "", className].filter(Boolean).join(" ")}>
      {nav.length > 0 && (
        <nav className="fp-chat__nav" aria-label="Berichten">
          {nav.map((n) => <NavKnop key={n.id} n={n} />)}
        </nav>
      )}
      <aside className="fp-chat__lijst">
        <div className="fp-chat__lijst-kop">
          <h2 className="fp-chat__h">{t.chats}</h2>
        </div>
        <div className="fp-chat__zoek">
          <SearchField placeholder={t.search} shortcut="" value={zoek} onChange={(e) => setZoek(e.target.value)} />
        </div>
        <ul className="fp-chat__contacten">
          {zichtbaar.map((c) => (
            <ContactRij key={c.id} c={c} actief={c.id === activeId} onPress={() => { onSelectContact(c); setToonGesprek(true); }} />
          ))}
        </ul>
      </aside>
      <section className="fp-chat__gesprek" aria-label={actief ? `Gesprek met ${actief.name}` : undefined}>
        {actief ? (
          <>
            <header className="fp-chat__kop">
              <button {...terug.pressProps} type="button" className="fp-chat__terug" aria-label={t.back} data-pressed={terug.pressed ? "" : undefined}>
                <svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M15 6l-6 6 6 6" /></svg>
              </button>
              <Portret c={actief} />
              <span className="fp-chat__kop-tekst">
                <span className="fp-chat__kop-naam">{actief.name}</span>
                {actief.subtitle && <span className="fp-chat__kop-sub">{actief.subtitle}</span>}
              </span>
              {headerActions && <span className="fp-chat__kop-acties">{headerActions}</span>}
            </header>
            <div className="fp-chat__berichten">{children}</div>
            <form className="fp-chat__typbalk" onSubmit={verstuur}>
              {attachActions.length > 0 && (
                <Popover
                  open={bijlageOpen}
                  onOpenChange={setBijlageOpen}
                  placement="top-start"
                  aria-label={t.attach}
                  anchor={
                    <Button variant="ghost" className="fp-chat__icoonknop" aria-label={t.attach} aria-expanded={bijlageOpen} onPress={() => setBijlageOpen(!bijlageOpen)}>
                      <svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M21 12l-8.5 8.5a5 5 0 01-7-7L14 5a3.5 3.5 0 015 5l-8.5 8.5a2 2 0 01-3-3L15 8" /></svg>
                    </Button>
                  }
                >
                  <ul role="menu" className="fp-chat__bijlage-menu">
                    {attachActions.map((a) => <BijlageItem key={a.id} a={a} onDone={() => setBijlageOpen(false)} />)}
                  </ul>
                </Popover>
              )}
              <Field label={t.type} className="fp-chat__veld" placeholder={t.type} value={tekst} onChange={(e) => setTekst(e.target.value)} autoComplete="off" />
              <Button type="submit" className="fp-chat__stuur" aria-label={t.send} disabled={!tekst.trim()}>
                <svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></svg>
              </Button>
            </form>
          </>
        ) : (
          <p className="fp-chat__leeg">{t.empty}</p>
        )}
      </section>
    </div>
  );
}
blocks/chat-layout/chat-layout.css
/** ChatLayout — importeren in de app-root (registry-site: app/globals.css). */

.fp-chat {
  display: grid; grid-template-columns: 1fr; grid-template-areas: "lijst";
  width: 100%; height: 100%; min-height: 24rem; overflow: hidden;
  background: var(--background); color: var(--foreground);
  border: 1px solid var(--border); border-radius: var(--radius);
}
.fp-chat--met-nav { grid-template-columns: 3.5rem 1fr; grid-template-areas: "nav lijst"; }
.fp-chat__gesprek { display: none; }
.fp-chat--gesprek { grid-template-areas: "gesprek"; }
.fp-chat--gesprek.fp-chat--met-nav { grid-template-areas: "nav gesprek"; }
.fp-chat--gesprek .fp-chat__lijst { display: none; }
.fp-chat--gesprek .fp-chat__gesprek { display: flex; }
@media (min-width: 768px) {
  .fp-chat, .fp-chat--gesprek { grid-template-columns: minmax(16rem, 1fr) 3fr; grid-template-areas: "lijst gesprek"; }
  .fp-chat--met-nav, .fp-chat--gesprek.fp-chat--met-nav { grid-template-columns: 3.5rem minmax(16rem, 1fr) 3fr; grid-template-areas: "nav lijst gesprek"; }
  .fp-chat--gesprek .fp-chat__lijst { display: flex; }
  .fp-chat__gesprek { display: flex; }
  .fp-chat .fp-chat__terug { display: none; }
}

.fp-chat__nav { grid-area: nav; display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.5rem 0; border-right: 1px solid var(--border); }
.fp-chat__navknop {
  display: flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
  appearance: none; border: 0; background: transparent; cursor: pointer; color: var(--muted-foreground); border-radius: var(--radius);
  transition: background-color var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-chat__navknop:hover, .fp-chat__navknop[aria-current] { background: var(--muted); color: var(--foreground); }
.fp-chat__navknop[data-pressed] { transform: scale(0.92); }
.fp-chat__navknop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }

.fp-chat__lijst { grid-area: lijst; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); }
.fp-chat__lijst-kop { display: flex; align-items: center; height: 3rem; padding: 0 1rem; }
.fp-chat__h { margin: 0; font: inherit; font-weight: 500; }
.fp-chat__zoek { padding: 0.5rem 0.75rem 0.75rem; }
.fp-chat__contacten { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; min-height: 0; scrollbar-width: thin; }
.fp-chat__contact {
  display: flex; align-items: center; gap: 0.625rem; width: 100%; padding: 0.5rem 1rem;
  appearance: none; border: 0; background: transparent; cursor: pointer; text-align: left; font: inherit; color: inherit;
  transition: background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-chat__contact:hover, .fp-chat__contact[aria-current] { background: var(--muted); }
.fp-chat__contact:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: -2px; }
.fp-chat__portret { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; flex-shrink: 0; border-radius: 50%; object-fit: cover; background: var(--muted); }
.fp-chat__portret--letter { font-weight: 600; color: var(--muted-foreground); }
.fp-chat__contact-tekst { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; flex: 1; }
.fp-chat__contact-naam { font-weight: 500; font-size: 0.9375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-chat__contact-preview { font-size: 0.8125rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-chat__ongelezen { display: flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; padding: 0 0.375rem; font-size: 0.6875rem; font-weight: 600; color: var(--primary-foreground); background: var(--primary); border-radius: 999px; font-variant-numeric: tabular-nums; }

.fp-chat__gesprek { grid-area: gesprek; flex-direction: column; min-height: 0; }
.fp-chat__kop { display: flex; align-items: center; gap: 0.625rem; height: 4rem; padding: 0 0.75rem; border-bottom: 1px solid var(--border); }
.fp-chat__terug { display: flex; padding: 0.375rem; appearance: none; border: 0; background: transparent; cursor: pointer; color: var(--muted-foreground); border-radius: var(--radius); }
.fp-chat__terug[data-pressed] { transform: scale(0.92); }
.fp-chat__kop-tekst { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; flex: 1; }
.fp-chat__kop-naam { font-weight: 500; }
.fp-chat__kop-sub { font-size: 0.8125rem; color: var(--muted-foreground); }
.fp-chat__kop-acties { display: flex; gap: 0.25rem; }
.fp-chat__berichten { flex: 1; min-height: 0; overflow-y: auto; padding: 1rem; scrollbar-width: thin; }
.fp-chat__typbalk { display: flex; align-items: center; gap: 0.375rem; padding: 0.5rem 0.75rem; border-top: 1px solid var(--border); }
.fp-chat__icoonknop { width: 2.5rem; height: 2.5rem; padding: 0; justify-content: center; }
.fp-chat__veld { flex: 1; }
.fp-chat__veld .fp-field__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-chat__stuur { width: 2.5rem; height: 2.5rem; padding: 0; justify-content: center; }
.fp-chat__bijlage-menu { list-style: none; margin: 0; padding: 0; min-width: 11rem; }
.fp-chat__bijlage-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem; font-size: 0.875rem; cursor: pointer; border-radius: calc(var(--radius) / 2); transition: background-color var(--motion-instant-duration) var(--motion-instant); }
.fp-chat__bijlage-item:hover { background: var(--muted); }
.fp-chat__bijlage-icoon { display: flex; width: 1rem; height: 1rem; color: var(--muted-foreground); }
.fp-chat__leeg { margin: auto; font-size: 0.875rem; color: var(--muted-foreground); }