Skip to content

Plugins

FormLayer supports two extension mechanisms: custom field types (via fieldsMap) for replacing how individual fields render and behave, and form plugins for cross-cutting logic that operates on the form as a whole.

Custom field types replace the default FieldController for specific data-field-type values. Each custom type implements the FormField interface.

For DOM-backed widgets (hidden native control + custom UI), extend AbstractDomFormField from formlayer — it provides validation, error rendering, server errors, enable/disable, and field events. Built-in plugins (combobox, datepicker) use this base class. See Creating Custom Fields for a step-by-step image input with preview tutorial.

Pass a fieldsMap to FormControllerOptions:

import { initTypo3Forms } from 'formlayer/typo3';
import { ComboboxField } from 'formlayer-plugin-combobox';
import { DatepickerField } from 'formlayer-plugin-datepicker';
initTypo3Forms({
fieldsMap: {
combobox: ComboboxField,
datepicker: DatepickerField,
},
});

When a [data-form-field] wrapper has data-field-type="combobox", FormLayer instantiates ComboboxField instead of the default FieldController.

Use a factory function for code-split custom fields that load on first use:

initTypo3Forms({
fieldsMap: {
combobox: () => import('formlayer-plugin-combobox'),
datepicker: () => import('formlayer-plugin-datepicker'),
},
});

The factory must return { default: FormFieldClass }, matching the standard dynamic import() convention.

Requires formlayer-plugin-combobox:

Terminal window
npm install formlayer-plugin-combobox

Replaces a <select> with an accessible, searchable combobox (ARIA 1.2 pattern). The original <select> stays hidden and in sync for form submission. Implemented with AbstractDomFormField — the hidden <select> holds the canonical value while the text input is the active control.

<div data-form-field="country" data-field-type="combobox"
data-validate='[{"type":"NotEmpty","options":{"message":"Please select a country"}}]'>
<label for="country">Country</label>
<select id="country" name="country">
<option value="">Select...</option>
<option value="de">Germany</option>
<option value="at">Austria</option>
<option value="ch">Switzerland</option>
<option value="fr">France</option>
<option value="nl">Netherlands</option>
</select>
<div id="country-errors" class="invalid-feedback"></div>
</div>

The combobox supports:

  • Type-ahead filtering
  • Keyboard navigation (arrow keys, Enter, Escape, Home, End)
  • ARIA labels and live regions
  • Blur commit (selects closest match or reverts)

Requires formlayer-plugin-datepicker:

Terminal window
npm install formlayer-plugin-datepicker

Wraps an input with the Air Datepicker library. Reads the date format from a hidden input (TYPO3 convention).

<div data-form-field="startDate" data-field-type="datepicker"
data-validate='[{"type":"DateTime","options":{"message":"Enter a valid date"}}]'>
<label for="startDate">Start Date</label>
<div data-field-type="datepicker">
<input id="startDate" type="text" name="startDate" />
<input type="hidden" name="startDate[dateFormat]" value="d.m.Y" />
</div>
<div id="startDate-errors" class="invalid-feedback"></div>
</div>

Form plugins operate on the entire form. They receive the FormPluginHost API for reading field values, toggling field visibility, and subscribing to events.

Requires formlayer-plugin-client-variants:

Terminal window
npm install formlayer-plugin-client-variants
import { registerClientVariantsPlugin } from 'formlayer-plugin-client-variants';
registerClientVariantsPlugin();

The ClientVariantsPlugin enables/disables fields based on conditions evaluated against other field values — replicating TYPO3’s server-side variant logic on the client.

