chat-layout
FC-0059chatblockv1.0.0Driedelige 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.
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); }