I am trying to make a custom MatFormFieldControl, with the version 7 of Angular Material and Angular 6. The custom input is a weight input, which has a value (input type="number") and a unit (select "kg","g",...). It has to be placed inside a mat-form-field-control, work with reactive forms (formControlName="weight") and support error states (<mat-error *ngIf="weightControl.hasError('required')">error<...>
), even with custom validators.
I wrote this implementation:
weight-input.component.html
<div [formGroup]="weightForm">
<input fxFlex formControlName="value" type="number" placeholder="Valore" min="0" #value>
<select formControlName="unit" [style.color]="getUnselectedColor()" (change)="setUnselected(unit)" #unit>
<option value="" selected> Unità </option>
<option *ngFor="let unit of units" style="color: black;">{{ unit }}</option>
</select>
</div>
weight-input.component.css
.container {
display: flex;
}
input, select {
border: none;
background: none;
padding: 0;
opacity: 0;
outline: none;
font: inherit;
transition: 200ms opacity ease-in-out;
}
:host.weight-floating input {
opacity: 1;
}
:host.weight-floating select {
opacity: 1;
}
weight-input.component.ts
import { Component, OnInit, Input, OnDestroy, HostBinding, ElementRef, forwardRef, Optional, Self } from '@angular/core';
import { FormGroup, FormBuilder, ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material';
import { Subject } from 'rxjs';
import { FocusMonitor } from '@angular/cdk/a11y';
export class Weight {
constructor(public value: number, public unit: string) { };
}
@Component({
selector: 'weight-input',
templateUrl: './weight-input.component.html',
styleUrls: ['./weight-input.component.css'],
providers: [
{ provide: MatFormFieldControl, useExisting: WeightInput }
],
})
export class WeightInput implements OnInit, OnDestroy, MatFormFieldControl<Weight>, ControlValueAccessor {
stateChanges = new Subject<void>();
@Input()
get units(): string[] {
return this._units;
}
set units(value: string[]) {
this._units = value;
this.stateChanges.next();
}
private _units: string[];
unselected = true;
weightForm: FormGroup;
@Input()
get value(): Weight | null {
const value: Weight = this.weightForm.value;
return ((value.value || value.value == 0) && !!value.unit) ? value : null;
}
set value(value: Weight | null) {
value = value || new Weight(null, '');
this.weightForm.setValue({ value: value.value, unit: value.unit });
if(this._onChange) this._onChange(value);
this.stateChanges.next();
}
static nextId = 0;
@HostBinding() id = `weight-input-${WeightInput.nextId++}`;
@Input()
get placeholder() {
return this._placeholder;
}
set placeholder(placeholder) {
this._placeholder = placeholder;
this.stateChanges.next();
}
private _placeholder: string;
focused = false;
get empty() {
const value = this.weightForm.value as Weight;
return (!value.value && value.value != 0) || !!!value.unit;
}
@HostBinding('class.weight-floating')
get shouldLabelFloat() {
return this.focused || !this.empty;
}
@Input()
get required(): boolean {
return this._required;
}
set required(required: boolean) {
const temp: any = required;
required = (temp != "true");
this._required = required;
this.stateChanges.next();
}
private _required = false;
@Input()
get disabled(): boolean {
return this._disabled;
}
set disabled(disabled: boolean) {
const temp: any = disabled;
disabled = (temp != "true");
this._disabled = disabled;
this.setDisable();
this.stateChanges.next();
}
private _disabled = false;
errorState = false;
controlType = 'weight-input';
@HostBinding('attr.aria-describedby') describedBy = '';
setDescribedByIds(ids: string[]) {
this.describedBy = ids.join(' ');
}
onContainerClick(event: MouseEvent) {
if(!this.disabled) {
this._onTouched();
}
}
constructor(
@Optional() @Self() public ngControl: NgControl,
private fb: FormBuilder,
private fm: FocusMonitor,
private elRef: ElementRef<HTMLElement>
) {
if(this.ngControl != null) {
this.ngControl.valueAccessor = this;
}
fm.monitor(elRef.nativeElement, true).subscribe(origin => {
this.focused = !!origin;
this.stateChanges.next();
});
}
ngOnInit() {
this.weightForm = this.fb.group({
value: null,
unit: ''
});
this.setDisable();
this.weightForm.valueChanges.subscribe(
() => {
const value = this.value;
if(this._onChange) this._onChange(value);
this.stateChanges.next();
}
);
}
ngOnDestroy() {
this.stateChanges.complete();
this.fm.stopMonitoring(this.elRef.nativeElement);
}
writeValue(value: Weight): void {
if(value instanceof Weight) {
this.weightForm.setValue(value);
}
}
_onChange: (_: any) => void;
registerOnChange(fn: (_: any) => void): void {
this._onChange = fn;
}
_onTouched: () => void;
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
private setDisable(): void {
if(this.disabled && this.weightForm) {
this.weightForm.disable();
}
else if(this.weightForm) {
this.weightForm.enable();
}
}
getUnselectedColor(): string {
return this.unselected ? '#999' : '#000';
}
setUnselected(select): void {
this.unselected = !!!select.value;
}
}
And here is where it has to go:
app.component.html
<mat-form-field fxFlexAlign="stretch">
<weight-input formControlName="peso" [units]="units" placeholder="Peso" required></weight-input>
<mat-error *ngIf="peso.invalid">errore</mat-error>
</mat-form-field>
(peso means weight in Italian, the units are customs so you bind them in an input [units])
app.component.ts (partial)
units = [ 'Kg', 'g', 'T', 'hg' ];
ngOnInit() {
this.initForm();
}
private initForm(): void {
this.scheda = this.fb.group({
diametro: [ null, Validators.required ],
peso: [ null, Validators.required ], //There will be custom validators, for instance for unit control (Validators.unitsIn(units: string[]))
contorno: [ null, Validators.required ],
fornitore: null,
note: null
});
}
get diametro(): FormControl | undefined {
return this.scheda.get('diametro') as FormControl;
}
get peso(): FormControl | undefined {
return this.scheda.get('peso') as FormControl;
}
So what I need is:
Update: I corrected the empty method from this(!value.value && value.value != 0) || !!!value.unit
to this(!value.value && value.value != 0) && !!!value.unit
I changed the select input with a mat-select input, but it is still functionally the same
<div [formGroup]="weightForm">
<input fxFlex formControlName="value" type="number" placeholder="Valore" min="0" #value>
<mat-select fxFlex="10" id="mat-select" formControlName="unit">
<mat-option value="" selected> Unità </mat-option>
<mat-option *ngFor="let unit of units" [value]="unit">
{{ unit }}
</mat-option>
</mat-select>
</div>
Probably one should use the Validator interface, but unfortunately it creates that pesky cyclic error dependency. So instead, just add an errorState
property to your custom component that checks the ngControl
that was injected into the constructor, like this:
get errorState() {
return this.ngControl.errors !== null && !!this.ngControl.touched;
}
This should respect your normal Angular validators in the parent component, like this line in the formGroup:
peso: [ null, Validators.required ],
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With