<form id="registration">
<div data-form-field="accountType" data-validate='[{"type":"NotEmpty"}]'>
<label for="accountType">Account Type</label>
<select id="accountType" name="accountType">
<option value="">Select...</option>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
</div>
<div data-form-field="companyName"
data-client-variants='[{"condition": "formValue(\"accountType\") === \"business\"", "enabled": true}]'
data-validate='[{"type":"NotEmpty","options":{"message":"Company name is required"}}]'>
<label for="companyName">Company Name</label>
<input id="companyName" name="companyName" />
<div id="companyName-errors" class="invalid-feedback"></div>
</div>
<div data-form-field="taxId"
data-client-variants='[{"condition": "formValue(\"accountType\") === \"business\"", "enabled": true}]'
data-validate='[{"type":"NotEmpty","options":{"message":"Tax ID is required for business accounts"}}]'>
<label for="taxId">Tax ID</label>
<input id="taxId" name="taxId" />
<div id="taxId-errors" class="invalid-feedback"></div>
</div>
<button type="submit">Register</button>
</form>

When accountType is not "business", the companyName and taxId fields are hidden, disabled, and excluded from validation. A hidden input __clientVariantsDisabled lists the disabled field names for the backend.

Expression syntax supports formValue("fieldName"), comparisons (===, !==, >, <, >=, <=), logical operators (&&, ||, !), in operator, and string/number/boolean literals.

Recommended: extend AbstractDomFormField and implement three hooks — mount(), readValue(), and writeValue():

import { AbstractDomFormField } from 'formlayer';
export default class ToggleField extends AbstractDomFormField {
private hiddenInput!: HTMLInputElement;
private toggle!: HTMLButtonElement;
protected mount(): void {
this.hiddenInput = this.wrapper.querySelector('input')!;
this.toggle = document.createElement('button');
this.toggle.type = 'button';
this.toggle.className = 'toggle-btn';
this.toggle.textContent = this.readValue() === 'on' ? 'ON' : 'OFF';
this.toggle.addEventListener('click', () => {
const next = this.readValue() === 'on' ? 'off' : 'on';
this.setValue(next);
this.toggle.textContent = next === 'on' ? 'ON' : 'OFF';
}, { signal: this.signal });
this.hiddenInput.hidden = true;
this.wrapper.appendChild(this.toggle);
this.setControlElement(this.hiddenInput);
}
protected readValue(): string {
return this.hiddenInput.value;
}
protected writeValue(value: string): void {
this.hiddenInput.value = value;
this.toggle.textContent = value === 'on' ? 'ON' : 'OFF';
}
protected onReset(): void {
this.hiddenInput.value = this.hiddenInput.defaultValue;
this.toggle.textContent = this.readValue() === 'on' ? 'ON' : 'OFF';
}
protected onDestroy(): void {
this.toggle.remove();
this.hiddenInput.hidden = false;
}
protected focusControl(): void {
this.toggle.focus();
}
}

Register and use it:

import { initTypo3Forms } from 'formlayer/typo3';
import ToggleField from './fields/toggle';
initTypo3Forms({
fieldsMap: {
toggle: ToggleField,
},
});
<div data-form-field="notifications" data-field-type="toggle">
<label>Notifications</label>
<input type="hidden" name="notifications" value="off" />
</div>

For a longer tutorial (preview UI, lifecycle diagrams, event wiring, checklist), see Creating Custom Fields. Implement FormField directly only when your field has no DOM wrapper or no single control element.

Form plugins implement the FormPlugin interface and receive FormPluginHost:

import type { FormPlugin, FormPluginHost } from 'formlayer';
export default class FormAnalyticsPlugin implements FormPlugin {
private host!: FormPluginHost;
async init(formEl: HTMLFormElement, host: FormPluginHost): Promise<void> {
this.host = host;
host.on('form:submit', (detail) => {
analytics.track('form_submit', { formId: detail.formId });
});
host.on('form:invalid', (detail) => {
const invalidFields = Object.entries(detail.state.fields)
.filter(([, f]) => !f.isValid)
.map(([name]) => name);
analytics.track('form_validation_error', { fields: invalidFields });
});
}
destroy(): void {}
}

Register form plugins via the registry:

formRegistry.registerFormPlugin(() => import('./plugins/analytics'));