GitHub

smartforms

smartforms

State-aware client-side form manager for static HTML forms. Tracks per-field state (new, unchanged, changed, invalid, empty) and toggles toolbar actions accordingly

@hradigital/smartforms

State-aware client-side form manager for static HTML forms.

Server-rendered apps still ship plain HTML forms, but users expect client-side behaviour: fields that visibly react the moment their value changes, Save / Delete buttons that enable only when there is actually something to save or delete, inline validation feedback, and toolbar actions wired to the right HTTP verb and endpoint. Reaching for React or Vue just to get this is overkill — it forces a build step and a component rewrite of the markup the server already produces. smartforms fills that gap: it layers per-field state tracking, validation, and CRUD toolbar wiring onto the static HTML you already render, driven entirely by HTML attributes, with no framework and no build step.

smartforms adds dynamic state tracking to plain HTML forms — no framework, no JSX, no template engine. Mark a <form> with the smartform class and the library will:

  • Track per-field state (new, unchanged, changed, invalid, empty) and apply matching CSS classes for styling.
  • Manage two form layouts out of the box: a list of records (smartformlist) or a single record (smartformrecord).
  • Enable / disable toolbar buttons (create, update, delete, cancel, ...) based on the form's overall state.
  • Emit formStateChange CustomEvents you can hook into.

Installation

npm install @hradigital/smartforms
# or
yarn add @hradigital/smartforms
# or
pnpm add @hradigital/smartforms

Or load the UMD build directly in the browser:

<script src="https://unpkg.com/@hradigital/smartforms/dist/smartforms.umd.js"></script>
<script>
    SmartForms.autoInit();
</script>

Quick start — single record

<form class="smartform" data-resource="users">
    <input type="hidden" name="id" value="7" />
    <div class="smartformrecord">
        <input type="text"  name="name"  value="Ada" />
        <input type="email" name="email" value="ada@example.com" />
    </div>

    <nav class="toolbar">
        <button type="button" data-role="update">Save</button>
        <button type="button" data-role="cancel">Cancel</button>
    </nav>
</form>
import { autoInit } from '@hradigital/smartforms';

autoInit(); // scans for forms and toolbars

When a user edits a field, smartforms flips the field state to CHANGED, the form's overall state follows, and the update / cancel buttons enable themselves automatically. Clicking update executes PUT /users/7 (the default task for the update role, with the form's resource and ID auto-inserted). Click cancel to reset the form.


Quick start — list of records

<form class="smartform" data-resource="users">
    <div class="smartformlist">
        <table>
            <thead>
                <tr>
                    <th><input type="checkbox" name="checkall-toggle" /></th>
                    <th>Name</th>
                </tr>
            </thead>
            <tbody>
                <tr><td><input type="checkbox" name="cid[]" value="1" /></td><td>Ada</td></tr>
                <tr><td><input type="checkbox" name="cid[]" value="2" /></td><td>Linus</td></tr>
            </tbody>
        </table>
    </div>

    <nav class="toolbar">
        <a href="/users/create" data-role="create">New</a>
        <button type="button" data-role="delete">Delete selected</button>
        <button type="button" data-role="deleteall">Delete all</button>
    </nav>
</form>
import { autoInit } from '@hradigital/smartforms';

autoInit(); // scans for forms and toolbars

Auto-init

If you have one form per page and want zero glue code:

import { autoInit } from '@hradigital/smartforms';

autoInit(); // scans for `form.smartform` and `nav.toolbar`

Field states (CSS hooks)

State classes are applied to the <fieldset> that wraps the input, not to the <input> / <select> / <textarea> itself. Style your fields against the fieldset.

Class on <fieldset> When it's applied
changed The user's current value differs from the initial value and validation passes.
invalid Validation fails (required + empty, or regex/native rule mismatch).
(no class) Field matches its initial value and is valid — pristine state.

changed and invalid are mutually exclusive — setStateChanged strips invalid and vice versa (see BaseInput.js).

