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:

Edit profile

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:

Used for notifications only.

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