SmarkForm Usage — Agent Knowledge
This document captures key knowledge about how SmarkForm works from a usage perspective (writing HTML/CSS/JS that uses the library). It is intended to help coding agents avoid common mistakes.
Component Types
| Type | Role | Notes |
|---|---|---|
form | Container for named fields | Default type for root and nested containers |
list | Ordered collection of items | Items are cloned from a template |
input | Scalar value input (<input>, <textarea>) | Auto-detected by element tag |
color | Color picker | Wrapper around <input type="color"> with null support |
date | Date field | Wrapper with null support |
time | Time field | Wrapper with null support |
datetime-local | Date+time field | Wrapper with null support |
number | Numeric field | Wrapper with null support |
radio | Radio-button group | Group of <input type="radio"> elements |
trigger | Action button | Required action property |
label | Read-only display | Uses data-smark on element with inner content |
Type is often auto-inferred from the element tag or presence of the action property. The type key in data-smark can override.
How SmarkForm Enhances HTML
SmarkForm reads data-smark attributes on DOM nodes to build a reactive form tree. The root element is passed to the SmarkForm constructor:
const myForm = new SmarkForm(document.getElementById("myForm"));
Only elements with a data-smark attribute are captured and enhanced by SmarkForm. SmarkForm is markup-agnostic — plain HTML elements inside a managed component are ignored unless they also have data-smark.
Exceptions:
- The root element passed directly to the
SmarkFormconstructor does not needdata-smark - A list’s item template implicitly becomes a
formtype; its type can also be overridden via theofproperty in the list’s options
List Component — Critical Rules
Every direct child of a list container is treated as a template node. There is no notion of “regular children” — ALL children are templates and are removed from the DOM during initialization (stored internally). This is done by loadTemplates() in src/types/list.type.js.
Template Roles
Set via data-smark='{"role":"<role>"}':
| Role | Purpose |
|---|---|
item (default) | The repeating item template |
empty_list | Shown when list has 0 items |
header | Shown before items (not cloned) |
footer | Shown after items (not cloned) |
placeholder | DOM filler for fixed-width grids when slots are empty |
separator | Between items |
last_separator | Between second-to-last and last items |
Buttons inside vs. outside a list
Buttons (triggers) can be placed outside their target component using the context property with a path. The playground’s Export/Import/Reset/Clear buttons are a canonical example — they live in the root form but target the demo subform via context:"demo".
<!-- Buttons OUTSIDE the list with explicit context path -->
<div data-smark='{"type":"list","name":"myList"}'>...</div>
<button data-smark='{"action":"addItem","context":"myList"}'>➕</button>
Context paths are resolved lazily at action-trigger time via find(). Relative paths are resolved from the trigger’s enclosing field component (triggers are not field components themselves, so they don’t count as path nodes).
Exception — buttons inside cloned item templates: When a list’s item template itself contains a sub-list, and buttons targeting the sub-list are placed outside that sub-list but inside the item template (so they get cloned), context resolution may fail for the cloned instances. In this case, place the buttons inside the sub-list using
role="footer":
<!-- SAFE: buttons inside the sub-list via role="footer" -->
<div data-smark='{"type":"list","name":"myList","min_items":0,"max_items":3}'>
<!-- item template (default role) -->
<span class="slot">
<input data-smark type="time" name="start"> to <input data-smark type="time" name="end">
</span>
<!-- placeholder fills gap when list has fewer items than max -->
<span data-smark='{"role":"placeholder"}'>❌</span>
<!-- footer holds controls, always visible, not cloned -->
<span data-smark='{"role":"footer"}'>
<button data-smark='{"action":"removeItem","hotkey":"-"}'>➖</button>
<button data-smark='{"action":"addItem","hotkey":"+"}'>➕</button>
</span>
</div>
Only the item template is required. The rest are optional depending on the desired UI/UX and layout.
List Initialization with value Property
If options.value is set on a list, it becomes the list’s defaultValue. On initialization, reset() is called which triggers import(this.defaultValue).
<!-- Starts with 1 empty item; reset() restores this state -->
<div data-smark='{"type":"list","name":"items","min_items":0,"value":[{}]}'>
<span>...</span>
</div>
exportEmpties Behavior
The exportEmpties option is inherited — a child component inherits the value from its nearest ancestor that sets it. This means you may need to explicitly set exportEmpties:false on a nested list even if it matches the default, to override an ancestor’s exportEmpties:true.
When exportEmpties: false (the default):
- List items are checked via
isEmpty()before being included in the exported array - A form item is empty if ALL its field children are empty (null/undefined)
- Empty items are stripped from the output →
[]for a list of all-null items
When exportEmpties: true (must be explicit):
- All items are exported regardless of emptiness
- Useful for “save progress” scenarios where partial data is intentional
When an outer list (exportEmpties:true) contains an inner list (exportEmpties:false):
- Outer list exports all its items (including empty-looking ones)
- Inner list strips its empty items when exported as part of the outer item
Form Component — value and defaultValue
The value property in data-smark options sets the field’s defaultValue. For native HTML elements that support the value attribute (<input>, <textarea>, etc.), you can also set the default via the HTML attribute directly — but not both simultaneously (SmarkForm raises a VALUE_CONFLICT error if both are set).
<div data-smark='{"name":"demo","value":{"name":"Alice"}}'>
<input data-smark type="text" name="name">
</div>
demo.defaultValue = {"name": "Alice"}reset()ondemo→import({"name": "Alice"})→ fills “Alice” into the name field
CSS Layout with SmarkForm Lists
Because SmarkForm removes ALL direct children of a list container during initialization (treating them as templates), CSS tricks that depend on children being in the DOM at load time need to account for the SmarkForm lifecycle.
CSS Grid with Lists
To align list rows in a grid while items stack vertically:
/* Each list is a 3-column grid row: label | slots | controls */
.schedule-row {
display: grid;
grid-template-columns: 10em 1fr auto;
align-items: center;
}
/* The header (label) goes in col 1, row 1 */
.schedule-row > [data-role="header"] {
grid-column: 1;
grid-row: 1;
}
/* Items (slots) stack in col 2 */
.schedule-row > .slot {
grid-column: 2;
}
/* Footer (controls) spans all item rows in col 3 */
.schedule-row > [data-role="footer"] {
grid-column: 3;
grid-row: 1 / -1;
align-self: center;
}
/* Placeholder not needed for layout — hide it */
.schedule-row > [data-role="placeholder"] {
display: none;
}
data-roleis set by SmarkForm on template nodes when they are re-injected into the DOM. The CSS selects ondata-role, notdata-smark, becausedata-smarkis the original attribute whiledata-roleis set by the framework at render time.
Actions
Common actions triggered via data-smark='{"action":"<name>"}':
| Action | Target | Notes |
|---|---|---|
export | form/list | Exports current values as JSON |
import | form/list | Imports JSON into the form |
reset | form/list | Resets to defaultValue |
clear | form/list | Clears to emptyValue (type-level empty) |
addItem | list | Adds a new item |
removeItem | list | Removes target item |
position | list item | Shows item’s 1-based index |
count | list | Shows total item count |
fold / unfold | form/list | Toggles visibility |
Action Context Resolution
When a trigger button is clicked, SmarkForm resolves its effective context (the component that owns the action):
- If
contextis specified (explicit context): The path is resolved starting from where the trigger is placed. Because triggers are not field components (they can’t be addressed by path), the path navigates from the trigger’s enclosing field component — as if the trigger itself were not a node in the path. The resolved component must implement the action — SmarkForm does not walk further up the chain; if it doesn’t implement the action an error is reported. - If
contextis NOT specified (natural context): SmarkForm walks up the ancestor chain automatically to find the first component that implements the action.
Context path examples (resolved relative to the trigger’s enclosing field component):
"demo"— sibling named “demo” in the same parent field"/"— root form".-1"— previous sibling (used forsourcein duplicate)"..fieldname"— named field in grandparent scope
Action Target Resolution
Target paths are not relative to the trigger’s position — they are resolved from the effective context.
When target is specified, the path is evaluated starting from the effective context component. If you want to target a sibling of the context, you must navigate up with .. first:
<!-- Export billing, import into shipping (siblings of the root) -->
<button data-smark='{
"action": "export",
"context": "billing",
"target": "../shipping"
}'>📋 Copy to shipping</button>
context:"billing"— resolved from the trigger’s enclosing field (root form) → the billing subformtarget:"../shipping"— resolved from the effective context (billing) → up to billing’s parent, then “shipping”"target":"shipping"alone → looks for a child of billing named “shipping” — silent failure
Absolute target paths (starting with /) resolve from the root and avoid this ambiguity:
<button data-smark='{"action":"export","context":"billing","target":"/shipping"}'>📋 Copy</button>
Target semantics by action:
exportwith target → exports the context, imports result into target (copy: context → target)importwith target → exports from target, imports result into context (copy: target → context)
<!-- Copy FROM previous sibling INTO current item -->
<button data-smark='{"action":"import","context":".","target":".-1"}'>← Copy from Previous</button>
<!-- Copy current item INTO next sibling -->
<button data-smark='{"action":"export","context":".","target":".+1"}'>Copy to Next →</button>
@action Decorator — Calling Convention and Nuances
This is a common source of bugs. Read carefully before writing internal or programmatic action calls.
Signature convention
Every method decorated with @action follows the signature:
async actionName(data, options = {})
data is the first positional argument. options is the second. Even when data is not used by the method itself, you must pass null (or a value) as the first argument when calling it in code if you want to pass options:
// ✅ CORRECT — data is null, options is the second arg
await me.addItem(null, { silent: true });
await me.removeItem(null, { silent: true });
// ❌ WRONG — options object is silently received as data, options defaults to {}
await me.addItem({ silent: true });
await me.removeItem({ silent: true });
How triggers call actions
When a trigger button is clicked, onTriggerClick calls the action like this:
const options = triggerComponent.getTriggerArgs();
const { context, action, data } = options;
await context.actions[action](data, options);
datacomes from a"data"property on the trigger’sdata-smarkattribute — usuallyundefinedoptionsis the full trigger-args object
The @action wrapper — what it does
The decorator registers a wrapper in this.actions[name] that runs around the raw method:
- Sets
options.focus = trueby default — unlessfocusis already an own property ofoptions. - Sets
options.data = data— soBeforeActionevent handlers can read or modify the incoming data. - Emits
BeforeAction_<name>— unlessoptions.silent. Handlers can callevent.preventDefault()to cancel the action. - Re-reads
datafromoptions.dataafterBeforeAction— allowing event handlers to substitute the data. - Calls the raw method:
targetMtd.call(me, data, options) - Updates
options.datawith the return value. - Emits
AfterAction_<name>— unlessoptions.silent.
// Adding an item silently (no focus, no events)
await me.addItem(null, { silent: true });
// From a beforeAction handler modifying data
component.onLocal("BeforeAction_import", (ev) => {
ev.data = transformData(ev.data);
});
Programmatic calls — prototype vs. actions[name]
component.actions.reset(data, options)— goes through the@actionwrapper: fires events, defaultsfocus, etc.component.reset(data, options)— calls the prototype method directly, bypassing the@actionwrapper entirely.
Most internal calls use the prototype method directly to avoid overhead and event noise.
export_to_target and import_from_target — data pipeline decorators
These decorators are often stacked with @action:
@export_to_target: After the method returns a value, tries to calloptions.target.import(value). Transparent iftargetis absent.@import_from_target: Before calling the method, tries to calloptions.target.export()to replacedata. Transparent iftargetis absent.
import() and setDefault — Default Value Semantics
Since v0.13.0, calling component.import(data) updates the component’s defaultValue by default (i.e., setDefault defaults to true). This means reset() after an import() restores the imported data, not the HTML initialization default.
Behavior Summary
| Call | Updates default? | What reset() restores |
|---|---|---|
import(data) | ✅ Yes | data (normalized via exportEmpties:true) |
import(data, {setDefault:false}) | ❌ No | Previous default |
import(undefined) | ❌ No (always skipped) | Current default unchanged |
clear() | ❌ No | Default unchanged |
reset() | ❌ No | Current default |
How the New Default is Computed
After a successful import(data) with setDefault:true, the new defaultValue is set by:
me.defaultValue = await me.export(null, {silent: true, exportEmpties: true});
Using exportEmpties: true ensures empty list items are included in the stored default so reset() restores the exact same structure (including empty slots).
Breaking Change From Earlier Versions
Code that calls import(data) and expects reset() to restore the original HTML-defined default must now use:
await component.import(data, {setDefault: false});
HTML Trigger Example
<!-- Import without updating defaults (preview mode) -->
<button data-smark='{"action":"import","setDefault":false}'>Preview</button>
<!-- Import with default update (load/apply mode, the new default) -->
<button data-smark='{"action":"import"}'>Load Data</button>
find() Timing — Must Await rendered
The find() method looks up components in an internal map that is built asynchronously during render(). Before rendering is complete, all find() calls return null (or undefined).
const myForm = new SmarkForm(document.getElementById("myForm"));
// ❌ WRONG — render is not done yet, find returns null
const field = myForm.find("/name"); // null!
// ✅ CORRECT — wait for render to complete
await myForm.rendered;
const field = myForm.find("/name"); // returns the component
Inside event handlers (AfterAction_*, afterRender, etc.) this is not an issue because those events only fire after rendering has finished.
List Template Roles — Complete Reference
| Role | Behavior |
|---|---|
item (default) | Repeating item template. Required. Cloned for each list item. |
empty_list | Shown in the list container when there are 0 items. Removed when the first item is added. |
header | Prepended once to the list container. Not cloned. Cannot contain SmarkForm fields; can contain triggers. |
footer | Appended once to the list container. Not cloned. Cannot contain SmarkForm fields; can contain triggers. |
separator | Cloned between each pair of adjacent items. Removed when only one item remains. |
last_separator | Like separator but used only between the second-to-last and last item. Falls back to separator if not defined. |
placeholder | Visual filler shown for each empty slot when max_items is set. One placeholder per unfilled slot. |
Rules for non-item roles
header,footer: Rendered once per list, not duplicated. Can contain action triggers (addItem, etc.) but not SmarkForm data fields.separator,last_separator: Cloned dynamically byrenum()as items are added/removed. Not enhanced by SmarkForm.placeholder: Only rendered whenmax_itemsis finite. Count =max_items - children.length(minus 1 ifempty_listis also shown and the list is empty).empty_list: Managed byrenum(). Added whenchildren.length === 0, removed otherwise.
Set the role via data-smark='{"role":"<role>"}'. The item role is the default and does not need to be specified explicitly.