5.0.0-beta.1 · Published beta · b06750c5. Published on npm. Match your installed version.
ConsumerForms and accessible interactions#
Use native form controls and keep an unfinished draft in the existing input DOM. A Marionette View owns the form and its pending save; the application supplies the persistence operation. A DataApi or StateApi is not required for this local draft. Choose a shared observable source only when other owners need to observe it.
Save without replacing the user's input#
This complete module uses the default DOM and event implementations. The template
contains only trusted, fixed markup. User data is assigned through value or
textContent. Each instance gets its own label and message IDs.
import { View } from 'marionette';
export const ProfileForm = View.extend({
tagName: 'form',
attributes: { 'aria-label': 'Profile' },
templateContext() { return { id: this.cid }; },
template({ id }) {
return `<label for="${id}-name">Display name</label>
<input id="${id}-name" name="displayName" required
autocomplete="nickname" maxlength="80"
aria-describedby="${id}-status">
<button type="submit">Save</button>
<p id="${id}-status" role="status" aria-live="polite"></p>`;
},
events: { submit: 'onSubmit' },
initialize({ displayName, save }) {
this.initialName = displayName;
this.save = save;
this.pendingSave = null;
},
onRender() {
this.el.elements.namedItem('displayName').value = this.initialName;
},
onBeforeRender() {
this.cancelSave();
},
onSubmit(event) {
event.preventDefault();
return this.submit();
},
async submit() {
if (this.isDestroyed() || this.pendingSave) return false;
if (!this.el.reportValidity()) return false;
const input = this.el.elements.namedItem('displayName');
const button = this.el.querySelector('button');
const status = this.el.querySelector('[role="status"]');
const request = new AbortController();
this.pendingSave = request;
input.readOnly = true;
button.disabled = true;
this.el.setAttribute('aria-busy', 'true');
status.textContent = 'Saving…';
const displayName = input.value;
try {
await this.save({ displayName }, { signal: request.signal });
if (request.signal.aborted || this.isDestroyed()) return false;
this.initialName = displayName;
status.textContent = 'Saved.';
return true;
} catch {
if (request.signal.aborted || this.isDestroyed()) return false;
status.textContent = 'Could not save. Your changes are still here. Try again.';
return false;
} finally {
if (this.pendingSave === request) {
this.pendingSave = null;
input.readOnly = false;
button.disabled = false;
this.el.removeAttribute('aria-busy');
}
}
},
cancelSave() {
this.pendingSave?.abort();
this.pendingSave = null;
this.el.removeAttribute('aria-busy');
},
onBeforeDestroy() {
this.cancelSave();
}
});
Mount it through a Region. This example's persistence is deliberately in memory;
replace save with the application's API client for durable storage.
import { Region } from 'marionette';
import { ProfileForm } from './profile-form.js';
const mount = document.createElement('main');
document.body.append(mount);
const region = new Region({ el: mount });
let savedProfile = { displayName: 'Taylor' };
region.show(new ProfileForm({
...savedProfile,
async save(profile, { signal }) {
signal.throwIfAborted();
savedProfile = profile;
}
}));
// When the feature is removed: region.destroy(); mount.remove();
The submit event handles the button and keyboard submission. Native required
validation prevents an empty save. While saving, the input is read-only and the
button is disabled; duplicate programmatic submissions return false. A failure
keeps the same input, its value, and its selection. The live status announces the
outcome without replacing the form or forcing focus elsewhere.
Do not call render() for a status change. An explicit rerender is a reset to the
last saved value: it cancels a pending request before replacing the controls.
Destruction also aborts the request. The signal check matters even if a client
ignores cancellation. Aborting does not prove a server rolled back a write;
reconcile ambiguous writes through the application's API contract.
For server field validation, map known field errors to visible messages, set
aria-invalid="true", and connect each message with aria-describedby. Clear
those errors when corrected. Keep an error summary focusable when the user needs
to move among several invalid fields. Avoid displaying raw server errors.
WAI's form guidance explains labels and
structure; its notification guidance
explains associating errors and communicating results.
Focus when a screen changes#
A Region owns destruction and insertion; it does not decide the application's
navigation focus policy. After a user-initiated route change has successfully
shown the new screen, update document.title and focus a meaningful heading with
tabindex="-1". Keep that operation after the current-navigation check in the
routing guide. A stale response must neither replace the page nor
move focus. Background refreshes should normally leave focus where the user put it.
Prefer <button> for actions and <a href> for navigation. A delegated click on a
<div> does not supply native keyboard semantics. In delegated handlers,
event.delegateTarget identifies the matched control; event.target may be its
nested icon. See DOM interactions.
Verify the experience#
The executable form fixture checks unique labels, literal untrusted text, duplicate saves, retained input and focus, errors, cancellation, and late results. It uses a simulated DOM; it does not establish screen-reader announcements or native browser validation UI.
In the real application, tab through the form, submit with Enter, cause an API failure, navigate away during a save, and confirm there is no unexpected focus jump. Check labels and notifications with the assistive technology your users rely on. Automated accessibility checks supplement that interaction review.