Uno dei trend più in voga attualmente, nonché uno dei miei argomenti di studio preferiti, è quello dei Web Component! Stanno prendendo piede molte librerie di componenti di terze parti già pronti all’uso, e quasi sempre forniscono dei componenti per rimpiazzare i controlli nativi di un form (come input, select e textarea): parlo ad esempio di checkbox particolari, interruttori, color picker avanzati, date picker, e così via. Magari invece lavori in un’azienda in cui state sviluppando la vostra libreria di componenti! Fantastico.

Ma… come possiamo utilizzare questi componenti nei nostri form in Angular? Vediamo!

Il problema

A fini di esempio utilizzeremo la libreria Shoelace, che fornisce un ampio set di componenti riutilizzabili super performanti, sviluppati con Lit.

In particolare, utilizzeremo questo componente, chiamato sl-switch:

La libreria fornisce varie opzioni di installazione (fra cui ovviamente npm), per semplicità utilizzeremo una CDN. Ci basterà semplicemente incollare questo codice nella nostra pagina HTML:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.58/dist/themes/light.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.58/dist/shoelace.js"></script>

… E in qualsiasi punto dell’applicazione potremo utilizzare i suoi componenti, ad esempio:

<sl-switch>Switch!</sl-switch>

Prima ancora di preoccuparci dei form, però, dobbiamo fare i conti con un altro problema: se utilizziamo questi componenti all’interno dei nostri componenti Angular, riceveremo un errore!

Angular, per maggior sicurezza, controlla i template dei nostri componenti per avvisarci nel caso utilizzassimo un tag HTML errato. Quando rileva un tag che non conosce (come quello qui sopra), lancia un errore.

Dobbiamo dirgli esplicitamente che vogliamo utilizzare i Web Component, e quindi di non preoccuparsi: per farlo, andiamo in AppModule e includiamo questa particolare opzione:

import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";

@NgModule({
  ...,
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}

Fatto! Ora potete usare i componenti di Shoelace (o qualsiasi altro Web Component) senza alcun problema.


Angular, a differenza di altri framework, non ha alcun problema con i Web Component, anzi. Possiamo settare delle proprietà con le classiche parentesi quadre:

<sl-switch [checked]="true">Switch!</sl-switch>

Settare un attributo:

<sl-switch [attr.checked]="true">Switch!</sl-switch>

E ascoltare eventi custom:

<sl-switch (sl-blur)="onBlur()">Switch!</sl-switch>

Esattamente come con qualsiasi altro componente!

Ora veniamo al problema: dato che questo elemento è particolarmente adatto ad un form, vorremmo poterlo utilizzare come qualsiasi altro controllo nativo. Sto parlando ad esempio di ngModel (per i Template-driven form) o formControl / formControlName (per i Reactive form).

<sl-switch ngModel>Switch!</sl-switch>

Purtroppo però, otteniamo un errore: “No value accessor for form control with unspecified name attribute”.

Risolviamo questo problema!

Value Accessors

Per risolvere questo problema dobbiamo prima capire come fa Angular a gestire questo scenario con i controlli nativi: anche loro hanno un loro stato, con le loro proprietà, e Angular in qualche modo riesce a sincronizzare il DOM con le sue API.

Angular ci riesce grazie ad un costrutto chiamato Value Accessor. Questo costrutto è implementato in maniera differente a seconda dell’elemento: ad esempio, per gli input “normali” di tipo testuale, Angular utilizza il DefaultValueAccessor, per i select utilizza il SelectControlValueAccessor, e così via. Ognuno ha una particolarità perché i controlli nativi non sono tutti uguali!

Parlando più concretamente, sono delle Direttive che si agganciano agli elementi esistenti e fanno da ponte con Angular, in maniera molto semplice.

Quello che dobbiamo fare noi in questo caso, non potendo modificare il componente originale, è fare la stessa identica cosa: creiamoci la nostra direttiva custom che aggiunga questo comportamento.

ControlValueAccessor

Come primo step, creiamo la direttiva e facciamo in modo che implementi ControlValueAccessor:

import { ControlValueAccessor } from "@angular/forms";

@Directive({
  selector: "sl-switch"
})
export class ShoelaceSwitchDirective implements ControlValueAccessor {}

Ora, cosa facciamo in questa direttiva? Semplicissimo: quando il valore del controllo cambia dall’esterno (ad esempio via ngModel o FormControl.setValue), dobbiamo settare la proprietà checked di questo componente. Viceversa, quando il Web Component cambia stato internamente (l’utente ci interagisce) dobbiamo dire ad Angular che il valore è cambiato.

Come primo step, iniettiamo un servizio per manipolare l’elemento HTML a cui è agganciata la direttiva:

import { ElementRer } from "@angular/core";

...

constructor(private el: ElementRef) {}

Essendo in una direttiva, ElementRef farà riferimento all’elemento sul quale è applicata (il Web Component).

Ora implementiamo il metodo writeValue: questo metodo verrà chiamato da Angular per dirci che il valore è stato cambiato dall’esterno, quindi ci basta prenderne il valore e settare la giusta proprietà dell’elemento:

writeValue(x: any): void {
  this.el.nativeElement.checked = !!x;
}

Fatto. Ora il secondo step: dobbiamo dire noi ad Angular quando il valore cambia internamente. Come facciamo?

Come prima cosa, creiamoci due proprietà che facciano da placeholder per due funzioni:

export class ShoelaceSwitchDirective implements ControlValueAccessor {

  private onChange = (value: boolean) => {};
  private onTouched = () => {};

  ...
}

Il nostro lavoro sarà quello di chiamare queste due funzioni all’occorrenza! La prima quando il valore cambia, e la seconda quando il controllo viene toccato dall’utente (sì, viene anche sincronizzato lo stato touched!).

Ma al momento queste due funzioni non fanno nulla: in effetti non sono quelle giuste! Quelle giuste ce le fornirà Angular non appena istanzierà il componente, e ce le passerà grazie a questi due metodi:

registerOnChange(fn: (value: boolean) => void): void {
  this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
  this.onTouched = fn;
}

Voi non vi dovrete preoccupare mai di chiamare questi due metodi: vengono chiamati una sola volta da Angular in automatico. Scriveteli e dimenticatevene!

A questo punto, chiamiamo le due funzioni all’occorrenza: il Web Component fornisce un evento sl-change per dirci che il suo valore è cambiato, possiamo semplicemente ascoltarlo e notificare Angular in questo modo:

@HostListener("sl-change")
listenForValueChange(): void {
  const isChecked = this.el.nativeElement.checked;
  this.onChange(isChecked);
  this.onTouched();
}

HostListener

Al posto di HostListener potremmo anche ascoltare l’elemento manualmente con un addEventListener, ma questa è una best practice!

Già che ci siamo, implementiamo anche un ultimo metodo per quando lo stato disabled viene settato dall’esterno: questo Web Component ha a sua volta una sua proprietà disabled, ci basta settarla.

setDisabledState(isDisabled: boolean): void {
  this.el.nativeElement.disabled = isDisabled;
}

Come ultimissimo step (lo giuro!) dobbiamo dire ad Angular che questo ControlValueAccessor esiste: lo facciamo inserendolo nei provider in questo modo:

import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";

@Directive({
  selector: "sl-switch",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: ShoelaceSwitchDirective,
      multi: true
    }
  ]
})
export class ShoelaceSwitchDirective implements ControlValueAccessor { ... }

