Mensen toegang geven tot een bestand of project: adressen die als chips vormen terwijl je typt, per persoon een rol, uitnodigingen intrekken, de link wisselen tussen alleen-genodigden en iedereen, kopieerknop en een korte bevestiging. Receptuur: select + button + usePress.
Delen
Offerte-2026-Q4.pdf
Mensen met toegang
3 · 1 uitgenodigd- Jamie de Vries (jij)jamie@voorbeeld.nlEigenaar
- Sanne Muldersanne@voorbeeld.nl
- Milan Torresmilan@voorbeeld.nl
- Robin Kim · uitgenodigdrobin@voorbeeld.nl
Link
Alleen mensen hierboven kunnen openen.
Typ een adres en druk Enter, wissel een rol, kopieer de link — elke actie komt hier als callback terug.
blocks/share/share-dialog.tsx
/**
* ShareDialog — mensen toegang geven tot een bestand of project: adressen
* die als chips vormen terwijl je typt, per persoon een rol, uitnodigingen
* intrekken, en de link wisselen tussen alleen-genodigden en iedereen — met
* kopieerknop en een korte bevestiging onderin.
*
* Receptuur: rollen en linktoegang via select (FC-0074), knoppen via button
* (FC-0003), verwijderen via usePress. De props zaaien de staat; het paneel
* beheert hem zelf en meldt elke wijziging naar boven (V6): uitnodigen,
* rol wijzigen, verwijderen, linktoegang, link kopiëren. Portretten zonder
* beeld krijgen initialen op een tint die uit --primary en --muted wordt
* gemengd (color-mix, per naam stabiel) — geen kleur in de entry.
*/
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "../../primitives/ui/button";
import { Select } from "../../primitives/ui/select";
import { usePress } from "../../primitives/ui/press";
import { useSpringTransition } from "../../primitives/motion";
export type AccessRole = "owner" | "edit" | "comment" | "view";
export type LinkAccess = "restricted" | "anyone";
export type AssignableRole = Exclude<AccessRole, "owner">;
export interface Collaborator {
id: string;
name: string;
email: string;
avatar?: string;
role: AccessRole;
/** De ingelogde persoon — krijgt "(jij)". */
you?: boolean;
/** Uitgenodigd, nog niet geaccepteerd. */
pending?: boolean;
}
export interface ShareDialogLabels {
title: string;
close: string;
inputPlaceholder: string;
invite: string;
you: string;
pending: string;
peopleWithAccess: string;
/** {n} */
invited: string;
linkTitle: string;
restricted: string;
anyone: string;
restrictedHint: string;
anyoneHint: string;
copy: string;
copied: string;
/** {n} */
invitationsSent: string;
/** {name} */
invitationCancelled: string;
/** {name} */
accessRemoved: string;
linkCopied: string;
/** {name} */
remove: string;
removeChip: string;
search: string;
roles: Record<AccessRole, string>;
}
export interface ShareDialogProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
fileName?: string;
people?: Collaborator[];
linkAccess?: LinkAccess;
linkRole?: AssignableRole;
shareUrl?: string;
/** Zoekveld zodra de lijst langer is dan dit. */
searchThreshold?: number;
onInvite?: (emails: string[], role: AssignableRole) => void;
onRoleChange?: (id: string, role: AccessRole) => void;
onRemove?: (id: string) => void;
onLinkAccessChange?: (access: LinkAccess, role: AssignableRole) => void;
onCopyLink?: (url: string) => void;
onClose?: () => void;
labels?: Partial<ShareDialogLabels>;
}
const STANDAARD: ShareDialogLabels = {
title: "Delen",
close: "Sluiten",
inputPlaceholder: "E-mailadres toevoegen",
invite: "Uitnodigen",
you: "(jij)",
pending: "uitgenodigd",
peopleWithAccess: "Mensen met toegang",
invited: "{n} uitgenodigd",
linkTitle: "Link",
restricted: "Alleen genodigden",
anyone: "Iedereen met de link",
restrictedHint: "Alleen mensen hierboven kunnen openen.",
anyoneHint: "Iedereen die de link heeft kan openen.",
copy: "Link kopiëren",
copied: "Gekopieerd",
invitationsSent: "Uitnodiging verstuurd naar {n} persoon/personen",
invitationCancelled: "Uitnodiging voor {name} ingetrokken",
accessRemoved: "{name} heeft geen toegang meer",
linkCopied: "Link gekopieerd",
remove: "Verwijder {name}",
removeChip: "Verwijder adres",
search: "Zoek op naam of e-mail",
roles: { owner: "Eigenaar", edit: "Kan bewerken", comment: "Kan reageren", view: "Kan bekijken" },
};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const ROLLEN: AssignableRole[] = ["edit", "comment", "view"];
function initialen(naam: string) {
const delen = naam.trim().split(/\s+/);
return ((delen[0]?.[0] ?? "") + (delen[1]?.[0] ?? "")).toUpperCase() || "?";
}
/** Stabiele tint per naam: 6 stappen tussen --muted en --primary. */
function tintVoor(naam: string): number {
let n = 0;
for (let i = 0; i < naam.length; i += 1) n = (n * 31 + naam.charCodeAt(i)) >>> 0;
return 10 + (n % 6) * 12;
}
interface Chip { id: string; value: string; valid: boolean }
function ChipKnop({ chip, onWeg, label }: { chip: Chip; onWeg: () => void; label: string }) {
const { pressProps } = usePress({ onPress: onWeg });
return (
<span className="fp-share__chip" data-ongeldig={chip.valid ? undefined : ""}>
{chip.value}
<button {...pressProps} type="button" className="fp-share__chip-weg" aria-label={`${label}: ${chip.value}`}>×</button>
</span>
);
}
function Persoon({ p, L, onRol, onWeg }: { p: Collaborator; L: ShareDialogLabels; onRol: (r: AccessRole) => void; onWeg?: () => void }) {
const weg = usePress({ onPress: () => onWeg?.() });
return (
<li className="fp-share__persoon" data-pending={p.pending ? "" : undefined}>
<span className="fp-share__avatar" style={{ ["--fp-share-tint" as string]: `${tintVoor(p.name)}%` } as React.CSSProperties} aria-hidden="true">
{p.avatar ? /* eslint-disable-next-line @next/next/no-img-element -- url van het project */ <img src={p.avatar} alt="" /> : initialen(p.name)}
</span>
<span className="fp-share__tekst">
<span className="fp-share__naam">{p.name}{p.you && <span className="fp-share__jij"> {L.you}</span>}{p.pending && <span className="fp-share__pending"> · {L.pending}</span>}</span>
<span className="fp-share__mail">{p.email}</span>
</span>
{p.role === "owner" ? (
<span className="fp-share__rol-vast">{L.roles.owner}</span>
) : (
<Select className="fp-share__rol" options={ROLLEN.map((r) => ({ value: r, label: L.roles[r] }))} value={p.role} onChange={(v) => v && onRol(v as AccessRole)} />
)}
{onWeg && p.role !== "owner" && !p.you && (
<button {...weg.pressProps} type="button" className="fp-share__weg" data-pressed={weg.pressed ? "" : undefined} aria-label={L.remove.replace("{name}", p.name)}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
)}
</li>
);
}
export function ShareDialog({
fileName, people = [], linkAccess = "restricted", linkRole = "view", shareUrl = "", searchThreshold = 6,
onInvite, onRoleChange, onRemove, onLinkAccessChange, onCopyLink, onClose, labels: eigen, className, ...rest
}: ShareDialogProps) {
const L = useMemo(() => ({ ...STANDAARD, ...eigen, roles: { ...STANDAARD.roles, ...(eigen?.roles ?? {}) } }), [eigen]);
const id = useId();
const toast = useSpringTransition("default");
const [lijst, setLijst] = useState<Collaborator[]>(people);
useEffect(() => setLijst(people), [people]);
const [toegang, setToegang] = useState<LinkAccess>(linkAccess);
const [linkRol, setLinkRol] = useState<AssignableRole>(linkRole);
const [chips, setChips] = useState<Chip[]>([]);
const [concept, setConcept] = useState("");
const [uitnodigRol, setUitnodigRol] = useState<AssignableRole>("edit");
const [zoek, setZoek] = useState("");
const [gekopieerd, setGekopieerd] = useState(false);
const [melding, setMelding] = useState<{ tekst: string; toon: "done" | "removed" } | null>(null);
const sluit = usePress({ onPress: () => onClose?.() });
useEffect(() => {
if (!melding) return;
const t = window.setTimeout(() => setMelding(null), 2600);
return () => window.clearTimeout(t);
}, [melding]);
useEffect(() => {
if (!gekopieerd) return;
const t = window.setTimeout(() => setGekopieerd(false), 1800);
return () => window.clearTimeout(t);
}, [gekopieerd]);
const maakChips = (ruw: string) => {
const delen = ruw.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
if (!delen.length) return;
setChips((c) => [...c, ...delen.filter((d) => !c.some((x) => x.value.toLowerCase() === d.toLowerCase())).map((d) => ({ id: `${d}-${Date.now()}`, value: d, valid: EMAIL.test(d) }))]);
};
const conceptGeldig = EMAIL.test(concept.trim());
const aantalGeldig = chips.filter((c) => c.valid).length + (conceptGeldig ? 1 : 0);
const nodigUit = () => {
const mails = [...chips.filter((c) => c.valid).map((c) => c.value), ...(conceptGeldig ? [concept.trim()] : [])];
if (!mails.length) return;
const nieuw: Collaborator[] = mails.map((email) => ({
id: `pending-${email}`,
name: (email.split("@")[0] ?? email).replace(/[._-]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase()),
email, role: uitnodigRol, pending: true,
}));
setLijst((huidig) => [...nieuw, ...huidig.filter((p) => !mails.includes(p.email))]);
setChips((c) => c.filter((x) => !x.valid));
setConcept("");
setMelding({ tekst: L.invitationsSent.replace("{n}", String(mails.length)), toon: "done" });
onInvite?.(mails, uitnodigRol);
};
const wijzigRol = (pid: string, rol: AccessRole) => {
setLijst((h) => h.map((p) => (p.id === pid ? { ...p, role: rol } : p)));
onRoleChange?.(pid, rol);
};
const verwijder = (pid: string) => {
const persoon = lijst.find((p) => p.id === pid);
setLijst((h) => h.filter((p) => p.id !== pid));
if (persoon) setMelding({ tekst: (persoon.pending ? L.invitationCancelled : L.accessRemoved).replace("{name}", persoon.name), toon: "removed" });
onRemove?.(pid);
};
const kopieer = async () => {
try {
await navigator.clipboard?.writeText(shareUrl);
} catch {
/* geblokkeerd klembord: de bevestiging blijft eerlijk over de bedoeling */
}
setGekopieerd(true);
setMelding({ tekst: L.linkCopied, toon: "done" });
onCopyLink?.(shareUrl);
};
const zetToegang = (a: LinkAccess) => {
setToegang(a);
onLinkAccessChange?.(a, linkRol);
};
const zetLinkRol = (r: AssignableRole) => {
setLinkRol(r);
onLinkAccessChange?.(toegang, r);
};
const geaccepteerd = lijst.filter((p) => !p.pending).length;
const uitgenodigd = lijst.length - geaccepteerd;
const toonZoek = lijst.length > searchThreshold;
const getoond = zoek.trim() ? lijst.filter((p) => `${p.name} ${p.email}`.toLowerCase().includes(zoek.trim().toLowerCase())) : lijst;
return (
<div {...rest} role="dialog" aria-label={L.title} className={["fp-share", className].filter(Boolean).join(" ")}>
<div className="fp-share__kop">
<div className="fp-share__koptekst">
<h2 className="fp-share__titel">{L.title}</h2>
{fileName && <p className="fp-share__bestand">{fileName}</p>}
</div>
{onClose && (
<button {...sluit.pressProps} type="button" className="fp-share__sluit" data-pressed={sluit.pressed ? "" : undefined} aria-label={L.close}>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
)}
</div>
{/* Uitnodigen: chips + concept in één veld, rol ernaast, knop */}
<div className="fp-share__nodig">
<label htmlFor={`${id}-mail`} className="fp-sr-only">{L.inputPlaceholder}</label>
<div className="fp-share__veld fp-input" onPointerDown={(e) => (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.focus()}>
{chips.map((c) => <ChipKnop key={c.id} chip={c} label={L.removeChip} onWeg={() => setChips((x) => x.filter((y) => y.id !== c.id))} />)}
<input
id={`${id}-mail`}
className="fp-share__invoer"
type="email"
autoComplete="off"
placeholder={chips.length ? "" : L.inputPlaceholder}
value={concept}
onChange={(e) => setConcept(e.target.value)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === "," || e.key === " ") && concept.trim()) {
e.preventDefault();
maakChips(concept);
setConcept("");
} else if (e.key === "Enter" && !concept.trim() && aantalGeldig) {
e.preventDefault();
nodigUit();
} else if (e.key === "Backspace" && !concept && chips.length) {
const laatste = chips[chips.length - 1]!;
setChips((c) => c.slice(0, -1));
setConcept(laatste.value);
}
}}
onPaste={(e) => {
const tekst = e.clipboardData.getData("text");
if (/[,;\s]/.test(tekst)) {
e.preventDefault();
maakChips(tekst);
}
}}
onBlur={() => {
if (concept.trim()) {
maakChips(concept);
setConcept("");
}
}}
/>
</div>
<Select className="fp-share__rolkeuze" options={ROLLEN.map((r) => ({ value: r, label: L.roles[r] }))} value={uitnodigRol} onChange={(v) => v && setUitnodigRol(v as AssignableRole)} />
<Button onPress={nodigUit} disabled={aantalGeldig === 0}>{L.invite}</Button>
</div>
{/* Mensen */}
<div className="fp-share__sectie">
<div className="fp-share__sectiekop">
<h3 className="fp-share__subkop">{L.peopleWithAccess}</h3>
<span className="fp-share__telling">{geaccepteerd}{uitgenodigd > 0 ? ` · ${L.invited.replace("{n}", String(uitgenodigd))}` : ""}</span>
</div>
{toonZoek && <input className="fp-input fp-share__zoek" type="search" placeholder={L.search} value={zoek} onChange={(e) => setZoek(e.target.value)} aria-label={L.search} />}
<ul className="fp-share__lijst">
{getoond.map((p) => <Persoon key={p.id} p={p} L={L} onRol={(r) => wijzigRol(p.id, r)} onWeg={() => verwijder(p.id)} />)}
</ul>
</div>
{/* Link */}
<div className="fp-share__sectie fp-share__link">
<div className="fp-share__sectiekop">
<h3 className="fp-share__subkop">{L.linkTitle}</h3>
</div>
<div className="fp-share__linkrij">
<Select className="fp-share__toegang" options={[{ value: "restricted", label: L.restricted }, { value: "anyone", label: L.anyone }]} value={toegang} onChange={(v) => v && zetToegang(v as LinkAccess)} />
{toegang === "anyone" && <Select className="fp-share__rolkeuze" options={ROLLEN.map((r) => ({ value: r, label: L.roles[r] }))} value={linkRol} onChange={(v) => v && zetLinkRol(v as AssignableRole)} />}
<Button variant="ghost" onPress={() => void kopieer()} disabled={!shareUrl}>{gekopieerd ? L.copied : L.copy}</Button>
</div>
<p className="fp-share__hint">{toegang === "anyone" ? L.anyoneHint : L.restrictedHint}</p>
</div>
{/* Bevestiging */}
<div className="fp-share__toast-vak" aria-live="polite">
<AnimatePresence>
{melding && (
<motion.p key={melding.tekst} className="fp-share__toast" data-toon={melding.toon} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={toast}>
{melding.tekst}
</motion.p>
)}
</AnimatePresence>
</div>
</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); }