Recipes
Summary
Complete, copy-paste patterns for the situations server-rendered apps hit every day. Each recipe runs live on this page with exactly the code shown, composes plain Soma APIs (no hidden helpers), and notes where your server takes over. The safety habits are built in: content is inserted as text (never HTML), listeners are delegated so ajax-injected triggers keep working, and destructive actions are modeled as POSTs.
1 — Confirm a destructive action
A declarative trigger: any element with
data-confirm opens a danger-styled confirmation
via Soma.confirm. One delegated listener wires
every current and future trigger; the shorthand inserts the
title and body as text (a title of
<img onerror=…> stays text), builds the
dialog on demand, and removes it after it closes, so
repeated opens can't accumulate state. Try it:
HTML
<a href="/api/devices/42"
class="soma-button"
data-confirm="This permanently removes the device gateway-west."
data-confirm-title="Remove device"
data-confirm-action="Remove">
<span class="soma-icon soma-icon-trash"></span> Remove device
</a>
JavaScript
// One delegated listener wires every current and future trigger.
document.addEventListener('click', async (e) => {
const trigger = e.target.closest('[data-confirm]');
if (!trigger) return;
e.preventDefault();
const ok = await Soma.confirm({
title: trigger.getAttribute('data-confirm-title') || 'Are you sure?',
body: trigger.getAttribute('data-confirm'),
appearance: 'danger',
confirmLabel: trigger.getAttribute('data-confirm-action') || 'Confirm',
});
if (ok) {
// Real app: destructive actions are POSTs, never GET navigations:
// await fetch(trigger.getAttribute('href'), { method: 'POST', headers: csrfHeaders() });
// location.reload();
Soma.toast({
appearance: 'success',
title: 'Removed',
body: `POST ${trigger.getAttribute('href')} would run here.`,
});
}
});
2 — Load a form into a dialog
The workhorse of admin screens: a button fetches a
server-rendered form fragment, shows it in a dialog, and
wires validation when the dialog opens. The wiring keys off
the bubbling soma-dialog-show event, so it
works no matter how the content got there. This demo pulls
the fragment from a <template> instead of
the network; the fetch line to swap in is in the code:
JavaScript
// Wire forms when a dialog opens — one listener covers every dialog,
// however its content arrived (fetch, template, server-rendered).
document.addEventListener('soma-dialog-show', (e) => {
if (e.target.id !== 'profile-dialog') return;
const form = e.target.querySelector('form');
if (!form || form.dataset.wired) return;
form.dataset.wired = 'true';
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const name = form.querySelector('#pr-name');
const field = name.closest('.soma-field');
const slot = field.querySelector('.soma-field-message');
if (!name.value.trim()) {
field.classList.add('soma-field-error');
name.setAttribute('aria-invalid', 'true');
slot.textContent = 'Display name is required.';
slot.hidden = false;
return;
}
// Real app: await fetch('/profile', { method: 'POST', body: new FormData(form) })
Soma.dialog2('#profile-dialog').hide();
Soma.toast({ appearance: 'success', title: 'Profile saved', body: `Hello, ${name.value.trim()}!` });
});
});
document.getElementById('open-profile').addEventListener('click', () => {
const content = document.querySelector('#profile-dialog .soma-dialog2-content');
// Real app: content.innerHTML = await (await fetch('/profile/edit')).text();
content.replaceChildren(
document.getElementById('profile-form-template').content.cloneNode(true)
);
Soma.dialog2('#profile-dialog').show();
});
3 — Show server validation errors on fields
The classic server contract: validation returns
{ status, result: { fieldName: message } } and
the page maps messages onto the matching fields. The helper
below clears old errors, matches fields by their
name, sets aria-invalid, and drops
unknown keys silently. Try saving with and without an
@ in the email:
JavaScript
function applyFieldErrors(form, errors) {
form.querySelectorAll('.soma-field').forEach((field) => {
field.classList.remove('soma-field-error');
const slot = field.querySelector('.soma-field-message');
if (slot) { slot.textContent = ''; slot.hidden = true; }
});
form.querySelectorAll('[aria-invalid]').forEach((el) => el.removeAttribute('aria-invalid'));
for (const [name, message] of Object.entries(errors || {})) {
const input = form.elements[name];
const field = input?.closest('.soma-field');
const slot = field?.querySelector('.soma-field-message');
if (slot) {
field.classList.add('soma-field-error');
input.setAttribute('aria-invalid', 'true');
slot.textContent = message;
slot.hidden = false;
}
}
}
document.getElementById('account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
// Real app: const res = await (await fetch('/account/validate', {
// method: 'POST', body: new FormData(form) })).json();
const email = form.elements.email.value;
const res = email.includes('@')
? { status: 'success', result: {} }
: { status: 'fail', result: { email: 'Enter a valid email address.' } };
applyFieldErrors(form, res.result);
if (res.status === 'success') {
Soma.toast({ appearance: 'success', title: 'Account saved' });
}
});
For long forms, pair the per-field messages with the
form error summary
(.soma-form-errors) so keyboard and
screen-reader users get one announced list of everything
that failed.
See also
Dialogs · Forms · Toasts · Getting started (Common patterns) · Integration