Son astuce pour mieux se former

Plonge dans une interview inspirante et condensée de Gérôme Grignon, développeur frontend passionné et figure incontournable de la communauté Angular francophone.

Dans cet échange, Gérôme partage son parcours, ses conseils d'apprentissage, sa vision d'Angular et sa réflexion sur l'usage de l'IA dans le développement web.

Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

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

  1. State minimaliste : nous stockons la recherche, l’index survole et les identifiants selectionnes dans des signal() pour reactualiser la vue sans surcharge.
  2. Filtrage reactif : un computed() derive la liste filtree a partir du terme de recherche, garantissant que la navigation clavier parcourt toujours les options visibles.
  3. Navigation et accessibilite : les bindings host declenchent les fleches et la touche Enter, tandis que aria-activedescendant renseigne les lecteurs d’ecran.
  4. Contrat ControlValueAccessor : writeValue, registerOnChange, registerOnTouched et setDisabledState synchronisent le composant avec Angular Forms.
  5. Tokens et suppression : chaque selection s’affiche sous forme de tag et peut etre retiree en un clic, en veillant a recréer un Set pour 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)

Exemple complet