Appearance
Composants ControlValueAccessor réutilisables
Ce tutoriel explique comment concevoir des composants de formulaire avancés (datepicker, multi-select, upload de fichiers) avec ControlValueAccessor, tout en conservant une API claire, accessible et simple a reutiliser dans l’application.
Imaginez une fabrique de jouets modulaires. Chaque piece doit s’assembler sans effort avec le reste de la collection, meme si l’enfant choisit une combinaison inedite. Un composant ControlValueAccessor joue le meme role : il encapsule des interactions complexes, mais presente toujours le meme connecteur standard pour Angular Forms.
Comprendre le contrat ControlValueAccessor
Reflexion
ControlValueAccessor agit comme un adaptateur entre Angular Forms et votre composant. Il doit savoir recevoir une valeur externe (writeValue), notifier les changements (registerOnChange, registerOnTouched) et respecter l’etat disabled. Aujourd’hui, les signaux et inject() simplifient cette orchestration.
Uniformisez votre contrat
Des signatures identiques et documentees facilitent la comprehension par votre equipe. Gardez un objet de configuration documente et exposez clairement les evenements generes.
Exemple : multi-select accessible avec recherche
Reflexion
Nous allons creer un composant app-token-multi-select qui combine :
- Une liste filtrable
- Une navigation clavier
- Des jetons affiches pour chaque selection
- Un contrat ControlValueAccessor permettant de l’utiliser avec Reactive Forms
Nous utilisons host pour declarer les evenements clavier et les attributs ARIA, des signaux pour le filtrage et input() pour recevoir la configuration.
Comment nous l'avons construit
- State minimaliste : nous stockons la recherche, l’index survole et les identifiants selectionnes dans des
signal()pour reactualiser la vue sans surcharge. - Filtrage reactif : un
computed()derive la liste filtree a partir du terme de recherche, garantissant que la navigation clavier parcourt toujours les options visibles. - Navigation et accessibilite : les bindings
hostdeclenchent les fleches et la toucheEnter, tandis quearia-activedescendantrenseigne les lecteurs d’ecran. - Contrat ControlValueAccessor :
writeValue,registerOnChange,registerOnTouchedetsetDisabledStatesynchronisent le composant avec Angular Forms. - Tokens et suppression : chaque selection s’affiche sous forme de tag et peut etre retiree en un clic, en veillant a recréer un
Setpour declencher la detection des changements.
ts
import {
ChangeDetectionStrategy,
Component,
Signal,
computed,
effect,
forwardRef,
input,
signal
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
interface TokenOption {
id: number;
label: string;
}
/**
* **Objectif** : proposer un multi-select reutilisable avec recherche, navigation clavier et tokens.
* **Conception** : s'appuyer sur les signaux pour refléter les valeurs sélectionnées et exposer une interface ControlValueAccessor compatible avec les formulaires.
* @example
* <app-token-multi-select [options]="userOptions" formControlName="members"></app-token-multi-select>
*/
@Component({
selector: 'app-token-multi-select',
standalone: true,
host: {
role: 'listbox',
tabindex: '0',
'(keydown.enter)': 'confirmHighlighted()',
'(keydown.arrowdown)': 'highlightNext()',
'(keydown.arrowup)': 'highlightPrevious()',
'[attr.aria-activedescendant]': 'activeOptionId()',
'[attr.aria-disabled]': 'isDisabled ? "true" : null'
},
template: `
<label class="sr-only" [for]="searchId">Filter choices</label>
<input
[id]="searchId"
type="text"
placeholder="Filter..."
(input)="onSearch($any($event.target).value)"
(focus)="markAsTouched()"
/>
<ul>
@for (option of filteredOptions(); track option.id; let index = $index) {
<li
id="option-{{ option.id }}"
[class.highlighted]="index === highlightedIndex()"
(mouseenter)="highlightIndex(index)"
(click)="toggleOption(option)"
[attr.aria-selected]="isSelected(option)"
>
{{ option.label }}
</li>
}
</ul>
<div class="tokens" aria-live="polite">
@for (token of selectedOptions(); track token.id) {
<span class="token">
{{ token.label }}
<button type="button" (click)="removeToken(token)">
Remove
</button>
</span>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TokenMultiSelectComponent),
multi: true
}
],
styleUrls: ['./token-multi-select.component.css']
})
export class TokenMultiSelectComponent implements ControlValueAccessor {
private static idCounter = 0;
readonly options = input<TokenOption[]>([]);
readonly highlightedIndex = signal(0);
readonly search = signal('');
readonly selectedIds = signal<Set<number>>(new Set<number>());
readonly searchId = `token-search-${++TokenMultiSelectComponent.idCounter}`;
protected isDisabled = false;
private readonly onChangeFns = signal<Array<(values: number[]) => void>>([]);
private onTouched: () => void = () => {};
private readonly filteredSignal = computed(() => {
const term = this.search().toLowerCase();
return this.options().filter((option) =>
option.label.toLowerCase().includes(term)
);
});
/**
* **Objectif** : exposer les options filtrées sous forme de signal reutilisable dans le template et la logique.
* **Conception** : memoriser ce calcul pour maintenir une navigation clavier coherente.
* @example
* const options = tokenMultiSelect.filteredOptions();
*/
readonly filteredOptions: Signal<TokenOption[]> = this.filteredSignal;
private readonly selectionEffect = effect(() => {
const ids = Array.from(this.selectedIds());
this.onChangeFns().forEach((fn) => fn(ids));
});
writeValue(ids: number[] | null): void {
const normalized = Array.isArray(ids) ? ids : [];
this.selectedIds.set(new Set(normalized));
}
registerOnChange(fn: (ids: number[]) => void): void {
this.onChangeFns.update((fns) => [...fns, fn]);
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled = isDisabled;
}
/**
* **Objectif** : filtrer les options lorsque l'utilisateur saisit un texte.
* **Conception** : nettoyer les espaces et mettre a jour immediatement le signal de recherche pour garder une interface reactive.
* @example
* tokenMultiSelect.onSearch('dev');
*/
onSearch(term: string): void {
this.search.set(term.trim());
}
/**
* **Purpose**: notifier Angular Forms que le composant a ete touche.
* **Design**: appeler le callback `onTouched` lors des interactions clavier ou souris.
* @example
* tokenMultiSelect.markAsTouched();
*/
markAsTouched(): void {
this.onTouched();
}
/**
* **Objectif** : surligner une option lorsque l'utilisateur survole la liste.
* **Conception** : conserver l'index fourni pour synchroniser navigation souris et clavier.
* @example
* tokenMultiSelect.highlightIndex(2);
*/
highlightIndex(index: number): void {
this.highlightedIndex.set(index);
}
/**
* **Objectif** : deplacer le surlignage vers l'option suivante au clavier.
* **Conception** : boucler en fin de liste pour garantir une navigation continue.
* @example
* tokenMultiSelect.highlightNext();
*/
highlightNext(): void {
const length = this.filteredOptions().length;
if (!length) {
return;
}
const next = (this.highlightedIndex() + 1) % length;
this.highlightedIndex.set(next);
}
/**
* **Objectif** : remonter sur l'option precedente au clavier.
* **Conception** : reboucler en debut de tableau pour conserver une navigation fluide.
* @example
* tokenMultiSelect.highlightPrevious();
*/
highlightPrevious(): void {
const options = this.filteredOptions();
if (!options.length) {
return;
}
const next = (this.highlightedIndex() - 1 + options.length) % options.length;
this.highlightedIndex.set(next);
}
/**
* **Objectif** : selectionner l'option surlignee lorsqu'on presse Entrée.
* **Conception** : reutiliser la logique de bascule pour eviter les doublons de code.
* @example
* tokenMultiSelect.confirmHighlighted();
*/
confirmHighlighted(): void {
const option = this.filteredOptions()[this.highlightedIndex()];
if (option) {
this.toggleOption(option);
}
}
/**
* **Purpose**: check whether a given option is already selected.
* **Design**: look up the identifier inside the reactive Set for quick access.
* @example
* tokenMultiSelect.isSelected(option);
*/
isSelected(option: TokenOption): boolean {
return this.selectedIds().has(option.id);
}
/**
* **Objectif** : ajouter ou retirer une option lors d'un clic ou d'une validation clavier.
* **Conception** : cloner le `Set` avant modification pour declencher la mise a jour du signal.
* @example
* tokenMultiSelect.toggleOption({ id: 1, label: 'Alice' });
*/
toggleOption(option: TokenOption): void {
const updated = new Set(this.selectedIds());
if (updated.has(option.id)) {
updated.delete(option.id);
} else {
updated.add(option.id);
}
this.selectedIds.set(updated);
this.onTouched();
}
/**
* **Objectif** : retirer une option depuis le bouton du jeton.
* **Conception** : conserver l'immutabilite pour informer Angular Forms et les signaux.
* @example
* tokenMultiSelect.removeToken({ id: 3, label: 'Sofia' });
*/
removeToken(option: TokenOption): void {
const updated = new Set(this.selectedIds());
updated.delete(option.id);
this.selectedIds.set(updated);
this.onTouched();
}
/**
* **Objectif** : retourner les options selectionnees pour l'affichage des jetons.
* **Conception** : filtrer la liste initiale afin de préserver l'association identifiant/libelle.
* @example
* const tokens = tokenMultiSelect.selectedOptions();
*/
selectedOptions(): TokenOption[] {
const ids = this.selectedIds();
return this.options().filter((option) => ids.has(option.id));
}
/**
* **Objectif** : exposer l'identifiant de l'option active pour les lecteurs d'ecran.
* **Conception** : calculer l'id a partir de l'option surlignee afin de maintenir l'annonce ARIA.
* @example
* const activeId = tokenMultiSelect.activeOptionId();
*/
activeOptionId(): string | null {
const option = this.filteredOptions()[this.highlightedIndex()];
return option ? `option-${option.id}` : null;
}
}css
:host {
display: block;
border: 1px solid #d1d5db;
border-radius: 0.75rem;
padding: 0.75rem;
background: #ffffff;
max-width: 360px;
font-family: Arial, sans-serif;
}
input {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px solid #9ca3af;
margin-bottom: 0.75rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
}
li {
padding: 0.5rem;
cursor: pointer;
border-radius: 0.5rem;
}
li.highlighted {
background: #1d4ed8;
color: #f9fafb;
}
.tokens {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.token {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #e0f2fe;
border-radius: 9999px;
}
button {
border: none;
background: transparent;
cursor: pointer;
color: #0f172a;
}Evitez les effets secondaires inutiles
Les methodes toggleOption et removeToken manipulent un Set. Creez toujours une nouvelle instance avant de l’attribuer au signal pour garantir la detection du changement.
Tester l’accessibilite du composant
Reflexion
Votre composant est fonctionnel, mais doit rester accessible. Verifiez :
- Le focus clavier sur l’input et la navigation sur les elements
- Les roles ARIA exposes et la presence d’un message live pour les tokens
- Les commandes clavier (
Enter,ArrowUp,ArrowDown)