Forms
NewBlast-radius confirm
A destructive delete that cannot be deferred or undone gets a confirm dialog that names everything it cascades, blocks every dismissal while it runs, and resets its error and its options on every open.
Reach for it when building
- deleting an album or playlist that cascades to other records
- removing an account with attached history
- deleting a project with files and comments beneath it
- clearing a saved deck or collection
- deleting an upload others may already reference
- removing a recurring schedule with logged runs attached
- confirm-dialog
- destructive
- cascade
- dialog-element
- forms
<div class="brc-demo">
<label class="brc-fail-toggle">
<input type="checkbox" id="brc-fail-next">
Make the next delete fail
</label>
<ul class="brc-list" id="brc-list"></ul>
<p class="brc-log" id="brc-log" role="status"></p>
<dialog class="brc-dialog" id="brc-dialog" aria-labelledby="brc-title">
<button type="button" class="brc-x" id="brc-x" aria-label="Close">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M5 5 L15 15 M15 5 L5 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</button>
<h2 class="brc-title" id="brc-title">Delete this album?</h2>
<p class="brc-body" id="brc-body"></p>
<div class="brc-options" id="brc-options"></div>
<p class="brc-error" id="brc-error" role="alert" hidden></p>
<div class="brc-actions">
<button type="button" class="brc-btn brc-cancel" id="brc-cancel">Cancel</button>
<button type="button" class="brc-btn brc-confirm" id="brc-confirm">Delete</button>
</div>
</dialog>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.brc-demo { display: flex; flex-direction: column; gap: 0.9rem; max-width: 420px; }
.brc-fail-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--dim);
width: fit-content;
}
.brc-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.brc-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.brc-row:last-child { border-bottom: none; }
.brc-row-title { margin: 0; font-weight: 600; }
.brc-row-sub { margin: 0; font-size: 0.8125rem; color: var(--dim); }
.brc-empty { padding: 1.25rem; color: var(--dim); text-align: center; font-size: 0.9375rem; }
.brc-delete-icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface);
color: var(--bad);
cursor: pointer;
}
.brc-delete-icon:hover { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, var(--surface)); }
.brc-log { margin: 0; font-size: 0.875rem; color: var(--dim); min-height: 1.4em; }
/* Reset the browser's own dialog chrome; the surface, border and radius come
from the same tokens as every other surface in the library. */
.brc-dialog {
position: relative;
max-width: 420px;
width: calc(100% - 2rem);
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
}
.brc-dialog::backdrop {
background: color-mix(in srgb, var(--ink) 55%, transparent);
}
.brc-x {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--dim);
border-radius: 6px;
cursor: pointer;
}
.brc-x:hover { color: var(--ink); background: var(--surface-2); }
/* Disabled only while the delete is genuinely in flight — a transient state,
not a precondition someone could act on, so the native attribute is the
right tool here. */
.brc-x:disabled, .brc-cancel:disabled { opacity: 0.5; cursor: not-allowed; }
.brc-title { margin: 0 1.75rem 0.6rem 0; font-size: 1.125rem; }
.brc-body { margin: 0 0 0.85rem; }
.brc-options { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
.brc-option {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--ink);
}
.brc-option input { margin-top: 0.2rem; }
.brc-error {
margin: 0 0 0.85rem;
padding: 0.6rem 0.75rem;
border-radius: calc(var(--radius) - 4px);
background: color-mix(in srgb, var(--bad) 14%, var(--surface));
border: 1px solid var(--bad);
color: var(--ink);
font-size: 0.9375rem;
}
.brc-error[hidden] { display: none; }
.brc-actions { display: flex; justify-content: flex-end; gap: 0.6rem; }
.brc-btn {
font: inherit;
font-weight: 600;
min-height: 44px;
padding: 0.5rem 1.1rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.brc-confirm {
border-color: var(--bad);
background: var(--bad);
color: var(--accent-ink);
}
.brc-confirm:disabled { cursor: wait; opacity: 0.8; }var albums = [
{ id: 'al1', title: 'Moonlight Sessions', artist: 'Ayako Rin', playlists: 3, playCounts: 142, reversible: false },
{ id: 'al2', title: 'Harbor Static', artist: 'The Harbour Lights', playlists: 0, playCounts: 58, reversible: false },
{ id: 'al3', title: 'Paper Radio', artist: 'Nils Aker', playlists: 1, playCounts: 0, reversible: true },
{ id: 'al4', title: 'Live at the Cove', artist: 'Corbin & Dale', playlists: 0, playCounts: 0, reversible: false }
];
var listElement = document.getElementById('brc-list');
var logElement = document.getElementById('brc-log');
var failToggle = document.getElementById('brc-fail-next');
var dialogElement = document.getElementById('brc-dialog');
var titleElement = document.getElementById('brc-title');
var bodyElement = document.getElementById('brc-body');
var optionsElement = document.getElementById('brc-options');
var errorElement = document.getElementById('brc-error');
var closeButton = document.getElementById('brc-x');
var cancelButton = document.getElementById('brc-cancel');
var confirmButton = document.getElementById('brc-confirm');
var pending = false;
var currentAlbum = null;
var dangerCheckbox = null;
function pluralize(count, singular) {
return count + ' ' + (count === 1 ? singular : singular + 's');
}
function joinParts(parts) {
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
if (parts.length === 2) return parts[0] + ' and ' + parts[1];
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
}
// A zero-count tally is not "0 playlists" — it is a row that should not
// appear at all, so it is filtered out before the sentence is built.
function cascadeClause(album) {
var parts = [];
if (album.playlists > 0) parts.push(pluralize(album.playlists, 'playlist'));
if (album.playCounts > 0) parts.push(pluralize(album.playCounts, 'play count'));
var joined = joinParts(parts);
return joined === null ? 'Nothing else is attached to it.' : 'This also deletes ' + joined + '.';
}
function trashIconMarkup() {
return '<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">' +
'<path d="M4 6 H16 M8 6 V4.5 A1 1 0 0 1 9 3.5 H11 A1 1 0 0 1 12 4.5 V6 M6 6 L6.7 16 A1 1 0 0 0 7.7 17 H12.3 A1 1 0 0 0 13.3 16 L14 6" ' +
'stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function renderList() {
listElement.innerHTML = '';
if (albums.length === 0) {
var empty = document.createElement('li');
empty.className = 'brc-empty';
empty.textContent = 'No albums left in this library.';
listElement.appendChild(empty);
return;
}
albums.forEach(function (album) {
var row = document.createElement('li');
row.className = 'brc-row';
var text = document.createElement('div');
var title = document.createElement('p');
title.className = 'brc-row-title';
title.textContent = album.title;
var sub = document.createElement('p');
sub.className = 'brc-row-sub';
sub.textContent = album.artist;
text.appendChild(title);
text.appendChild(sub);
var deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'brc-delete-icon';
deleteButton.setAttribute('aria-label', 'Delete ' + album.title);
deleteButton.innerHTML = trashIconMarkup();
deleteButton.addEventListener('click', function () {
openDialog(album);
});
row.appendChild(text);
row.appendChild(deleteButton);
listElement.appendChild(row);
});
}
function openDialog(album) {
currentAlbum = album;
pending = false;
// Every open starts clean: no stale error from a previous attempt, and no
// option still ticked from a run that was cancelled. A leftover "also
// delete the files from disk" tick is a destructive surprise waiting on
// the next album this dialog happens to open on.
errorElement.hidden = true;
errorElement.textContent = '';
titleElement.textContent = 'Delete "' + album.title + '"?';
bodyElement.innerHTML = '';
var name = document.createElement('strong');
name.textContent = album.title;
bodyElement.appendChild(name);
bodyElement.appendChild(document.createTextNode(' will be removed. ' + cascadeClause(album) + ' '));
var consequence = document.createElement('span');
// A reversible action overrides the "cannot be undone" line, or people
// learn to click through the one confirm that actually means it.
consequence.textContent = album.reversible
? 'It moves to Trash — restore it from Settings › Trash within 30 days.'
: 'This cannot be undone.';
bodyElement.appendChild(consequence);
optionsElement.innerHTML = '';
dangerCheckbox = null;
if (!album.reversible) {
var label = document.createElement('label');
label.className = 'brc-option';
var input = document.createElement('input');
input.type = 'checkbox';
input.id = 'brc-danger-check';
var span = document.createElement('span');
span.textContent = 'Also delete the audio files from disk';
label.appendChild(input);
label.appendChild(span);
optionsElement.appendChild(label);
dangerCheckbox = input;
}
confirmButton.textContent = 'Delete ' + album.title;
confirmButton.disabled = false;
cancelButton.disabled = false;
closeButton.disabled = false;
dialogElement.showModal();
// Focus lands on Cancel, not Delete — the safe action is the one within
// reach of an unthinking Enter key.
cancelButton.focus();
}
function dismiss() {
if (pending) return;
dialogElement.close();
}
// Escape fires "cancel" before the dialog closes. Blocking it while pending
// is what stops a second keypress from unmounting the dialog mid-delete.
dialogElement.addEventListener('cancel', function (event) {
if (pending) event.preventDefault();
});
// A click that lands on the dialog element itself (not any of its children)
// landed on the backdrop.
dialogElement.addEventListener('click', function (event) {
if (event.target === dialogElement) dismiss();
});
closeButton.addEventListener('click', dismiss);
cancelButton.addEventListener('click', dismiss);
confirmButton.addEventListener('click', function () {
if (pending) return;
pending = true;
errorElement.hidden = true;
var alsoDeleteFiles = dangerCheckbox !== null && dangerCheckbox.checked;
var originalLabel = confirmButton.textContent;
cancelButton.disabled = true;
closeButton.disabled = true;
confirmButton.disabled = true;
confirmButton.setAttribute('aria-busy', 'true');
confirmButton.textContent = 'Deleting…';
window.setTimeout(function () {
pending = false;
cancelButton.disabled = false;
closeButton.disabled = false;
confirmButton.disabled = false;
confirmButton.removeAttribute('aria-busy');
if (failToggle.checked) {
// One-shot: the toggle arms exactly the next attempt, not every one
// after it.
failToggle.checked = false;
confirmButton.textContent = originalLabel;
errorElement.hidden = false;
errorElement.textContent = 'The delete did not complete. The server did not respond in time — try again.';
cancelButton.focus();
return;
}
albums = albums.filter(function (item) {
return item.id !== currentAlbum.id;
});
renderList();
logElement.textContent = 'Deleted ' + currentAlbum.title +
(alsoDeleteFiles ? ', including its audio files on disk.' : '.');
dialogElement.close();
}, 1100);
});
renderList();Paste this into an agent to rebuild the pattern from scratch.
Build the one confirm dialog worth keeping: a destructive delete that cannot be deferred and cannot be undone, so it earns the click a confirm dialog normally costs for nothing.
Reach for this on a delete that is final the moment it runs — a record with rows that cascade from it, a file, an account. Walk away whenever the action is reversible and cheap to reverse; that case wants `undo-toast` instead, which removes the row on the spot and holds the real delete behind an expiring toast. Putting a confirm dialog in front of every delete trains people to click through it without reading, which is exactly the habit that makes the one delete that matters dangerous.
**Name what else goes with the item.** "Delete this album?" tells a person nothing they didn't already know; "this also deletes 3 playlists and 142 play counts" tells them what they are actually agreeing to lose. Cascade is a required input to the dialog, not an optional prop — pass an explicit "nothing cascades" rather than omitting it, so a developer cannot ship the confirmation without deciding what belongs in it. Drop a zero-count tally from the sentence entirely; "0 playlists" is not a fact worth stating, it is a row that should not be there.
**The dialog cannot be dismissed while the delete is running.** No backdrop click, no Escape, no close button — block `cancel` events and swallow the button handlers on a pending check, so a second click cannot unmount the dialog with the request still in flight. A destructive action that can vanish from the screen mid-run is the one place a person genuinely cannot tell whether it went through.
**Every open resets both the error and the options.** A stale failure message from three albums ago greets nobody usefully, and a checkbox left ticked from a run that was cancelled is worse than useless — it is a second destructive action riding along on the next delete, silently. Reset state on open, not on close, so a dialog cancelled mid-thought starts the next album completely clean.
**A reversible action overrides the "cannot be undone" copy, and it is the one row this pattern must get right.** If one confirm in the app says "cannot be undone" about something that plainly can be, people learn that the phrase is decorative, and they stop reading it on the delete that actually means it. Pass the real consequence in, per call site — a permanent delete says so, and a soft-delete says where it went and how long the window is.
Name the thing on the confirm button — "Delete Moonlight Sessions", not "Delete" — so confirming is not an act of faith in what the last click was about. Give an icon-only trigger a real `aria-label` naming the same thing; a bare trash glyph in a dense row list has no accessible name at all otherwise.
Build it on a native `<dialog>` with `showModal()`, not a hand-rolled overlay — the browser gets focus trapping and top-layer stacking right for free. Land focus on Cancel when it opens, not Delete, so an unthinking Enter press lands on the safe action. A transient pending state is the one place the native `disabled` attribute is correct here, exactly as it is for a busy button anywhere else in this library — it lasts a moment and nobody needs to interact with a control mid-request.
Every colour comes from theme custom properties, including the backdrop and the error banner, so the dialog reads correctly in both light and dark themes.