Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.
Load js-forms.js and its stylesheet with the page's nonce, wrap the steps in a .coredna-js-form container, print the form's conditionals for js-forms-conditionals.js, add js-payment-element.js for a payment, and point the container at a result callback, so a failed submission shows its errors to the visitor.
Context
Context. A multi-step form that should move between steps without reloading, show and hide fields as the visitor answers, or take a payment, uses the scripts every Core dna site serves under /assets/forms/. They are the platform's and update with it, so the layout loads them rather than copying them. Each expects particular markup, and one of them needs a fix in the layout before it handles errors.
Before you start
A form built in the admin, and a form layout template in the site's repository whose layout record the form uses. The layout receives the form as $form. The site's own JavaScript bundle, for the callback.
Steps
In the form layout, load js-forms.css and js-forms.js with the page's nonce. Add js-forms-conditionals.js if the form has conditional rules, as a plain or defer tag, never async: it starts only on DOMContentLoaded.
Print the form's conditionals into window['formConditionals' + form id]. Without it, the console says "Conditionals not found for form ID" and every field stays visible.
Wrap the form in a .coredna-js-form container with a hidden _formId field, and render one .form-step per step with data-step-name set to the step's name. Every step except the current one carries js-hidden.
Give each normal step a .navigate-previous link after the first, and a .submit-error-message paragraph for general errors.
Put data-submit-result-callback="formResult" on the container, and define window.formResult in the site's bundle: it is what shows a rejected submission's errors and re-enables the button.
If the form takes a payment, add js-payment-element.js after js-forms.js, with the nonce and defer. It takes over the result callback and calls yours, but it reads the attribute only once, so the attribute must be in the markup when the page loads.
Test three things on a development site: a field rule shows and hides its field, a submission with a bad email shows the message beside the field with every typed answer still there, and a good submission shows the result step.
Code
Template code
{* the form's rules, for js-forms-conditionals.js *}
{$rules = []}
{foreach $form->getActiveRevision()->getConditionals() as $conditional}
{$rules[] = ['conditions' => $conditional->getConditions(), 'actions' => $conditional->getActions()]}
{/foreach}
<script nonce="{$_inlineScriptNonce}">
window['formConditionals{$form->getId()}'] = {$rules|json_encode};
</script>
<link rel="stylesheet" href="/assets/forms/js-forms.css">
<script src="/assets/forms/js-forms.js" nonce="{$_inlineScriptNonce}"></script>
<script src="/assets/forms/js-forms-conditionals.js" nonce="{$_inlineScriptNonce}"></script>
{* only when the form takes a payment *}
<script src="/assets/forms/js-payment-element.js" nonce="{$_inlineScriptNonce}" defer></script>
{# the form's rules, for js-forms-conditionals.js #}
{% set rules = [] %}
{% for conditional in form.getActiveRevision().getConditionals() %}
{% set rules = rules|merge([{ conditions: conditional.getConditions(), actions: conditional.getActions() }]) %}
{% endfor %}
<script nonce="{{ _inlineScriptNonce }}">
window['formConditionals{{ form.getId() }}'] = {{ rules|json_encode }};
</script>
<link rel="stylesheet" href="/assets/forms/js-forms.css">
<script src="/assets/forms/js-forms.js" nonce="{{ _inlineScriptNonce }}"></script>
<script src="/assets/forms/js-forms-conditionals.js" nonce="{{ _inlineScriptNonce }}"></script>
{# only when the form takes a payment #}
<script src="/assets/forms/js-payment-element.js" nonce="{{ _inlineScriptNonce }}" defer></script>
{% set current = form.getCurrentStep() %}
<div class="coredna-js-form" data-submit-result-callback="formResult">
{% for step in form.getSteps() %}
<div class="form-step{% if step.getPosition() != current.getPosition() %} js-hidden{% endif %}" data-step-name="{{ step.getName() }}">
{% if step.getType() == 'normal' %}
<form method="post">
<input type="hidden" name="_formId" value="{{ form.getId() }}">
{% for element in step.getElements().filtered('parent', null) %}
{{ element.getHtml() }}
{% endfor %}
{% if step.getPosition() > 1 %}<a href="#" class="navigate-previous">Back</a>{% endif %}
<button type="submit">Continue</button>
<p class="submit-error-message js-hidden" role="alert"></p>
</form>
{% else %}
{% for element in step.getElements() %}{{ element.getHtml() }}{% endfor %}
{% endif %}
</div>
{% endfor %}
</div>
JavaScriptThe result callback (the full version, which also jumps back to an earlier step, is on Validation)
// form-result.js, in the site's bundle: shows a rejected submission without losing what was typed
window.formResult = ({ xhr, formContainer }) => {
// success: let the platform show the result step, or redirect
if (xhr.status >= 200 && xhr.status < 300 && xhr.status !== 207) return true;
const body = xhr.response || {};
const errors = body.validationErrors || {};
let firstField = null;
formContainer.querySelectorAll('.form-error-js').forEach((old) => old.remove());
Object.keys(errors).forEach((name) => {
const field = formContainer.querySelector(`[name="${name}"], [name="${name}[]"]`);
const block = field && field.closest('.input-element-block');
if (!block) return;
const message = document.createElement('div');
message.className = 'input-element-validation-errors form-error-js';
message.id = 'form-error-' + name;
message.textContent = errors[name].map((error) => error.message).join(' ');
block.appendChild(message); // appended, never innerHTML: the typed values stay
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', message.id);
firstField = firstField || field;
});
// the platform disabled the buttons when the submit started: give them back
formContainer.querySelectorAll('button[type="submit"]').forEach((button) => button.removeAttribute('disabled'));
const general = formContainer.querySelector('.form-step:not(.js-hidden) .submit-error-message');
if (general) {
general.textContent = Object.keys(errors).length
? 'Please check the highlighted answers.'
: 'The form could not be sent. Please try again.';
general.classList.remove('js-hidden');
}
if (firstField) setTimeout(() => firstField.focus(), 100);
return false; // the platform does nothing more
};
Why this and not that
Not a copy of the scripts in the theme: they are the platform's, they change with it, and a copied one stops matching the server it talks to. Not without the result callback: the platform's own handling of a rejected submit throws, and if it ran it would rebuild each field and throw away what was typed. Not the conditionals script loaded with async or from another script: load it as a plain or defer tag, so it always starts on DOMContentLoaded. Not a form added to the page after load: js-forms.js sets up the containers it finds once, and offers no function to set up another. And not a hidden step holding required fields: hiding a step does not relax its requirements on the server, so the form can never be completed. Hide a group instead.