Control Value Accessor: Integración con Angular forms (Básico)
Control Value Accessor
Hay ocasiones en las que necesitamos desarrollar un componente que no sigue las guidelines o reglas de diseño establecidas. Bien porque el control no existe o porque se requiere modificar en profundidad alguno existente. En una situación como ésta, lo más común es que simplemente creemos el componente con la funcionalidad requerida, y mediante propiedades de entrada y salida comuniquemos al componente con nuestra aplicación. En la práctica puede parecer que el componente ya está integrado con Angular como si fuera nativo, pero en realidad solo está simulando ese comportamiento.
Prefacio
Pongámonos en situación con un sencillo ejemplo. En nuestra aplicación usamos formularios con ngModel (Template-driven Forms). El modelo de datos que usamos es el siguiente.
class Hero {
constructor(
public id: number,
public name: string,
public power: string,
public alterEgo?: string
) { }
}
// Datos de ejemplo para nuestro componente
let myHero = new Hero(
42,
'SkyDog',
'Fetch any object at any distance',
'Leslie Rollover'
);
Y en el HTML tenemos lo siguiente.
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name"
[(ngModel)]="myHero.name" required />
</div>
<div>
<label for="alterEgo">Alter Ego</label>
<input type="text" id="alterEgo" name="alterEgo"
[(ngModel)]="myHero.alterEgo" required />
</div>
<div>
<label for="power">Hero Power</label>
<custom-control id="power" name="power"
[value]="myHero.power"
(valueChange)="myHero.power = $event"
[required]="true">
</custom-control>
</div>
Podemos observar que los 2 primeros controles utilizan ngModel y required, lo cual mantiene actualizado el valor y estado de validación del modelo en todo momento. En cambio, en nuestro componente custom-control hemos tenido que crear una propiedad de entrada, y mediante un evento de salida actualizar el valor manualmente. Además, si queremos mantener la posibilidad de validar si es requerido hemos tenido que añadir otra propiedad más (y hacer las validaciones internamente).
Por ello vamos a afirmar, que a pesar de poder cumplir con sus especificaciones y diseño requeridos, NO es un componente que se pueda considerar integrado con los formularios de Angular y reusable en este sentido. Puesto que si en vez de formularios simples (Template-driven forms), en un momento dado empezaramos a utilizar formularios dinámicos (Reactive Forms), la forma de trabajar con sus propiedades cambiaría. Tendríamos que actualizar la forma de trabajar con nuestro componente en todos los sitios del proyecto en los que ya se estuviera usando.
Integración con Angular Forms
¿Cuál es la solución? Convertir nuestro componente en un Control Value Accessor. Este es el modo en el que el equipo de Angular expone su API de formularios y la pone a nuestra disposición. Su uso consiste en implementar una interfaz, la cual contiene las funciones comunes a todos los controles de formulario de Angular, ya sean simples o dinámicos:
- writeValue – Recibe el valor del form control en nuestro componente
- registerOnChange – Nos provee la función necesaria para indicar al form control que el valor de nuestro componente ha cambiado
- registerOnTouched – Nos provee la función necesaria para indicar al form control que el estado del componente ha cambiado
- setDisabledState – Recibimos el valor del form control cada vez que se habilita o deshabilita en nuestro componente
Objetivo
Para comprender mejor el funcionamiento de la interfaz Control Value Accessor, desarrollaremos un componente sencillo que la implemente para seleccionar un color primario RGB. El resultado se verá similar a la imágen que os dejo a continuación.
Tecnologías
El stack de versiones que se usarán para el desarrollo del proyecto es el siguiente.
- Angular CLI (9.0.5)
- Node (12.14.1)
- Npm (6.13.7)
- Typescript (3.7.5)
Creación del proyecto
Vamos a empezar creando un proyecto nuevo que llamaremos cva-basic.
ng new cva-basic
Creamos un componente llamado color-selector.
ng g c components/color-selector
Dejamos preparado el archivo app.module.ts para más tarde.
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { ColorSelectorComponent } from './components/color-selector/color-selector.component';
@NgModule({
declarations: [
AppComponent,
ColorSelectorComponent
],
imports: [
BrowserModule,
ReactiveFormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
También dejamos preparados los archivos app.component.html y app.component.scss para que muestre el componente.
app.component.html
<div class="container">
<app-color-selector>
</app-color-selector>
</div>
app.component.scss
.container {
display: flex;
flex-flow: row;
place-content: center;
place-items: center;
}
Componente Color Selector
Nuestro componente va a consistir en 3 círculos con los colores primarios, que actuarán como botones de selección. Cuando se haga click sobre uno de ellos se quedará «seleccionado» y mostrará un tick para indicarlo. Si se hace click sobre el que ya está seleccionado, éste se desmarcará.
En el archivo color-selector.component.ts tendremos una variable que guardará el color que tenemos seleccionado (si es que lo hay). Y una función que será la que seleccione o deseleccione un color. Además, definiremos un tipo personalizado para los colores que acepta el componente.
color-selector.component.ts
import { Component, OnInit } from '@angular/core';
/** Función adaptadora para el array de colores */
const colorsType = <T extends string>(array: T[]) => array;
/** Colores admitidos por el componente */
const colors = colorsType(['red', 'green', 'blue']);
/** Modelo de colores del componente */
type ColorSelected = (typeof colors)[number];
@Component({
selector: 'app-color-selector',
templateUrl: './color-selector.component.html',
styleUrls: ['./color-selector.component.scss']
})
export class ColorSelectorComponent implements OnInit {
/** Color seleccionado */
selection: ColorSelected;
constructor() {
// Se inicializa el color sin valor
this.selection = null;
}
ngOnInit(): void { }
/**
* Limpia el valor de la selección del componente
*/
clearSelection(): void {
this.selection = null;
}
/**
* Selecciona un color
* @param color Color sobre el que se ha hecho click
*/
colorSelected(color: ColorSelected): void {
// Si se ha hecho click sobre el color que ya estaba seleccionado
if (color === this.selection) {
this.clearSelection(); // Deselección
}
// Si se ha hecho click en un color no seleccionado
else {
this.selection = color; // Selección
}
}
}
En al archivo color-selector.component.html tendremos 3 divs, cada cual con un ID para establecer su color correspondiente por CSS. Dinámicamente se le asignará una clase al color seleccionado, que cambiará su estilo para que se diferencie bien del resto. Todos los colores ejecutarán la función de selección cuando se produzca un click sobre ellos, pero cada uno le pasará como parámetro el color sobre el que se hizo click.
color-selector.component.html
<div class="container">
<div id="red" class="color"
[ngClass]="{'selected': selection === 'red'}"
(click)="colorSelected('red')">
</div>
<div id="green" class="color"
[ngClass]="{'selected': selection === 'green'}"
(click)="colorSelected('green')">
</div>
<div id="blue" class="color"
[ngClass]="{'selected': selection === 'blue'}"
(click)="colorSelected('blue')">
</div>
</div>
Y por último, el archivo color-selector.component.scss. En él se encontrarán todos los estilos necesarios para el componente.
color-selector.component.scss
:host {
position: relative;
display: flex;
flex-flow: row;
width: 192px;
padding: 8px 16px;
border: 1px solid lightgrey;
border-radius: 8px;
.container {
display: flex;
flex-flow: row;
width: 100%;
height: 100%;
place-content: space-between;
.color {
width: 48px;
height: 48px;
border-radius: 100%;
cursor: pointer;
&.selected::after {
content: "2714";
color: white;
display: flex;
flex-flow: row;
height: 100%;
place-content: center;
place-items: center;
border-radius: 100%;
background-color: rgba(0,0,0,0.5);
}
}
#red {
background-color: red;
}
#green {
background-color: green;
}
#blue {
background-color: blue;
}
}
}
Si ejecutamos el proyecto con ng serve observaremos que el componente ya tiene el comportamiento que queríamos.
Preparación para el CVA
Vamos a empezar por dejar preparado AppComponent para cuando vayamos a «convertir» nuestro componente en CVA (Control Value Accessor).
app.component.html
<div class="container">
<app-color-selector [formControl]="colorSelectorControl">
</app-color-selector>
<div class="margin-top">
Color selector value: {{ colorSelectorControl.value }}
</div>
</div>
app.component.ts
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
colorSelectorControl: FormControl;
constructor() {
this.colorSelectorControl = new FormControl();
}
}
app.component.scss
.container {
display: flex;
flex-flow: column;
place-content: center;
place-items: center;
}
.margin-top {
margin-top: 16px;
}
Si ejecutamos en este punto el proyecto con ng serve -o podremos ver en la consola del navegador un error. Éste nos informa que se está usando una directiva de CVA (en nuestro caso es por [formControl]=»colorSelectorControl») en un componente que no es un CVA, lo cual es normal ya que aún no lo implementamos.
Integración como CVA
Lo primero que tenemos que hacer es implementar la interfaz de ControlValueAccessor, y declarar las variables necesarias para su uso.
/** Valida si un valor es de tipo ColorSelected */
const isColor = (x: any): x is ColorSelected => colors.includes(x);
@Component({ ... })
export class ColorSelectorComponent implements OnInit, ControlValueAccessor {
/** Color seleccionado */
selection: ColorSelected;
/** Función para actualizar el valor del CVA */
onChanged: any;
/** Funcion para marcar como 'touched' el CVA */
onTouched: any;
/** Controla si el componente está habilitado */
disabled: boolean;
}
En el constructor del componente inyectamos NgControl, y lo marcamos con los decoradores @Self() y @Optional(). El primero le indica al sistema de inyección de dependencias de Angular que nuestro componente es el NgControl (CVA). Y el segundo marca como opcional el uso de nuestro componente en formularios. Dentro del constructor inicializamos las variables del componente con un valor por defecto.
constructor(
@Self() @Optional() private ngControl: NgControl
) {
// Si el componente se está usando como control de formulario
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
// En caso contrario se inicializan las funciones de CVA por defecto
else {
this.onChanged = () => null;
this.onTouched = () => null;
}
// Se inicializa el color sin valor
this.selection = null;
// Se inicializa el control como habilitado
this.disabled = false;
}
Ahora tenemos que hacer un pequeño cambio a la función colorSelected, para que además de actualizar el valor interno del componente también actualice el valor externo del CVA.
/**
* Selecciona un color
* @param color Color sobre el que se ha hecho click
*/
colorSelected(color: ColorSelected): void {
// Si se ha hecho click sobre el color que ya estaba seleccionado
if (color === this.selection) {
this.clearSelection(); // Deselección
}
// Si se ha hecho click en un color no seleccionado
else {
this.selection = color; // Selección
}
// Se actualiza el valor del CVA si el componente está habilitado
if (!this.disabled) {
this.onChanged(this.selection);
this.onTouched();
}
}
Implementamos la función writeValue de la interfaz CVA. A través de la cual recibiremos los cambios en el valor del CVA, y así poder actualizar el valor interno correspondientemente.
/**
* Recibe un valor desde fuera del componente (a través del CVA)
* @param color Valor recibido desde fuera del componente
*/
writeValue(color: any): void {
// Si el valor recibido es un color admitido
if (isColor(color)) {
this.selection = color;
}
// En caso contrario se limpia la selección
else if (!color) {
this.clearSelection();
}
}
Las siguientes dos funciones a implementar son registerOnChange y registerOnTouched. Son ejecutadas una única vez, en la que recibimos las funciones para poder actualizar el valor y estado del CVA.
/**
* Recibe la función para emitir un cambio en el valor del CVA
* @param fn Función a implementar
*/
registerOnChange(fn: any): void {
this.onChanged = fn;
}
/**
* Recibe la función para emitir un cambio en el estado 'touched' del CVA
* @param fn Función a implementar
*/
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
Ejecutamos el proyecto con ng serve y comprobamos que el valor del FormControl que tenemos en AppComponent se actualiza correctamente. ¡Nuestro componente ya está integrado como ControlValueAccessor 🤩!
Deshabilitando el CVA
Vamos a implementar la última función de CVA (aunque es opcional), encargada de recibir si el control está habilitado o deshabilitado, setDisabledState.
/**
* Recibe si el CVA está habilitado o no
* @param isDisabled Estado del CVA
*/
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
Actualizamos los estilos del componente para que se diferencie cuando está deshabilitado.
color-selector.component.scss
:host {
...
.container {
...
&.disabled::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 6px;
background-color: lightgrey;
opacity: 0.8;
}
.color {
...
&.selected::after { ... }
}
#red { ... }
#green { ... }
#blue { ... }
}
}
Incluimos la nueva clase CSS en el HTML.
color-selector.component.html
<div class="container" [ngClass]="{'disabled': disabled}">
...
</div>
Agregamos un botón en AppComponent para poder habilitar y deshabilitar el componente.
app.component.html
<div class="container">
...
<button (click)="toggleDisableState()" class="margin-top">
{{ colorSelectorControl.disabled ? 'Enable' : 'Disable' }} ColorSelector
</button>
</div>
app.component.html
@Component({ ... })
export class AppComponent {
...
constructor() { ... }
/**
* Habilita o deshabilita el control del ColorSelector
*/
toggleDisableState(): void {
// Si el control esta deshabilitado
if (this.colorSelectorControl.disabled) {
this.colorSelectorControl.enable(); // Se habilita
}
// Si está habilitado
else {
this.colorSelectorControl.disable(); // Se deshabilita
}
}
}
Stackblitz
A continuación tenéis el proyecto disponible en Stackblitz.
Frontend Engineer en Reveni.
Autodidacta, apasionado de las nuevas tecnologías y de los proyectos DIY.






Mejor no se puede explicar. Enhorabuena