E ricordatevi di dichiarare questa direttiva:

@NgModule({
  ...,
  declarations: [ShoelaceSwitchDirective]
})
export class AppModule {}

Finito! Ora possiamo utilizzare questo Web Component come qualsiasi altro controllo nativo HTML, sia con i Template-driven Form che con i Reactive Form!

<!-- Template-driven --> 
<sl-switch [ngModel]="false" #switch="ngModel">{{ switch.value }}</sl-switch>

<!-- Reactive -->
<sl-switch [formControl]="myControl">{{ myControl.value }}</sl-switch>

Codice completo

Ecco il codice completo della direttiva:

import { Directive, ElementRef, HostListener } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";

@Directive({
  selector: "sl-switch",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: ShoelaceSwitchDirective,
      multi: true
    }
  ]
})
export class ShoelaceSwitchDirective implements ControlValueAccessor {
  private onChange = (value: boolean) => {};
  private onTouched = () => {};

  constructor(private el: ElementRef) {}

  @HostListener("sl-change")
  listenForValueChange(): void {
    const isChecked = this.el.nativeElement.checked;
    this.onChange(isChecked);
    this.onTouched();
  }

  writeValue(x: any): void {
    this.el.nativeElement.checked = !!x;
  }

  setDisabledState(isDisabled: boolean): void {
    this.el.nativeElement.disabled = isDisabled;
  }

  registerOnChange(fn: (value: boolean) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
}

Ti è piaciuto l’articolo? Condividilo!

Rilasciare risorse gratuite non è facile: un sacco di tempo vola fra la ricerca dell’idea, la stesura, la preparazione di codice funzionante e la revisione. Se ti piace ciò che facciamo, condividi l’articolo: nel peggiore dei casi ti daranno del secchione!

Ti interesserà anche…

Angular

Gestione degli errori

In Angular abbiamo diversi modi per gestire gli errori: ognuno ha i suoi pro e i suoi contro, vediamoli assieme. HttpErrorResponse Il caso più comune di errore da dover gestire…

Iscriviti alla nostra newsletter!

Rimani aggiornato per quando rilasceremo nuovi articoli, nuovi corsinuove promozioni e nuove risorse gratuite. Ti promettiamo che useremo la tua email con prudenza, e non la condivideremo con nessuno senza il tuo consenso!

Niente spam. Leggi la nostra privacy policy qui.

Menu