a11y-form-draft-persistence
v1.0.0
Published
Framework-agnostic, accessible draft persistence for semantic HTML forms and editor-like workflows.
Maintainers
Readme
A11yFormDraftPersistence
Framework-agnostic, accessible draft persistence for semantic HTML forms and editor-like workflows.
It preserves eligible form values through a browser-storage adapter, protects against conflict overwrites, and creates no UI unless an optional addon is used. Passwords, one-time codes, payment-like autocomplete fields, hidden inputs, and file input contents are excluded by default.
Browser storage is not encrypted or secure. Do not persist secrets or sensitive personal data without an appropriate application architecture. This package does not synchronize drafts across devices or replace server-side storage.
Install
npm install a11y-form-draft-persistence
# pnpm add a11y-form-draft-persistence
# yarn add a11y-form-draft-persistenceBasic usage
Use semantic form controls with real labels, and supply a stable, application-owned key. Do not derive production keys only from a URL.
import { createDraftPersistence } from "a11y-form-draft-persistence";
const form = document.querySelector("form");
if (form) {
const drafts = createDraftPersistence(form, {
formVersion: 1,
key: "service-intake:production:application-123:v1"
});
const result = await drafts.check();
if (result.status === "available") {
// Render your own restore decision, or use the optional restore-prompt addon.
}
}The package never auto-initializes on import. The minified ESM entry is available from a11y-form-draft-persistence/min.
API
createDraftPersistence(root, options) creates (or reuses) an A11yFormDraftPersistence instance. initDraftPersistenceAll(options) initializes roots with data-a11y-form-draft-persistence; it passes the same options, including key, to every matched root. Use individual instances when forms need distinct draft keys.
key and formVersion are required. Options also cover storage (adapter), autosave (autoSave, saveDelay, maxSaveWait, saveOnBlur, saveWhenHidden, and saveOnPageHide), expiry (expiresAfter), restoration (restoreStrategy), external-change notices (crossTab), and field selection. The default restore strategy is prompt; autosave is enabled unless autoSave is false.
Instances provide check(), save(), restore(), clear(), pause(), resume(), getStatus(), and destroy().
Public events are namespaced with a11y-form-draft-persistence:. Use
DRAFT_PERSISTENCE_EVENTS and the exported DraftPersistenceEventDetail /
DraftPersistenceEventMap types; event details include metadata such as status
and key, never raw saved field values. See EVENTS.md for the
complete lifecycle-event contract.
Optional addons
a11y-form-draft-persistence/addons/statusconnects events to host-provided visible and optional live status elements. Save success stays non-live.a11y-form-draft-persistence/addons/restore-promptrenders a non-modal restore region in a host-provided container.a11y-form-draft-persistence/addons/submission-recoverymaps explicit submission-outcome events to save, pause, resume, and clear only when that submission save reportssavedorunchanged. Use its exportedDRAFT_SUBMISSION_RECOVERY_EVENTSconstants instead of hard-coded default event names. A nativesubmitattempt never clears a draft; retained drafts are reported by a value-free outcome event.a11y-form-draft-persistence/addons/setup-inspectorperforms one explicit, value-free development scan. It returns stable diagnostic codes and counts only; it creates no UI and never runs automatically.
Development setup inspection
Use the optional setup inspector in development or CI to catch invalid selectors, empty field selection, duplicate or order-dependent identities, sensitive-field overrides, and optionally unavailable storage. It never returns field values, names, selectors, DOM references, keys, adapter IDs, or adapter error messages.
import { inspectDraftSetup } from "a11y-form-draft-persistence/addons/setup-inspector";
const report = await inspectDraftSetup(form, {
key: "service-intake:production:application-123:v1",
formVersion: 1,
adapterCheck: "read" // optional; the default is "none"
});
if (!report.ready) {
throw new Error(`Draft setup diagnostics: ${report.codes.join(", ")}`);
}adapterCheck: "read" makes one adapter read and discards its result; it never writes. Custom adapters can have their own side effects, so enable this check only for adapters whose read behavior you control. The inspector is not an accessibility audit: it does not assess labels, validation, or application markup.
The diagnostic codes are intentionally generic and stable: MISSING_KEY, MISSING_FORM_VERSION, INVALID_SELECTOR, NO_ELIGIBLE_FIELDS, IDENTITYLESS_FIELD, UNNAMED_RADIO_GROUP, DUPLICATE_FIELD_IDENTITY, ORDER_DEPENDENT_IDENTITY, SENSITIVE_PERSISTENCE_OVERRIDE_ENABLED, CONTROL_ADAPTER_ERROR, FIELD_CONFIGURATION_ERROR, and STORAGE_UNAVAILABLE. Counts identify scope without revealing which control caused a finding.
Localized addon text
Both presentation addons retain their current English defaults, but applications can replace their wording without copying addon code. Omitted prompt keys use the default; an empty string is intentional and is not replaced.
createDraftRestorePrompt(drafts, {
container,
messages: {
heading: "Saved application available",
restore: "Restore application",
keep: "Keep current answers",
clear: "Delete saved application"
}
});
createDraftStatus(drafts, {
element: statusElement,
liveElement,
formatMessage(event) {
return messages[event.status];
}
});createDraftRestorePrompt(..., { messages }) accepts these typed keys. The older top-level text options remain supported; a messages value takes precedence.
| Prompt key | Default message | Used for |
| --- | --- | --- |
| heading | Saved draft available | Prompt heading |
| description | A saved draft is available for this form. | Prompt explanation |
| expiredHeading | Saved draft expired | Expired-draft heading |
| expiredDescription | This saved draft has expired and can no longer be restored. | Expired-draft explanation |
| restore | Restore saved draft | Restore button |
| keep | Keep current values | Dismiss button |
| clear | Clear saved draft | Clear button |
| pendingRestore | Restoring saved draft… | Restore in progress |
| pendingClear | Clearing saved draft… | Clear in progress |
| restored | Saved draft restored. | Completed restore announcement |
| cleared | Saved draft cleared. | Completed clear announcement |
| restoreError | Saved draft could not be restored. Try again. | Failed restore |
| clearError | Saved draft could not be cleared. Try again. | Failed clear |
The status formatter receives one of these typed event.status values and is called once for each supported persistence event. If it throws or returns a non-string value, the addon uses its default English message so a translation failure does not break status feedback.
| Status key | Default message | Live by default |
| --- | --- | --- |
| saved | Draft saved. | No |
| storage-error | Draft could not be saved. | Yes |
| restored | Saved draft restored. | Yes |
| partial | Saved draft restored. | Yes |
| cleared | Saved draft cleared. | No |
| confirmation-required | Review current values before restoring the saved draft. | Yes |
Use short, action-oriented wording that describes the outcome rather than storage internals. Keep autosave success non-live, as the addon does by default. For important updates, provide a separate, persistent polite live region through liveElement; do not also make element live, unless both options deliberately reference the same element, or a message may be announced twice.
Restore prompt outcomes
The restore-prompt addon keeps its decision interface available until it receives a confirmed outcome. A restore removes the prompt only after restored or partial; a clear removes it only after cleared or already-empty. Any other result, including storage-error and busy, leaves the prompt in place with a visible failure message and enabled retry buttons. An expired draft has no Restore button, but retains its clear and dismiss actions.
Use an application-owned container for the decision UI. The addon exposes its current state with data-state (available, expired, pending, restore-failed, or clear-failed), and the visible outcome element has data-a11y-form-draft-prompt-outcome. It ships no presentation CSS, so the host can style these hooks without depending on color, hover, or motion.
<div id="draft-restore-prompt"></div>
<p id="draft-recovery-announcements" role="status" aria-atomic="true"></p>import { createDraftRestorePrompt } from "a11y-form-draft-persistence/addons/restore-prompt";
const restorePrompt = createDraftRestorePrompt(drafts, {
container: document.querySelector("#draft-restore-prompt")!,
// Optional: an existing host-owned polite live region.
liveElement: document.querySelector("#draft-recovery-announcements")!,
pendingMessage: "Updating your saved draft…",
restoreErrorMessage: "Your saved draft could not be restored. Try again.",
clearErrorMessage: "Your saved draft could not be cleared. Try again."
});
await restorePrompt.check();container is required. heading, liveElement, pendingMessage, restoreErrorMessage, and clearErrorMessage are optional. While restore or clear is pending, all available native decision buttons are disabled, preventing repeated activation. The addon does not announce pending changes. It updates liveElement once for a successful or failed decision when supplied; use one host-owned polite region and avoid also announcing the same outcome through another status addon. Failed operations do not move focus, so the initiating button remains the retry target.
Draft context summaries
Use describeDraft to add application-owned context for an available draft. It receives only conflictCount, createdAt, updatedAt, and optional expiresAt—never saved values, field names, draft keys, or custom record metadata. If the formatter throws or returns a non-string, the addon uses its ordinary description message instead.
createDraftRestorePrompt(drafts, {
container,
describeDraft({ conflictCount, expiresAt, updatedAt }) {
const formatter = new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Athens"
});
const expiry = expiresAt
? ` It expires ${formatter.format(new Date(expiresAt))}.`
: " It has no automatic expiry.";
const conflicts = conflictCount === 0 ? "" : ` Restoring will replace ${conflictCount} current answers.`;
return `Saved ${formatter.format(new Date(updatedAt))}.${expiry}${conflicts}`;
}
});Choose the locale and time zone deliberately; do not rely on the browser default when a workflow requires a consistent interpretation. The formatter is opt-in because timestamps and conflict counts can disclose workflow activity. Define any “expiring soon” threshold in application copy, where the product’s retention policy is known.
An expired draft is shown as a non-restorable clear-or-dismiss decision. Configure messages.expiredHeading and messages.expiredDescription with the rest of the prompt messages when localizing it.
Operating guidance
Shared devices
Do not enable browser draft persistence for kiosks, public terminals, shared household profiles, or any browser profile that another person may use. A session-storage adapter limits a draft to a browser session; it does not make that session private. If the application changes from a private to a shared-device context, clear any existing draft and stop saving new ones. Prefer an application-owned account or server-side recovery flow when that is appropriate for the data and product.
Retention recipe
Choose a positive, explicit expiresAfter duration that fits the workflow, use a stable application-owned key scoped to the environment and record, and expose a visible Clear draft control that calls clear(). Do not place secrets or sensitive values in the key itself.
const drafts = createDraftPersistence(form, {
expiresAfter: 1000 * 60 * 60 * 24, // 24 hours
formVersion: 1,
key: "service-intake:production:application-123:v1"
});
clearDraftButton.addEventListener("click", async () => {
await drafts.clear();
});Expiry prevents a stale record from being available for restoration; it does not itself remove the stored value. Clear expired records as part of the application-owned recovery experience when appropriate.
Consent and privacy checklist
The package can save eligible values to a configured browser-storage adapter. It does not collect consent, classify data, determine a lawful basis, publish retention terms, or make a deployment compliant. Before enabling persistence, the application team should decide:
- whether the workflow and device context are suitable for browser storage;
- which fields are eligible and whether the defaults need further exclusions;
- the retention period, clear-draft experience, and key scope;
- whether notice, consent, account controls, or other safeguards are required for its users and jurisdictions.
Cross-tab behavior
With crossTab: true, an adapter that supports subscribe() can report that another context changed or cleared the same key. The package does not merge values, restore automatically, serialize operations, resolve conflicts between tabs, or lock a draft. Treat external-update as a signal to offer an application-owned check or refresh decision.
Page lifecycle and BFCache
Each instance listens for pageshow, independently of autosave. After a
persisted pageshow (a BFCache restoration), it runs check() when idle so the
host can reconcile draft status and conflict metadata. That check does not
restore or overwrite values, save a draft, move focus, or dispatch native
input/change events. Whether a page is admitted to BFCache is browser- and
application-dependent, so verify the intended navigation path in target
browsers.
Custom controls
To persist and restore a custom control, provide a customControlAdapters entry with a stable id, matches(), read(), write(), and—when needed—getFieldKey(). The adapter's read() result must be JSON-safe. During restoration, the package finds a live control with the saved identity and calls write(element, savedValue, context); it does not create a replacement control, move focus, or dispatch native input/change events. A missing, changed, or failing adapter produces a partial restore.
Accessibility behavior
The core creates no live region, dialog, focus target, or CSS. Saving and restoring do not move focus or dispatch native input/change events. The restore-prompt addon uses a labelled non-modal region with real buttons, no autofocus, and no focus trap. Its optional announcement target is host-owned; it never creates an assertive live region.
Test the final application markup with your target assistive technologies, browser matrix, translations, zoom levels, and storage restrictions.
Limitations
- Custom controls require an explicit adapter with a stable ID and identity; absent or changed adapters are skipped during restoration.
- Cross-tab support is a notification, not synchronization or locking.
pagehideand hidden-document saves are best effort; asynchronous storage cannot be guaranteed while unloading.- File contents are never persisted.
- The setup inspector is a point-in-time diagnostic, not a guarantee that future dynamic markup or custom-adapter behavior remains safe.
Examples and metadata
The basic example demonstrates manual controls. The Autosave + Autorestore example shows quiet debounced saves and safe, empty-field-only recovery on page load. The community grant application shows a realistic conflict-aware recovery flow with sensitive-field exclusions. The draft-context summary example shows localized save, expiry, and overwrite context without exposing saved values. The setup inspector example demonstrates value-free configuration diagnostics. The conditional-fields integration example shows how one value-free restore listener resynchronizes conditional visibility, disabled controls, and restored values without a public bridge addon. The GitHub Pages-ready documentation and example atlas connects the full set of focused browser demos.
Supporting guides: architecture, accessibility, security and privacy, adapters, events, integrations, and testing.
Project tracking: roadmap, implementation tasks, and contributing.
Report suspected vulnerabilities through the private route documented in SECURITY.md; do not disclose vulnerability details or sensitive form values in a public issue.
import { docs } from "a11y-form-draft-persistence/docs";Development
npm install
npm run build
npm run build:docs
npm run typecheck
npm test
npm run check:exports
npm run test:browser
npm run pack:checkThe local browser command uses installed Chromium. CI sets
CI_FULL_BROWSER_MATRIX=1 after installing Chromium, Firefox, and WebKit. No
publish, push, tag, or release action is performed by these commands.
