Kaart om mensen toe te voegen en hun rol te beheren: e-mailveld met rolkeuze en plusknop (Enter werkt ook, ongeldig adres geeft een veldfout), een hairline, en daaronder de mensen met portret of initialen, naam, adres en per persoon een select met de rollen plus Verwijderen; de eigenaar krijgt alleen een label. Toevoegen, rol wisselen en verwijderen zijn callbacks. Kleine zus van share-dialog. Componeert field, select en button.
Delen met mensen
Rol
- Ada Lovelaceada@voorbeeld.nlEigenaar
- Grace Hoppergrace@voorbeeld.nlRol: Grace Hopper
- Alan Turingalan@voorbeeld.nlRol: Alan Turing
Typ een adres en druk op Enter of de plus; wissel een rol of kies Verwijderen.
blocks/share/people-picker.tsx
/**
* PeoplePicker — een kaart om mensen toe te voegen en hun rol te beheren:
* een e-mailveld met rolkeuze en plusknop (Enter werkt ook), een hairline,
* en daaronder de mensen met portret of initialen, naam, adres en per
* persoon een select met de rollen plus "Verwijderen"; de eigenaar krijgt
* alleen een label. Alles is data; toevoegen, rol wisselen en verwijderen
* zijn callbacks — het project bewaart.
*
* Geoogst uit 21st.dev People Picker (felipemenezes098): radix Avatar/
* Select/Separator + lucide Plus → field (FC-0006), select (FC-0009),
* button (FC-0003) en initialen; text-destructive → --danger; Ada/Grace/
* Alan → people-prop. Kleine zus van share-dialog (FC-0086): geen link-
* instellingen of chips, wél rollen per persoon.
*/
"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 { Select, type SelectOption } from "../../primitives/ui/select";
export interface Person {
id: string;
name: string;
email: string;
avatar?: string;
role: string;
owner?: boolean;
}
export interface PeoplePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
people: readonly Person[];
roles?: readonly SelectOption[];
defaultRole?: string;
onInvite: (email: string, role: string) => void;
onRoleChange?: (id: string, role: string) => void;
onRemove?: (id: string) => void;
title?: React.ReactNode;
labels?: Partial<Record<"email" | "placeholder" | "role" | "invite" | "owner" | "remove" | "invalid", string>>;
}
const initialen = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
const GELDIG = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function PeoplePicker({ people, roles = [{ value: "viewer", label: "Bekijker" }, { value: "editor", label: "Bewerker" }], defaultRole, onInvite, onRoleChange, onRemove, title = "Delen met mensen", labels, className, ...rest }: PeoplePickerProps) {
const t = { email: "Toevoegen per e-mail", placeholder: "naam@voorbeeld.nl", role: "Rol", invite: "Uitnodigen", owner: "Eigenaar", remove: "Verwijderen", invalid: "Vul een geldig e-mailadres in.", ...labels };
const [email, setEmail] = useState("");
const [rol, setRol] = useState(defaultRole ?? roles[0]?.value ?? "");
const [fout, setFout] = useState<string | undefined>();
const nodigUit = () => {
const adres = email.trim();
if (!GELDIG.test(adres)) { setFout(t.invalid); return; }
setFout(undefined);
onInvite(adres, rol);
setEmail("");
};
const rolOpties: SelectOption[] = onRemove ? [...roles, { value: "__remove", label: t.remove }] : [...roles];
return (
<div {...rest} className={["fp-people", className].filter(Boolean).join(" ")}>
<h3 className="fp-people__titel">{title}</h3>
<div className="fp-people__invoer">
<Field
type="email"
label={t.email}
placeholder={t.placeholder}
value={email}
error={fout}
onChange={(e) => { setEmail(e.target.value); if (fout) setFout(undefined); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); nodigUit(); } }}
className="fp-people__veld"
/>
<div className="fp-people__invoer-rechts">
<Select label={t.role} options={roles} value={rol} onChange={(v) => v && setRol(v)} className="fp-people__rol" />
<Button variant="primary" className="fp-people__plus" aria-label={t.invite} onPress={nodigUit}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
</Button>
</div>
</div>
<hr className="fp-people__lijn" />
<ul className="fp-people__lijst">
{people.map((p) => (
<li key={p.id} className="fp-people__persoon">
{p.avatar ? (
// eslint-disable-next-line @next/next/no-img-element -- portret van het project
<img className="fp-people__portret" src={p.avatar} alt="" />
) : (
<span className="fp-people__portret fp-people__portret--letters" aria-hidden="true">{initialen(p.name)}</span>
)}
<div className="fp-people__wie">
<span className="fp-people__naam">{p.name}</span>
<span className="fp-people__mail">{p.email}</span>
</div>
{p.owner ? (
<span className="fp-people__eigenaar">{t.owner}</span>
) : (
<Select
options={rolOpties}
value={p.role}
label={`${t.role}: ${p.name}`}
className={onRemove ? "fp-people__rol fp-people__rol--rij fp-people__rol--verwijderbaar" : "fp-people__rol fp-people__rol--rij"}
onChange={(v) => { if (!v) return; if (v === "__remove") onRemove?.(p.id); else onRoleChange?.(p.id, v); }}
/>
)}
</li>
))}
</ul>
</div>
);
}
blocks/share/share.css
/**
* Share-blocks: share-dialog, people-picker.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
.fp-share {
position: relative; display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 32.5rem; padding: 1rem; box-sizing: border-box;
background: var(--card); color: var(--card-foreground, var(--foreground)); border: 1px solid var(--border); border-radius: calc(var(--radius) * 2);
box-shadow: 0 1px 2px var(--shadow-overlay);
}
.fp-share__kop { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
.fp-share__titel { margin: 0; font: inherit; font-weight: 600; font-size: 1rem; }
.fp-share__bestand { margin: 0.125rem 0 0; font-size: 0.8125rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-share__sluit, .fp-share__weg {
display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; flex-shrink: 0; border: 0; border-radius: var(--radius); background: none; color: var(--muted-foreground); cursor: pointer; outline: none;
transition: background-color var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant);
}
.fp-share__sluit:hover, .fp-share__weg:hover, .fp-share__sluit[data-pressed], .fp-share__weg[data-pressed] { background: var(--muted); color: var(--foreground); }
.fp-share__sluit:focus-visible, .fp-share__weg:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
/* Uitnodigen: chips + invoer in één veld */
.fp-share__nodig { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 0.5rem; align-items: start; }
@media (max-width: 34rem) { .fp-share__nodig { grid-template-columns: 1fr; } }
.fp-share__veld { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; height: auto; min-height: 2.5rem; padding: 0.25rem 0.5rem; cursor: text; }
.fp-share__invoer { flex: 1; min-width: 8rem; border: 0; background: none; padding: 0.25rem; font: inherit; color: inherit; outline: none; }
.fp-share__invoer::placeholder { color: var(--muted-foreground); }
.fp-share__chip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.25rem 0.125rem 0.5rem; border-radius: 999px; background: var(--muted); font-size: 0.8125rem; }
.fp-share__chip[data-ongeldig] { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
.fp-share__chip-weg { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; font: inherit; line-height: 1; opacity: 0.7; }
.fp-share__chip-weg:hover { opacity: 1; background: color-mix(in srgb, currentColor 12%, transparent); }
.fp-share .fp-select { max-width: none; width: auto; }
.fp-share__rolkeuze { min-width: 9.5rem; }
.fp-share__toegang { min-width: 12rem; }
/* Secties */
.fp-share__sectie { display: flex; flex-direction: column; gap: 0.5rem; }
.fp-share__sectiekop { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; }
.fp-share__subkop { margin: 0; font: inherit; font-weight: 600; font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted-foreground); }
.fp-share__telling { font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.fp-share__zoek { height: 2.25rem; }
.fp-share__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; max-height: 16rem; overflow-y: auto; }
.fp-share__persoon { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
.fp-share__persoon:last-child { border-bottom: 0; }
.fp-share__persoon[data-pending] .fp-share__avatar { opacity: 0.6; }
/* Portret: initialen op een tint tussen --muted en --primary, per naam stabiel. */
.fp-share__avatar {
display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; flex-shrink: 0; overflow: hidden; border-radius: 50%;
background: color-mix(in srgb, var(--primary) var(--fp-share-tint, 20%), var(--muted)); color: var(--foreground); font-size: 0.75rem; font-weight: 600;
}
.fp-share__avatar img { width: 100%; height: 100%; object-fit: cover; }
.fp-share__tekst { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.fp-share__naam { font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-share__jij, .fp-share__pending { font-weight: 400; color: var(--muted-foreground); }
.fp-share__mail { font-size: 0.75rem; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-share__rol { width: 9.5rem; flex-shrink: 0; }
.fp-share__rol-vast { flex-shrink: 0; padding: 0 0.5rem; font-size: 0.8125rem; color: var(--muted-foreground); }
/* Link */
.fp-share__linkrij { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.fp-share__hint { margin: 0; font-size: 0.75rem; color: var(--muted-foreground); }
/* Bevestiging */
.fp-share__toast-vak { position: absolute; left: 1rem; right: 1rem; bottom: 0.75rem; display: flex; justify-content: center; pointer-events: none; }
.fp-share__toast { margin: 0; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; background: var(--foreground); color: var(--background); box-shadow: 0 4px 12px var(--shadow-overlay); }
.fp-share__toast[data-toon="removed"] { background: var(--muted); color: var(--foreground); }
/* ── people-picker ─────────────────────────────────────────────────────── */
.fp-people { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 28rem; padding: 1.5rem; box-sizing: border-box; background: var(--card); color: var(--card-foreground, var(--foreground)); border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.5); }
.fp-people__titel { margin: 0; font: inherit; font-size: 1rem; font-weight: 600; line-height: 1.2; }
.fp-people__invoer { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; }
.fp-people__veld { flex: 1 1 12rem; min-width: 0; }
.fp-people__invoer-rechts { display: flex; align-items: flex-end; gap: 0.5rem; }
.fp-people__rol { width: 8rem; }
.fp-people__plus { width: 2.5rem; height: 2.5rem; padding: 0; justify-content: center; flex: 0 0 auto; }
.fp-people__lijn { margin: 0; border: 0; border-top: 1px solid var(--border); }
.fp-people__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.fp-people__persoon { display: flex; align-items: center; gap: 0.75rem; }
.fp-people__portret { width: 2rem; height: 2rem; flex: 0 0 auto; border-radius: 50%; object-fit: cover; background: var(--muted); }
.fp-people__portret--letters { display: inline-flex; align-items: center; justify-content: center; font: inherit; font-size: 0.6875rem; font-weight: 600; color: var(--muted-foreground); }
.fp-people__wie { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; }
.fp-people__naam { font: inherit; font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-people__mail { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-people__eigenaar { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-people__rol--rij { flex: 0 0 auto; width: 8rem; }
.fp-people__rol--rij .fp-field__label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.fp-people__rol--verwijderbaar .fp-select__optie:last-child { color: var(--danger); }