@col/confirm-dialog
Versie: 1.0.0 Toegevoegd: 2026-08-06
Herkomst
growerportal2 heeft dit drie keer met de hand geschreven: src/components/layout/user-management.tsx
(regel 444-465, gebruiker verwijderen), src/features/fust/components/fust-settings.tsx (regel
840-864, fusttype/transporteur verwijderen) en src/features/fust/components/fust-pickups.tsx
(regel 476-544, pickup als afgeleverd bevestigen — rijkere body met een itemlijst, maar hetzelfde
annuleren/bevestigen-skelet en dezelfde saving-discipline). voorraadbeheer's
src/app/(dashboard)/admin/employees/page.tsx gebruikt kaal window.confirm() voor
wachtwoord-reset en heractivering — geen component, en de reden dat dit item niet alleen de drie
kopieën samenvoegt maar ook het gat vult dat window.confirm() achterlaat (geen eigen styling, geen
loading-status, blokkerend in plaats van async-vriendelijk). KCB's
src/components/settings/user-management.tsx bevestigt hetzelfde gat vanuit een rij-actiemenu
(handleDelete, regel 135) — ook daar een kaal confirm(). Het rij-actiemenu zelf valt buiten dit
item; alleen hoe de bevestiging wordt afgehandeld is relevant.
Alle drie de growerportal2-kopieën sluiten de dialoog niet onvoorwaardelijk bij een klik op
bevestigen — ze zetten hun dialoog-state pas op gesloten ná een geslaagd antwoord (res.ok /
ok), in de try-tak, niet in finally. Dat patroon is de kern van dit item; zie Bewuste keuzes.
Waarvoor
Een bevestigingsdialoog met titel, optionele beschrijving, annuleren- en bevestigen-knop — voor een verwijderactie of een andere onomkeerbare stap. Pak het wanneer een klik direct een schadelijk gevolg heeft en een korte "weet je het zeker?" de kans op een ongeluk verkleint.
Gebruik het niet als de bevestiging eigen inhoud nodig heeft buiten titel/beschrijving — zoals
fust-pickups.tsx's afleverbevestiging, die een bewerkbare itemlijst in de dialoog toont. Dit
component heeft geen kinderen-slot; het is titel, beschrijving en twee knoppen, niets meer. Een
dialoog met eigen body-inhoud blijft bespoke, gebouwd met de kale Dialog-primitives.
Wat je app moet leveren
Niets structureels — een presentatiecomponent, volledig controlled, alle tekst via props. Geen backend-contract.
import { ConfirmDialog } from '@/components/confirm-dialog'
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
async function handleConfirm() {
setSaving(true)
try {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
if (res.ok) {
toast.success('Verwijderd')
setOpen(false)
} else {
toast.error('Verwijderen mislukt')
}
} catch {
toast.error('Verwijderen mislukt')
} finally {
setSaving(false)
}
}
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title="Gebruiker verwijderen?"
description="Dit kan niet ongedaan worden gemaakt."
confirmLabel="Verwijderen"
cancelLabel="Annuleren"
destructive
loading={saving}
onConfirm={handleConfirm}
/>
function ConfirmDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
confirmLabel: string
cancelLabel: string
destructive?: boolean
loading?: boolean
onConfirm: () => void
}): JSX.Element
| Prop | Verplicht | Opmerking |
|---|---|---|
open / onOpenChange |
ja | volledig controlled, geen interne staat, geen trigger-prop — zie Bewuste keuzes |
title |
ja | — |
description |
— | weglaten toont alleen de titel; zie Bewuste keuzes voor wanneer dat verstandig is |
confirmLabel / cancelLabel |
ja | geen standaardtekst — geen vertaalsysteem in dit item, elke aanroeper geeft zijn eigen taal mee |
destructive |
— | confirm-knop krijgt variant="destructive" in plaats van "default" |
loading |
— | door de aanroeper beheerd (dezelfde saving-state als de bronapps). Schakelt de confirm-knop uit en toont er een spinner vóór confirmLabel op. Annuleren, Escape, een overlay-klik en de close-knop blijven altijd werken — zie Bewuste keuzes |
onConfirm |
ja | vuurt bij een klik op bevestigen; sluit de dialoog niet zelf — zie Bewuste keuzes |
Bestanden
| Bestand | Landt in | Soort |
|---|---|---|
confirm-dialog.tsx |
components/confirm-dialog.tsx |
beheerd |
registryDependencies: dialog, button. Geen cssVars.
Gebruikt door
| App | Sinds versie | Opmerkingen |
|---|---|---|
| — | Nog niet vanuit dit item geïnstalleerd. |
growerportal2 heeft drie eigen kopieën (zie Herkomst), voorraadbeheer en KCB gebruiken kaal
window.confirm(). Voeg hier een regel toe zodra een app is overgezet.
Bewuste keuzes
- Geen
trigger-prop, alleen controlled. Een trigger is verleidelijk (<ConfirmDialog trigger={<Button>Verwijderen</Button>}>) maar zou intern vrijwel altijd opasChilduitkomen om de aanroeper-knop te laten samensmelten metDialogTrigger— precies het Radix-only idioom dat AGENTS.md regel 5 verbiedt, en dat een Base UI-consument (floriday api,KCB) bij elke--overwriteopnieuw zou moeten herschrijven. Alle drie de bronapps openen hun dialoog toch al vanuit een eigenuseStatenaast een rij-knop of dropdown-item (setDeleteDialog(user)), dus een trigger-prop zou geen state besparen — hij voegt alleen een idioom toe dat de helft van de consumers moet omzeilen. Gewoon eenopen/onOpenChange-paar dat de aanroeper toch al nodig heeft. onConfirmsluit de dialoog niet zelf. Alle drie growerportal2-kopieën zetten hun dialoog-state pas op gesloten ná een geslaagd antwoord (if (res.ok) { …; setDeleteDialog(null) }), nooit onvoorwaardelijk en nooit in eenfinally. ZouConfirmDialogzelf sluiten bij elke klik, dan verdwijnt de dialoog ook bij een misluktefetch— de gebruiker ziet een toast-foutmelding verschijnen zonder enige aanwijzing welke actie daarbij hoorde, want het scherm waarop hij net "Verwijderen" zag staan is al weg. Vandaar datonConfirm: () => voidpuur een signaal is; de aanroeper roept zelfonOpenChange(false)aan zodra dat past — typisch ná een geslaagdeawait, binnen dezelfdetry-tak als de bronapps al hebben. GooitonConfirmeen fout (of wijst de promise af), dan is dat voor de aanroeper: dezelfde try/catch/finally die alle drie de bronapps toch al om hun eigenfetchheen hebben, dit component grijpt daar niet stilzwijgend voor in.loadingschakelt alleen de confirm-knop uit — precies wat alle drie de bronapps doen.user-management.tsx,fust-settings.tsxenfust-pickups.tsxdisablen elk hun confirm-knop (disabled={saving}) en niets anders: Annuleren blijft klikbaar, en geen van de drie blokkeert Escape of een overlay-klik tijdens het lopende verzoek. Een eerdere versie van dit item week daarvan af —handleOpenChangenegeerde Escape/overlay-sluiten zolangloadingwaar was, en Annuleren kreeg hetzelfdedisabled={loading}als Bevestigen — met als bedoeling een dubbele submit nog wat harder te voorkomen. Dat bleek een slechtere fout dan de dubbele submit die het moest voorkomen: hangtonConfirm— een weggevallen verbinding, een promise die de aanroeper vergeet af te handelen — dan zit de gebruiker vast in een dialoog zonder feedback en zonder ontsnapping, zonder timeout en zonder escape hatch. Dat is precies wat geen van de drie bronapps doet. Vandaar de terugkeer naar bron-pariteit: alleen de confirm-knop blijft uitgeschakeld (dat voorkomt een dubbele klik op bevestigen prima), maar Escape, een overlay-klik, de close-knop en Annuleren werken altijd, ook tijdensloading. Dismissen annuleert het lopende verzoek niet — het stopt alleen met de gebruiker blokkeren, exact zoals de drie bronapps zich nu al gedragen.- Geen timeout in het component om
loadingte forceren loslaten. Dat is bewust weggelaten, niet vergeten. Dit component heeft geen idee wat een zinnige timeout is voor een willekeurigeonConfirm— te kort en een trage maar legitieme aanroep wordt losgelaten terwijl hij nog bezig is, te lang en de timeout beschermt niets. Erger nog: automatisch loslaten zonder dat de aanroeper het weet, herintroduceert het dubbele-submit-risico datloadingjuist moest voorkomen, alleen nu op een onvoorspelbaar moment. Een aanroeper die genuine annulering nodig heeft — het verzoek zelf afbreken, niet alleen de dialoog vrijgeven — hoort dat op de aanroepplek te regelen met eenAbortControllerrond defetch/onConfirm-aanroep, niet te verwachten dat een presentatiecomponent zoals dit besluit wanneer een lopend verzoek "lang genoeg" heeft geduurd. - De confirm-knop toont een spinner (
Loader2,animate-spin) vóórconfirmLabel, in plaats van de tekst te vervangen.fust-pickups.tsxdoet tekstvervanging (confirmingDelivery ? t('common.loading') : t('fust.markDelivered')); de andere twee bronapps veranderen de tekst helemaal niet tijdens het laden, alleen de disabled-state. Een spinner ervoor is de vorm die@col/page-states'LoadingStatein dit design system al gebruikt (zelfde icoon, zelfde animatieklasse) en heeft geen aparteloadingLabel-prop nodig —confirmLabelblijft zichtbaar, wat vertaalwerk bespaart ten opzichte van fust-pickups' aanpak. descriptionis optioneel, geen verplichte prop.user-management.tsxenfust-settings.tsxgeven allebei eenDialogDescriptionmee (t('fust.deleteConfirm')/deleteTransporterConfirm);voorraadbeheer'swindow.confirm()-teksten zijn zelf al een korte toelichting, niet alleen een titel. Geen van de vier bronaanroepen laat de toelichting helemaal weg. Toch blijft de prop optioneel in plaats van verplicht: een lichtere bevestiging ("Wachtwoord opnieuw instellen?" zonder verdere uitleg, zoalsvoorraadbeheer's reset-password-actie in de kern is) hoeft niet gedwongen een tweede zin te verzinnen die niets toevoegt. Voor eendestructive-dialoog is eendescriptionin de praktijk bijna altijd op zijn plaats — dat wordt hier benoemd, niet afgedwongen met een conditioneel type, wat de API voor een marginaal geval onnodig ingewikkeld zou maken.
Bekende beperkingen
- Geen
aria-describedbyalsdescriptionontbreekt. Radix'Dialog.Contentverwacht eenDialog.Description(of een handmatigearia-describedby) en logt anders een dev-only console-waarschuwing.fust-pickups.tsx's eigen afleverbevestiging heeft hetzelfde gat (alleen eenDialogTitle, geen beschrijving) en draait al in productie zonder dat het is opgelost — dit item volgt hetzelfde, geaccepteerde patroon in plaats van eenVisuallyHidden-omweg te verzinnen voor een waarschuwing die nooit zichtbaar is voor een eindgebruiker. - Geen kinderen-slot. Een dialoog met eigen body-inhoud (zoals
fust-pickups.tsx's bewerkbare itemlijst) kan dit component niet gebruiken — zie Waarvoor.