Form Fields
This documentation covers files located at: Component-Library
This README explains our form-fields system: the base framework for all current and future fields, and how to add new ones.
Goals
- Provide a unified foundation for all input-like components (fields).
- Ensure consistent structure/UX across fields: label, description, error output, disabled/invalid state, and layout variants.
- Centralize cross-cutting integration (NgControl hookup, status/error propagation) in a single base.
- Make extension simple: new fields implement only what’s unique; the base supplies the rest.
Folder Overview
- field-base/field-base.component.ts — Shared inputs, state, and Angular Forms (NgControl) integration.
- field/field.variants.ts — Layout variants (vertical/horizontal/responsive) as class recipes.
- field-label/* — Thin label component around a native
<label>with curated classes. - field-description/* — Attribute directive for helper/description content.
- field-error/* — Lightweight container to consistently render errors (mapped from Angular ValidationErrors).
- +types/* — Type helpers defining the input surface of concrete fields.
- form-field.type.ts — Base inputs shared by all fields (e.g., testId, label, required, errorMessages).
- text-form-field.type.ts — Inputs for the text field.
- text-form-field/* — Reference implementation of a simple text field.
Core Concepts
FieldBase (base component)
All concrete fields extend the base. The base provides the shared API and integrates with Angular Reactive Forms:
-
Shared inputs (available on every field):
-
testId: string — stable identifier for QA/testing/accessibility.
-
label?: string — optional label text.
-
required?: boolean — visual/semantic hint; concrete fields decide how to display it.
-
errorMessages?: FormFieldErrorMessagesMap — maps error keys (e.g., 'required', 'minlength') to message/i18n keys.
-
State/signals provided by the base:
-
controlStatus:
Signal<FormControlStatus | null>— current form control status (VALID/INVALID/PENDING/DISABLED). -
controlErrors:
Signal<ValidationErrors | null>— Angular error object from the control. -
_invalid:
computed<boolean>— true when controlErrors exist. -
_disabled:
signal<boolean>— set by the concrete field via setDisabledState. -
_touched:
signal<boolean>— set by the concrete field (e.g., via onTouch). -
Host state exposure (for styling):
-
[attr.data-disabled] and [attr.data-invalid] are set automatically based on the base state. data-invalid is only true when invalid AND touched.
-
Angular Reactive Forms integration:
-
The base gets NgControl via the Injector (when present) and registers itself as valueAccessor.
-
It subscribes to statusChanges and mirrors status/errors into controlStatus/controlErrors.
-
Stubs/hooks used by concrete fields:
-
writeValue(value: any): write external value into your internal control.
-
setDisabledState(disabled: boolean): enable/disable your internal control and update _disabled.
-
registerOnChange(fn), registerOnTouched(fn): receive callbacks from Angular and forward them appropriately.
-
onTouch(): call from the concrete field to set _touched and trigger touchFn().
Note: Validation logic (required, minlength, async, etc.) lives entirely on the consumer’s FormControl via Angular validators. The base only reflects status/errors.
Error handling (using standard Angular validators)
- Error source: Angular Reactive Forms (Validators / AsyncValidators) configured on the consumer’s FormControl.
- The base reads errors via NgControl (control.errors) and passes them to
mona-field-error. mona-field-errormaps error keys through errorMessages (FormFieldErrorMessagesMap) to the messages to display.- Async validators are represented by PENDING status; errors appear once available.
Example error mapping (e.g., in Storybook/consumer):
const errorMessages = {
required: 'ERROR.FIELD.REQUIRED',
minlength: 'ERROR.FIELD.MINLENGTH',
async: 'ERROR.FIELD.ASYNC',
} as const;
Only keys that exist both in control.errors and in the mapping are shown. The order is determined by Angular’s iteration over the error object keys.
Structure and slots
All fields follow the same structure:
- Optional label via
mona-field-labelwhen a label is provided. - A content area with the actual input control (e.g., input, select, etc.).
- Optional description via [monaFieldDescription] (projection/directive).
- Error output via
mona-field-errorusing controlErrors/controlStatus and errorMessages.
Layout variants (optional)
- field/field.variants.ts provides orientation presets (Vertical/Horizontal/Responsive).
- Use them when it makes sense; otherwise supply your own host classes.
- Variants respect host states (data-disabled, data-invalid).
Add a new field — step by step (with rationale)
We’ll implement a Number Form Field as an example. Each step is tiny and explains what’s required vs. optional and why.
Background: The field implements ControlValueAccessor and is driven by Angular Reactive Forms.
- Define the types (+types)
- File: projects/component-library/src/lib/components/form-fields/+types/number-form-field.type.ts
- Required: Define a dedicated type for the field's public inputs. This is not optional.
- Note: If you plan to build dynamic forms from configuration objects, these types allow your configurations to be strongly typed, safer to refactor, and easier to validate at compile time.
import { FormFieldBase } from './form-field.type';
export type NumberFormField = FormFieldBase & {
placeholder?: string;
min?: number;
max?: number;
// Optional: increment size used by the custom + / - stepper buttons (defaults to 1 if omitted)
stepSize?: number;
orientation?: FieldOrientation;
};
Why: This keeps the component’s public inputs aligned with other fields and documents the intended API. It also enables well-typed dynamic form configurations when generating forms from schema/config.
- Create the component (TS)
- File: number-form-field/number-form-field.component.ts
- Required: extend FieldBaseComponent, provide ControlValueAccessor, and use an internal FormControl.
- Optional: orientation/class inputs for layout variants.
import { Component, computed, forwardRef, input } from '@angular/core';
import { ReactiveFormsModule, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { FieldBaseComponent } from '../field-base/field-base.component';
import { FieldLabelComponent } from '../field-label/field-label.component';
import { FieldErrorComponent } from '../field-error/field-error.component';
import { fieldVariants, FieldOrientation } from '../field/field.variants';
import { mergeClasses } from '../../../+utils/helpers/merge-classes.helper';
import { NumberFormField } from '../+types/number-form-field.type';
import { SignalInputsFrom } from '../../../+types/signal-inputs-from.type';
@Component({
selector: 'mona-number-form-field',
standalone: true,
imports: [ReactiveFormsModule, FieldLabelComponent, FieldErrorComponent],
templateUrl: './number-form-field.component.html',
styleUrls: ['./number-form-field.component.css'],
host: { '[class]': 'classes()' },
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NumberFormFieldComponent),
multi: true,
}],
})
export class NumberFormFieldComponent extends FieldBaseComponent implements SignalInputsFrom<NumberFormField> {
inputControl = new FormControl<number | null>(null);
// Layout (optional)
class = input<string | undefined>();
orientation = input<FieldOrientation | undefined>();
// Specific inputs
placeholder = input<string | undefined>();
min = input<number | undefined>();
max = input<number | undefined>();
// Optional: step size for the custom +/- buttons
stepSize = input<number | undefined>();
classes = computed(() => mergeClasses(
fieldVariants({ orientation: this.orientation() }),
this.class(),
));
constructor() {
super();
this.inputControl.valueChanges.subscribe(v => this.changeFn(v));
}
override writeValue(value: number | null): void {
this.inputControl.patchValue(value, { emitEvent: false });
}
override setDisabledState(disabled: boolean): void {
disabled ? this.inputControl.disable({ emitEvent: false }) : this.inputControl.enable({ emitEvent: false });
this._disabled.set(disabled);
}
onTouch() {
this._touched.set(true);
this.touchFn();
}
increment() {
// custom implementation
}
decrement() {
// custom implementation
}
}
Why: Extending the base gives you NgControl integration, status/error signals, and host state handling. The internal FormControl forwards value changes to Angular via changeFn; writeValue/setDisabledState keep your internal state in sync with the parent form.
- Build the template (HTML)
- File: number-form-field/number-form-field.component.html
- Required: label (conditionally rendered), the input control, and the field-error.
- Optional: the [monaFieldDescription] slot for helper text.
<mona-field-label *ngIf="label()" [for]="testId()">
{{ label() }}
<span *ngIf="required()" aria-hidden="true">*</span>
</mona-field-label>
<div class="flex items-stretch gap-2">
<button
type="button"
class="inline-flex items-center justify-center select-none px-2 border rounded"
[attr.aria-label]="'Decrement'"
(click)="decrement()"
>
−
</button>
<input
monaTextInput
[id]="testId()"
type="number"
[attr.placeholder]="placeholder() ?? null"
[attr.min]="min() ?? null"
[attr.max]="max() ?? null"
[formControl]="inputControl"
(blur)="onTouch()"
/>
<button
type="button"
class="inline-flex items-center justify-center select-none px-2 border rounded"
[attr.aria-label]="'Increment'"
(click)="increment()"
>
+
</button>
</div>
<ng-content select="[monaFieldDescription]"></ng-content>
<mona-field-error
[controlErrors]="controlErrors()"
[controlStatus]="controlStatus()"
[errorMessages]="errorMessages()"
></mona-field-error>
Why each piece?
-
mona-field-label: Ensures a consistent label style and spacing. [for] ties the label to the input via id for accessibility. The asterisk is a visual hint only; actual “required” validation must come from Angular validators.
-
input with monaTextInput plus custom stepper buttons: The directive provides consistent input styles, focus rings, and invalid/disabled visuals. The [id] binds to testId so the label’s [for] works. [formControl] connects the control to the internal FormControl, enabling value/disabled sync with ControlValueAccessor. The − and + buttons live alongside the input to adjust the numeric value via component methods (decrement()/increment()). Note: Depending on requirements, this does not have to be an
<input>; it can be a<select>,<textarea>, or a custom control. We do not rely on the nativestepattribute at all; increments are handled in component logic so they can be validated and surfaced via Angular validators andmona-field-error.- (blur)="onTouch()": Marks the field as touched so data-invalid only appears after user interaction, avoiding premature error display.
- [monaFieldDescription] slot: Lets consumers project helper text below the control with consistent styling.
- mona-field-error: Receives errors/status from the base and maps them via errorMessages to human-readable/i18n keys.
Custom stepper buttons (number inputs)
- Instead of using the native
stepattribute, this field exposes two buttons (− and +) next to the input. These call component methods to decrease/increase the value. - The increment size is configurable via the optional
stepSizeinput (default is 1). Values are clamped tomin/maxif provided. - Validation remains in Angular forms: use Validators.min/Validators.max or any custom validators you need. Errors are surfaced through
mona-field-errorfromcontrol.errors.
- Styles (optional)
- File: number-form-field/number-form-field.component.css
- Usually not required because monaTextInput and the layout variants provide the common styles. Add only field-specific tweaks.
- Export in the public API (required for external usage)
- File: projects/component-library/src/public-api.ts
- Example:
export * from './lib/components/form-fields/number-form-field/number-form-field.component';
- Use with Reactive Forms (consumer example)
- Required: configure validators on the FormControl (Angular standard!).
- Optional: add an async validator.
import { FormControl, FormGroup, Validators } from '@angular/forms';
form = new FormGroup({
amount: new FormControl<number | null>(null, [
Validators.required,
Validators.min(1),
Validators.max(1000),
]),
});
messages = {
required: 'ERROR.FIELD.REQUIRED',
min: 'ERROR.FIELD.MIN',
max: 'ERROR.FIELD.MAX',
};
<form [formGroup]="form">
<mona-number-form-field
formControlName="amount"
[label]="'Amount'"
[required]="true"
[errorMessages]="messages"
[placeholder]="'0'"
[min]="1"
[max]="1000"
>
<span monaFieldDescription>Please enter a value between 1 and 1000.</span>
</mona-number-form-field>
</form>
- Tests/checks (recommended)
- Verify data-disabled and data-invalid host attributes (data-invalid only when invalid AND touched).
- Verify errors are shown when validators fail and that errorMessages mapping works as expected.
- Verify async validators (e.g., PENDING status leading to later error display).
Reference: Text Form Field
The Text Form Field demonstrates the integration with Angular Reactive Forms and the base:
- Inputs: type, placeholder, minLength, maxLength, label, required, errorMessages, orientation, class.
- Internal control:
FormControl<string>with valueChanges → changeFn. - Base wiring: writeValue, setDisabledState, onTouch.
- Error output:
mona-field-errorwith controlErrors/controlStatus.
See files in text-form-field/* and the Storybook story at projects/storybook/stories/component-library/form-fields/text-form-field.stories.ts for a complete example, including an async validator.