Overview & Philosophy
ln-form is applied to standard HTML forms via <form data-ln-form action="..." method="...">. It listens for ln-fill events carrying record payloads to populate matching controls ([name="key"]) and dispatches synthetic input and change events so reactive components (like ln-validate and ln-autoresize) update in sync. In edit workflows, it automatically ensures an <input type="hidden" name="_method" value="PUT"> exists for RESTful method spoofing, without intercepting native form submission.
In ln-ashlar, forms are built on a strict, platform-first **Coordinator & Primitive** doctrine: upgrading native HTML forms instead of inventing a custom JavaScript state engine.
The Decoupled Form Doctrine
The form layer has zero direct references to layout widths or validation logic. JavaScript only manages the high-level gate (submit, fill, reset), while individual fields own their state via the platform's ValidityState API. The DOM is the single source of truth.
Three Orthogonal Layers of Form UX
-
The Layout Engine (CSS Grid Mixins): Responsive multi-column layouts are handled purely in SCSS using the
@include form-gridmixin. Column spans are defined inside form-specific stylesheets via semanticnth-childselectors. The HTML contains no layout classes (like.col-md-6or.form-row), keeping markup clean and flexible. -
The Validity Primitive (
ln-validate): Each input, select, or textarea is a standalone validation primitive. It wraps the browser's nativeValidityState, controls when to surface errors visually (only after the field is touched), and manages custom/server-side errors via theln-validate:set-customevent channel. -
The Form Coordinator (
ln-form): This is the coordinator. It sits on the root<form>element and manages fill/reset and, for local-first write pages, the submit validation gate (blocks invalidPOST/PUT/PATCHsubmits on anydata-ln-formform;novalidateis injected automatically byln-validatewherever a field needs it). Field-level validation state isln-validate's job; a valid submit is claimed byln-data-coordinator, not dispatched byln-form.
Data and Validation Flow
When a user interacts with a form:
- The user types in a field. The field's
ln-validateprimitive catches theinputorchangeevent, marks the field as touched, evaluates validity usingdom.checkValidity(), updates the input's visual classes (.ln-validate-valid/.ln-validate-invalid), toggles the matching error in the error list, and dispatches a bubbledln-validate:validorln-validate:invalidCustomEvent. - The event bubbles up to the root
[data-ln-form]. The coordinator intercepts it, scans the active fields, and re-calculates the submit button'sdisabledstate. - Without
data-ln-data-coordinator-scope, submit is left entirely native — the browser submits toaction/methodas usual, or a separate ajax component may intercept the nativesubmitevent. Withdata-ln-data-coordinator-scope, a valid submit is still left native — the matchingln-data-coordinatorclaims it viapreventDefault()(document, bubble phase) and serializes the form itself.
Why This Decoupled Approach is Superior
- Ultra-Lightweight & Responsive: Because validation is delegated to the browser's native engine, there is no sluggish JS-based validation loop running on every keystroke. Typing remains instantaneous.
- Seamless Server Integration: Server-side validation errors are easily mapped back to fields using standard
ln-validate:set-customevents. The coordinator and form are agnostic to where errors originate. - Unified Execution on Fill/Reset: Programmatic bulk population (via
lnForm.fill()) fires synthetic events, ensuring textareas resize instantly, fields re-validate, and the submit button enables itself.
Anatomy & Rules
Each field is a <div class="form-element"> containing a <label for="..."> and <input id="..."> (explicit association). The root <form> gets @include form-grid in SCSS — a 6-column grid, collapsing to 1 column on mobile. Column widths are set via nth-child in form-specific SCSS.
| Element | Rule / Attribute | Description |
|---|---|---|
| Root Form | <form data-ln-form> |
Creates the form coordinator instance. Styled with @include form-grid. |
| Field Wrapper | <div class="form-element"> |
Wraps the label, input control, and error list. Controls alignment and column spans. |
| Label | <label for="my-id"> |
Explicit association with input's ID for premium accessibility (clicks focus input). |
| Input / Control | <input id="my-id" data-ln-validate> |
The native control. Rules like required or type="email" reside natively here. |
| Error List | <ul data-ln-validate-errors> |
Container for <li class="hidden" data-ln-validate-error="rule"> error messages. |
| Button Group | <ul class="form-actions"> |
Handles form action button wrapping. Cancel is type="button"; Save is type="submit". |
Live demo — ln-validate + ln-form
Event log
Fill — edit mode
Populate form fields programmatically or declaratively. Three patterns:
Code
// Pattern 1: Direct API — caller has a reference to the form
form.lnForm.fill({ fname: 'Dalibor', email: 'd@test.com' });
// Pattern 2: Coordinator — routes external events to the form
// Table doesn't know about the form. Form doesn't know about the table.
// The coordinator is the glue.
table.addEventListener('ln-table:row-click', function (e) {
form.lnForm.fill(e.detail.record);
});
// Pattern 3: Declarative (HTML only) — using data-ln-fill-form and data-ln-fill-*
// Trigger:
<button type="button"
data-ln-fill-form="my-form"
data-ln-fill-fname="Dalibor"
data-ln-fill-email="d@test.com">
Fill Form
</button>
// Reset
form.lnForm.reset();
Filled Pills (Joined Group)
Joined horizontal segmented control using ul.pills. Corner radiuses are automatically adjusted for a cohesive, joined horizontal block.
Radio selection
Checkbox selection
Outline Pills
Outline variant with transparent background, visible input indicator, and border highlighting. Can be grouped horizontally or used as a standalone label choice.
Grouped Outline (ul.pills-outline)
Standalone Outline (label.pill-outline)
Switch Pills (iOS Toggle Switches)
iOS-style sliding switches for immediate on/off preferences. Styled purely via CSS classes on the ul or standalone label.
Settings Stack (ul.pills-switch)
Disabled State
Standalone Switch (label.pill-switch)
Input States
- Invalid email address
Select & Textarea
Checkboxes & Radios
Buttons — Architecture
Three tiers. No class needed for the first two — the browser type attribute determines the role.
1. Neutral — <button type="button">
All buttons get structure (padding, font, flex, hover) from _global.scss. Cancel/Close/Toggle buttons need no class.
2. Primary — type="submit"
Submit buttons automatically receive primary fill color. Structure inherited from tier 1. No class needed.
3. Action button — class="btn"
Non-submit action buttons (Add, Delete, Export). In production, apply via @include btn on a semantic selector in project SCSS. class="btn" is for prototyping only.
Color change — override --color-primary
Set --color-primary on the button or any parent. All states (hover, active, focus-ring) adapt automatically.
/* Project SCSS — semantic selector */
#delete-user { @include btn; --color-primary: var(--color-error); }
#approve-user { @include btn; --color-primary: var(--color-success); }
/* Context override — all submit + action buttons inside change */
.danger-zone { --color-primary: var(--color-error); }
Button Sizes
Size modifiers: class="btn btn-sm" and class="btn btn-lg". In projects, apply via @include btn-sm / @include btn-lg after @include btn.
Icon-only Buttons
Icon-only buttons use no class — they get neutral global styling (transparent, gray hover). No padding override needed.
Button Groups — btn-group
Row action buttons grouped with small gap. In projects: #users-table .row-actions { @include btn-group; }
Implementation
HTML
<form id="my-form" data-ln-form>
<div class="form-element">
<label for="name">Name</label>
<input type="text" id="name" name="name" required data-ln-validate>
<ul data-ln-validate-errors>
<li class="hidden" data-ln-validate-error="required">Required field</li>
</ul>
</p>
<div class="form-element">
<label for="email">Email</label>
<input type="email" id="email" name="email" required data-ln-validate>
<ul data-ln-validate-errors>
<li class="hidden" data-ln-validate-error="required">Required</li>
<li class="hidden" data-ln-validate-error="typeMismatch">Invalid email</li>
</ul>
</p>
<div class="form-element">
<label for="category">Category</label>
<select id="category" name="category">
<option value="a">Option A</option>
</select>
</p>
<ul class="form-actions">
<li><button type="button">Cancel</button></li>
<li><button type="submit">Save</button></li>
</ul>
</form>
SCSS
#my-form {
@include form-grid;
.form-element { grid-column: span 3; } // default: half
.form-element:nth-child(3) { grid-column: span 6; } // category: full
> fieldset { grid-column: span 6; }
.form-actions { grid-column: span 6; }
}
Coordinator (local-first write pages)
<form id="my-form" data-ln-form data-ln-data-coordinator-scope="my-resource"
method="post" action="/api/resource" data-ln-form-action-edit>
// The nearest (or named) [data-ln-data-coordinator] claims
// the native submit automatically (preventDefault) — no application JS
// needed to build request-create/request-update events by hand. React to
// the store outcome instead of a form-level event:
document.addEventListener('ln-data-store:created', function (e) {
if (e.detail.store !== 'my-resource') return;
document.getElementById('my-modal').setAttribute('data-ln-modal', 'close');
});
focus-ring — outer glow (default)
Safe, universally readable. No layout shift. Tailwind / Vercel convention.
focus-border-thicken — structural outline
Sharp, structural. No glow. Enterprise / business UI. Material Design style.
focus-combination — border + ring
Border color + outer ring. Maximum signal strength. Accessibility-first.
focus-background-shift — minimal tint
Very light primary tint. No border change. Focus is felt, not announced.
focus-accent-line — bottom border only
Bottom border only changes. Subtle. Good inside bordered containers.
focus-inset-shadow — tactile sink
Inner shadow — field visually sinks. Tactile, three-dimensional feel.
RESTful action routing — create vs. edit
Add data-ln-form-action-edit to opt in. On fill with a record that has an id, ln-form rewrites form.action to the per-record URL and injects a hidden <input name="_method">. On new (no id), it restores the base action and clears _method. No coordinator JS required.