Skip to content

AbstractDomFormField

AbstractDomFormField is the recommended base class for custom field types that replace or augment native controls. It implements the full DomFormField interface — the same contract as FieldController, plus hooks for custom DOM and value synchronization.

Built-in plugins such as combobox and datepicker extend this class. For a step-by-step walkthrough, see Creating Custom Fields.

ScenarioUse
Standard input, select, or textarea with no custom UIFieldController (default)
Custom widget that keeps a native control for submissionAbstractDomFormField
Fully custom UI with no native controlImplement FormField directly

AbstractDomFormField handles state, validation, server errors, enable/disable, error rendering, and field events. You implement DOM construction and value read/write.

new AbstractDomFormField(wrapper: HTMLElement, options?: AbstractDomFieldOptions)

Do not instantiate directly. Extend the class, override the hooks, and create instances through createField(), initField(), or a registered fieldsMap entry — those drive the lifecycle for you.

export default class MyField extends AbstractDomFormField {
private control!: HTMLInputElement;
protected mount(): void {
this.control = this.wrapper.querySelector('input')!;
this.setControlElement(this.control);
}
}

The constructor only reads data-form-field and parses data-validate. Setup that touches the DOM — mount(), error presentation, and the initial state snapshot — runs in init(), which the field factories call immediately after construction. This keeps the overridable mount() out of the constructor, so ordinary class fields like private control!: HTMLInputElement work without any special handling. If mount() does not call setControlElement(), init() throws.

These methods define how your field interacts with the DOM. The base class calls them at the appropriate lifecycle points. Assign fields in mount() as you normally would — it runs after the instance is fully constructed.

Build custom UI and register the focus/validation control:

protected mount(): void {
this.fileInput = this.wrapper.querySelector('input[type="file"]')!;
this.buildPreview();
this.bindEvents();
this.setControlElement(this.fileInput);
}

Call setControlElement() exactly once with the element that receives focus, aria-* error classes, and the disabled state from setEnabled().

Return the canonical string value used for validation and form state. This may differ from what the user sees — for example, a combobox reads from a hidden <select> while displaying option labels in a text input.

writeValue(value: string): void (required)

Section titled “writeValue(value: string): void (required)”

Apply a programmatic value (via setValue() or form reset). Not all controls support this (file inputs cannot be set for security reasons).

Focus the interactive control. Default: this.input.focus(). Override when focus should land on a different element than inputElement (e.g. a combobox text input).

Restore custom DOM to initial values when reset() is called. The base class clears validation state afterward; you restore control values and UI.

Tear down custom DOM and third-party instances when destroy() is called. The base class aborts listeners via AbortSignal before this runs.

HelperPurpose
setControlElement(el)Register the active input for focus, validation UI, and setEnabled()
signalAbortSignal for { signal } on event listeners; aborted in destroy()
markDirty()Set isDirty on the internal state
markTouched()Set isTouched on the internal state
notifyChange()Re-read value, emit change / valid / invalid, notify the form via connect()
updateErrorDOM(isValid, errors)Update error classes and messages (usually called by validate())
parseRules()Parse data-validate JSON into ValidatorRule[] (also runs in constructor)

Custom fields must call validation and notification explicitly — unlike FieldController, the base class does not bind input / blur / change automatically:

this.control.addEventListener('blur', () => {
this.markTouched();
this.validate();
this.notifyChange();
}, { signal: this.signal });
this.control.addEventListener('input', () => {
this.markDirty();
}, { signal: this.signal });

Call setValue() when the user commits a new value; it writes the DOM, validates, and notifies in one step.

Inherits the same surface as FieldController:

Property / methodDescription
namedata-form-field attribute value
elementThe wrapper element
inputElementThe control set via setControlElement()
fieldTypedata-field-type attribute, or null
enabledWhether the field participates in validation
getState()Snapshot of FieldState
setValue(value)Write value, mark dirty/touched, validate, notify
validate()Run validators and update error DOM
setServerErrors(errors)Apply server-side errors (cleared when value changes)
setEnabled(enabled)Show/hide wrapper, toggle disabled, skip validation when off
reset()Calls onReset(), clears state and errors
destroy()Aborts listeners, calls onDestroy(), tears down emitter
connect(onChange)Wire into parent form (internal; prefer on('change') standalone)
focus()Calls focusControl()
replaceInput(newInput)Swap the active control element
on / once / offField-level change, valid, invalid events

Same as FieldOptions / FieldControllerOptions:

type AbstractDomFieldOptions = FieldOptions;

Supports custom validation, server error transformation, and error rendering hooks. See Error Rendering.

  • No automatic event binding — you wire listeners in mount() using this.signal.
  • No native constraint stripping — remove required, pattern, etc. yourself if needed.
  • No debounced input validation — call validate() when appropriate (typically on blur or commit).
  • No checkbox/radio/file group handling — implement in readValue() / writeValue() if needed.
  • File validatorsFieldController passes __files to validators for file inputs; override options.validate or call runValidators with extra context if you need file-aware rules.

Export your class as default and register via fieldsMap:

import { initTypo3Forms } from 'formlayer/typo3';
import ImageInputWithPreviewField from './fields/image-input-with-preview';
initTypo3Forms({
fieldsMap: {
'image-input-with-preview': ImageInputWithPreviewField,
},
});

Or lazy-load:

fieldsMap: {
'image-input-with-preview': () => import('./fields/image-input-with-preview'),
}

The factory must resolve to { default: FormFieldClass }.