Suggested SCSS:

fieldset.changed > input,
fieldset.changed > select,
fieldset.changed > textarea  { border-color: #7ec8ff; background: #fff8d6; }

fieldset.invalid > input,
fieldset.invalid > select,
fieldset.invalid > textarea  { border-color: #f4a3a3; background: #fdecec; }

Note: the form-level states (NORMAL, CHANGED, SELECTED, MANY, ILLEGAL, DEACTIVATED, SUBMITTED) drive toolbar button enable/disable — see "Toolbar action roles" below. They do not produce CSS classes on the form element.


Configuring an input

All configuration is via plain HTML attributes — there are no JS options for individual fields.

On the wrapping <fieldset> (applies to every input type)

Attribute Effect
required Field is required. An empty (whitespace-only) value flips the fieldset to invalid.
recommended Marks the field as recommended. Does not affect validation — exposed via isRecommended() only, so you can target <fieldset recommended> in CSS or add helper text.
<fieldset required>
    <label for="title">Title</label>
    <input id="title" type="text" name="title" />
</fieldset>

On the <input> / <select> / <textarea>

Per-type attributes the package reads:

Input type Attribute Effect
TextInput data-rule Regex string. Validation fails (invalid) if the value doesn't match.
TextInput data-limit Integer hard cap. Blocks keypress once value.length >= limit (silent).
TextInput data-guide Presence flag. Pair with data-limit to render a visible counter (see below).
NumberInput min,max,step Native HTML constraints, read at construction time.
DateTimeInput pattern Native HTML constraint, read at construction time.
TextAreaInput none Reads name and value only. Does not support data-rule / data-limit / data-guide.
All others none Just name and value (or value per option for Select/Radio/CheckBox).
<fieldset required>
    <label for="slug">Slug</label>
    <input id="slug" type="text" name="slug"
           data-rule="^[a-z0-9-]+$"
           data-limit="60"
           data-guide />
</fieldset>

Character counter (data-guide + data-limit)

When both are set on a TextInput, the package inserts a counter as a previous sibling of the <input> inside the same <fieldset>:

<span class="smartguide">
    <span>{currentLength}</span>
    <span>/</span>
    <span>{limit}</span>
</span>

Class hooks:

Class When it's applied
.smartguide Always (on the wrapper span).
.smartguide.warning When currentLength / limit > 0.9 (counter only).
fieldset > .smartguide          { font-size: 0.8em; color: #666; }
fieldset > .smartguide.warning  { color: #c00; font-weight: 600; }

Caveats: counter only updates on keyup. Pasting via mouse menu, programmatic value changes, and form.reset() don't re-render the counter until the next keystroke.


Supported inputs

TextInput, EmailInput, NumberInput, UrlInput, ColorInput, DateTimeInput, FileInput, TextAreaInput, CheckBoxInput, RadioInput, SelectInput, DynamicTableInput.

Input handlers are resolved from the element's tag and type. The mapping is:

Element / type Handler
input[type=text], input[type=password] TextInput
input[type=email] EmailInput
input[type=url] UrlInput
input[type=number] NumberInput
input[type=color] ColorInput
input[type=datetime], input.datetimepicker DateTimeInput
input[type=file] FileInput
input[type=checkbox] CheckBoxInput
input[type=radio] RadioInput
select SelectInput
textarea TextAreaInput
table.smartdynamictable DynamicTableInput

EmailInput extends TextInput, so it accepts the same data-rule / data-limit / data-guide attributes, and additionally flags the fieldset invalid when the value is not a valid email address (^[^\s@]+@[^\s@]+\.[^\s@]+$). An <input> whose type has no registered handler is silently skipped.

You can subclass BaseInput to add your own.


Toolbar placement rule

The nav.toolbar must be a descendant of the same <form> element it controls. Placing the toolbar outside the form breaks two things:

  • The cancel button uses closest('form') to call form.reset(). If the button is outside the form, closest returns null and reset is silently skipped.
  • Input-type detection in SmartFormRecord throws when it encounters an unhandled type, preventing Toolbar from initialising at all — buttons stay permanently enabled.

The toolbar may be placed anywhere inside the form: before or after the smartformrecord / smartformlist div, nested inside any container element. It does not have to be a direct child.

<form class="smartform" id="profile" data-toolbar="t-profile">
    <nav class="toolbar" id="t-profile">
        <button type="button" data-role="update">Save</button>
        <button type="button" data-role="cancel">Cancel</button>
    </nav>

    <div class="smartformrecord">
        <!-- fields -->
    </div>
</form>

Toolbar action roles

Buttons inside nav.toolbar are wired by their data-role:

create, edit, update, updateall, delete, deleteall, destroy, toggle, back, cancel.

Each role resolves to a default HTTP Task descriptor when clicked. The data-task attribute on the button can override the default — see HTTP Tasks below.

Role Default task Notes
create POST /{resource} New record; often rendered as <a href> navigation.
edit GET /{resource}/{id}/edit Navigate to a record's edit screen.
update PUT /{resource}/{id} Single-record update.
updateall PUT /{resource}?ids={ids} Bulk update; query-string ID list.
delete DELETE /{resource}/{id} Single-record delete.
deleteall DELETE /{resource}?ids={ids} Bulk delete; query-string ID list.
destroy DELETE /{resource}/{id} Delete enabled in NORMAL state (e.g. a permanent "delete this record" action).
toggle PATCH /{resource}/{id} Single-record toggle (async). Enabled in SELECTED state only.
back (special) Navigation only; like create, follows its <a href>. No HTTP task.
cancel (special) Calls form.reset() — no HTTP task.

HTTP Tasks

The data-task attribute encodes an HTTP verb and endpoint using a 3-segment format:

[mode:][verb:]endpoint
  • mode (optional): sync or async. If omitted, determined by the verb (GET/POST/PUT/DELETE → sync; PATCH → async).
  • verb (optional): HTTP verb (get, post, put, patch, delete, case-insensitive). If omitted, defaults to GET.
  • endpoint (required): Path or URL template (e.g. /users, /users/{id}, /api:v2/users/{resource}/{id}).

Examples:

<!-- sync GET: redirects to /users -->
<button data-role="create" data-task="/users">View all</button>

<!-- sync POST: submits form to /users via POST -->
<button data-role="create" data-task="post:/users">Save</button>

<!-- sync PUT: submits form via POST with _method=PUT -->
<button data-role="update" data-task="put:/users/{id}">Update</button>

<!-- async PATCH: fetch with Content-Type: application/json -->
<button data-role="toggle" data-task="async:patch:/users/{id}/publish">Toggle</button>

<!-- custom endpoint (override default) -->
<button data-role="update" data-task="put:/api/v2/users/{id}">Save (v2)</button>

Endpoint token substitution

Endpoints support three tokens:

Token Filled with Context
{resource} Form's data-resource attribute Copied from <form data-resource="…">
{id} Form's <input name="id"> value (record) or selected row ID (list, SELECTED state) Auto-appended if missing on PUT/PATCH/DELETE
{ids} Comma-joined list of checked row IDs SmartFormList.selectedIds()

Example:

<form class="smartform" data-resource="users">
    <input type="hidden" name="id" value="7" />
    <!-- Resolves to: PUT /users/7 -->
    <button data-role="update" data-task="put:/{resource}/{id}">Save</button>
</form>

ID auto-append

If a PUT/PATCH/DELETE endpoint does not include {id} and a form <input name="id"> exists, the ID is automatically appended:

<button data-role="update" data-task="put:/users">Update</button>
<!-- With <input name="id" value="7">, resolves to: PUT /users/7 -->

Verb → mode defaults

Verb Default mode
GET sync
POST sync
PUT sync
PATCH async
DELETE sync

A sync request either navigates (GET) or submits the form via POST with method spoofing. An async request uses fetch with optional CSRF headers and dispatches response events.

Sync vs. async

Sync (form submission):

  • GET: window.location.assign(url)
  • POST/PUT/PATCH/DELETE: set form action, spoof method via <input name="_method">, submit normally

Async (fetch):

  • Sends Content-Type: application/json + serialized form data (unless files present, then multipart/form-data)
  • Includes CSRF header from <meta name="csrf-token"> (configurable)
  • Dispatches taskCompleted or taskFailed events (see below)
  • On success, calls form.rebaseline() to reset state

CSRF configuration

By default, the library reads CSRF tokens from:

<meta name="csrf-token" content="…" />

Customize via Tasks.configure():

import { Tasks } from '@hradigital/smartforms';

Tasks.configure({
  csrf: {
    metaName: 'x-csrf-token',      // metadata tag name
    headerName: 'X-Custom-Token',   // header name for fetch
    value: 'static-token-string',   // or a function: () => token
  }
});

Response events

After async requests, the form emits:

  • taskCompleted: { descriptor, status, response, body }

    • body is raw response text (never parsed JSON)
    • Form transitions to NORMAL state
    • form.rebaseline() is called automatically
  • taskFailed: { descriptor, status, response, body, error }

    • status is the HTTP status code (0 for network errors)
    • error is populated on fetch errors only
    • Form state is restored to the pre-request state
    • body and response are null on network errors
form.addEventListener('taskCompleted', (e) => {
  console.log('Status:', e.detail.status, 'Body:', e.detail.body);
  // redirect, show toast, etc.
});

Button enable/disable by state

smartforms separates two concerns:

  • What buttons exist is the markup author's decision, driven by the role of the form (e.g. a single-record edit screen renders update + cancel; a list screen renders create + deleteall). The package never adds, removes, or hides buttons that the template did not render.
  • Whether each rendered button is enabled is driven by the state of the form (NORMAL, CHANGED, SELECTED, MANY, ILLEGAL, DEACTIVATED, SUBMITTED). The package toggles the disabled attribute and the disabled CSS class accordingly.

Practical rule of thumb:

Render only the buttons the form actually supports. Don't render a cancel button on a form that has nothing to cancel, and don't render updateall on a single-record form. If a button is in the markup, the package assumes it is reachable in some state and will enable it when that state is reached.

Per-state enable matrix (only applies to buttons that are actually rendered):

State Enabled roles
NORMAL create, destroy, back
CHANGED edit, update, updateall, delete, deleteall, cancel
SELECTED edit, update, delete, toggle, cancel
MANY updateall, deleteall
ILLEGAL cancel only
DEACTIVATED none (all disabled)
SUBMITTED none (all disabled while in flight)

Anything not listed is disabled in that state. A cancel button on a freshly loaded form is disabled in NORMAL because there is nothing to cancel — it lights up the moment any field becomes CHANGED. The toggle role is special: it is only enabled in SELECTED state (when exactly one row is selected in a list form).

The matrix above mirrors Button.js exactly. Note that the practical rule still applies: render only the roles a given form actually supports. updateall / deleteall are enabled in CHANGED as well as MANY, but you would only ever render them on a list form, where the dirty states are SELECTED / MANY rather than CHANGED.


Listening to state changes

form.addEventListener('formStateChange', (e) => {
    console.log('new state:', e.detail.state);
});

Browser support

Evergreen browsers + ES2017+. No IE.


Contributing

See CONTRIBUTING.md.

License

Mozilla Public License 2.0

You may use this package in closed-source and commercial products. If you modify and distribute the package's own files, those files must remain under the MPL-2.0.

The HRADigital name and package names are not covered by that licence - see TRADEMARK.md.

  • Forms
  • JavaScript
  • Client-side-validation
  • Es
  • Form-validation
  • Html-forms
  • Makefile
  • No-framework
  • Shell
  • State-management
  • Vanilla-js
  • Zero-dependencies

Visit project