Patterns Angular

Comment créer un composant qui consomme Ori proprement.

Composer plutôt qu’étendre

Les composants Ori exposent des classes CSS sémantiques (ori-input, ori-card, etc.) plus une API Angular standalone. La règle :

  • Pour un cas standard : utiliser le composant tel quel
  • Pour un variant qui revient souvent : créer un composant applicatif qui pré-configure
  • Pour quelque chose de très différent : composer un nouveau composant à partir des classes CSS du DS

Exemple : wrapper d’un Input avec compteur de caractères

import { Component, Input } from '@angular/core';
import { OriInputComponent } from '@govpf/ori-angular';

@Component({
  selector: 'app-input-with-counter',
  standalone: true,
  imports: [OriInputComponent],
  template: `
    <ori-input
      [label]="label"
      [(value)]="value"
      [hint]="value.length + ' / ' + maxLength + ' caractères'"
      [maxLength]="maxLength"
    ></ori-input>
  `,
})
export class InputWithCounterComponent {
  @Input() label = '';
  @Input() maxLength = 280;
  value = '';
}

Pas de fork du composant Input - juste un composant applicatif qui ajoute du comportement.

Réutiliser les classes CSS pour un composant 100% custom

Pour un composant qui n’existe pas dans Ori mais qui doit avoir le look du DS, réutiliser directement les classes CSS :

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-result-card',
  standalone: true,
  template: `
    <article class="ori-card ori-card--elevated" style="padding: 1.25rem;">
      <h3 class="ori-card__title">{{ title }}</h3>
      <p style="font-size: 2rem; font-weight: 600; margin: 0.5rem 0 0;">{{ count }}</p>
    </article>
  `,
})
export class ResultCardComponent {
  @Input() title = '';
  @Input() count = 0;
}

Le DS expose une trentaine de classes ori-* documentées dans Fondations / Tokens / Sémantique.

Forms réactifs avec ReactiveFormsModule

Les composants form Ori n’implémentent pas ControlValueAccessor nativement (volontaire pour rester découplés). Pour les utiliser avec [formControl], wrapper :

import { Component, Input } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { OriInputComponent } from '@govpf/ori-angular';

@Component({
  selector: 'app-rf-input',
  standalone: true,
  imports: [OriInputComponent, ReactiveFormsModule],
  template: `
    <ori-input
      [label]="label"
      [value]="control.value || ''"
      (valueChange)="control.setValue($event)"
      [error]="control.touched && control.invalid ? errorMessage : ''"
      (blur)="control.markAsTouched()"
    ></ori-input>
  `,
})
export class RfInputComponent {
  @Input({ required: true }) control!: FormControl<string | null>;
  @Input() label = '';
  @Input() errorMessage = 'Champ invalide';
}

Pattern similaire pour <ori-checkbox>, <ori-select>, etc. À factoriser en cas d’usages multiples.

Détection de changement OnPush

Tous les composants Ori utilisent ChangeDetectionStrategy.OnPush. Cela signifie qu’ils ne se rafraîchissent que sur :

  • Changement d’@Input() (par référence pour les objets/tableaux)
  • Émission d’un @Output()
  • Appel manuel à markForCheck() / detectChanges()

Muter un objet en place (this.user.name = 'X') puis le passer à un composant Ori ne déclenche pas le rendu. Solution : remplacer la référence (this.user = { ...this.user, name: 'X' }).

Tableau avec rendu personnalisé par colonne

<ori-table> accepte un cellTemplate par colonne pour rendre une cellule autrement que via {{ row[col.key] }}. Le piège : sous strictTemplates, le contexte du template doit être typé exactement { $implicit: TRow; index: number }, et row côté template est typé any, ce qui force à passer par des helpers typés.

import {
  AfterViewInit,
  Component,
  TemplateRef,
  ViewChild,
  signal,
} from '@angular/core';
import {
  OriTableComponent,
  OriTagComponent,
  type OriTableColumn,
} from '@govpf/ori-angular';

interface Dossier {
  id: string;
  numero: string;
  statut: 'a_instruire' | 'en_cours' | 'valide' | 'rejete';
}

const VARIANT: Record<Dossier['statut'], 'info' | 'warning' | 'success' | 'danger'> = {
  a_instruire: 'info',
  en_cours: 'warning',
  valide: 'success',
  rejete: 'danger',
};

@Component({
  selector: 'app-dossiers-table',
  standalone: true,
  imports: [OriTableComponent, OriTagComponent],
  template: `
    <ori-table
      [columns]="columns()"
      [rows]="rows"
      [rowKey]="rowKey"
    ></ori-table>

    <ng-template #statutTpl let-row>
      <ori-tag [variant]="variant(row)">{{ row.statut }}</ori-tag>
    </ng-template>
  `,
})
export class DossiersTableComponent implements AfterViewInit {
  rows: Dossier[] = [/* ... */];
  rowKey = (r: Dossier) => r.id;

  // Typage exact requis par OriTableColumn.cellTemplate.
  @ViewChild('statutTpl', { static: true })
  statutTpl!: TemplateRef<{ $implicit: Dossier; index: number }>;

  columns = signal<OriTableColumn<Dossier>[]>([]);

  // Helper typé : indispensable, parce que `row` côté template est `any`
  // et indexer un Record<X, Y> avec `any` est refusé par strictTemplates.
  variant(row: Dossier) {
    return VARIANT[row.statut];
  }

  ngAfterViewInit() {
    this.columns.set([
      { key: 'numero', label: 'Numéro', sortable: true },
      { key: 'statut', label: 'Statut', cellTemplate: this.statutTpl },
    ]);
  }
}

Trois choses qui surprennent la première fois :

  1. Le cellTemplate est défini côté composant via @ViewChild, pas côté template du parent. C’est le pattern TemplateRef Angular standard.
  2. Le typage TemplateRef<{ $implicit: Dossier; index: number }> doit matcher exactement le OriTableColumn.cellTemplate ; un TemplateRef<unknown> est refusé.
  3. let-row n’hérite pas du typage du TemplateRef ; tout accès indexé sur row doit passer par une méthode typée.

Pièges strict-templates récurrents

Inputs booléens

<!-- Refusé : passe la string "" au lieu de true -->
<ori-notification dismissible>

<!-- Bon -->
<ori-notification [dismissible]="true">

S’applique à tous les inputs booléens : dismissible, iconOnlyButton, striped, loading, hideLabel, removable, etc.

Sélection multiple sur <ori-table>

selectable est une string union 'none' | 'single' | 'multiple', pas un booléen. À passer comme attribut littéral ou string-bound :

<ori-table selectable="multiple" [rowKey]="rowKey" ...>

rowKey est obligatoire dès que selectable !== 'none'.

(select) du DropdownMenu : l’item peut être un séparateur

onMenuAction(item: OriDropdownMenuItem) {
  // `separator: true` ne déclenche pas (select), mais `id` peut être
  // undefined sur des items sans handler explicite. Toujours brancher
  // sur item.id avec une garde.
  switch (item.id) {
    case 'voir':    /* ... */ break;
    case 'editer':  /* ... */ break;
  }
}

Anti-patterns

  • ❌ Surcharger une classe ori-* dans le CSS global pour la modifier. Ça casse la cohérence DS et le dark mode.
  • ❌ Utiliser <button class="ori-button ori-button--primary"> à la main au lieu de <ori-button variant="primary">. Tu perds le typage et l’a11y.
  • ❌ Importer plusieurs BrowserModule ou autre Module global - les composants Ori sont standalone et n’en demandent pas.