# @suntelecoms/ngx-dynamic-form

> Génère des formulaires Angular complexes à partir d'une configuration TypeScript — sans écrire une ligne de template HTML. Inclut un Form Builder visuel, la gestion des schémas sauvegardés, un système de stockage interchangeable (localStorage / API REST) et le support des formulaires **multi-étapes (Stepper)**.

[![Angular](https://img.shields.io/badge/Angular-19-red?logo=angular)](https://angular.dev)
[![Angular Material](https://img.shields.io/badge/Angular_Material-19-blue?logo=material-design)](https://material.angular.io)
[![Version](https://img.shields.io/badge/version-1.7.22-brightgreen)](CHANGELOG.md)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
[![Author](https://img.shields.io/badge/Author-Amadou%20Diallo-purple)](mailto:amadoumacbookpro38@gmail.com)

---

## Sommaire

- [Présentation](#présentation)
- [Fonctionnalités](#fonctionnalités)
- [Installation](#installation)
- [Démarrage rapide — formulaire statique](#démarrage-rapide--formulaire-statique)
- [Démarrage rapide — stack complète](#démarrage-rapide--stack-complète)
- [Types de champs](#types-de-champs)
- [Configuration — DynamicFormConfig](#configuration--dynamicformconfig)
- [Propriétés d'un champ — DynamicFormField](#propriétés-dun-champ--dynamicformfield)
- [Options dynamiques depuis une API](#options-dynamiques-depuis-une-api)
- [Listes en cascade — dependsOn](#listes-en-cascade--dependson)
- [Recherche distante (typeahead) — remoteSearch](#recherche-distante-typeahead--remotesearch)
- [Filtre des options API — optionsFilter](#filtre-des-options-api--optionsfilter)
- [Validation](#validation)
- [Validation par pattern — regex](#validation-par-pattern--regex)
- [Vérification d'unicité — uniqueCheck](#vérification-dunicité--uniquecheck)
- [Affichage conditionnel — showWhen](#affichage-conditionnel--showwhen)
- [Auto-remplissage depuis une API — lookup](#auto-remplissage-depuis-une-api--lookup)
- [Required conditionnel — requiredWhen](#required-conditionnel--requiredwhen)
- [Validation croisée — maxField / minField](#validation-croisée--maxfield--minfield)
- [Au moins un champ obligatoire — atLeastOneOf](#au-moins-un-champ-obligatoire--atleastoneof)
- [Guard de step — beforeNext](#guard-de-step--beforenext)
- [Icônes (Material Icons)](#icônes-material-icons)
- [Disposition en grille (col)](#disposition-en-grille-col)
- [API du composant DynamicForm](#api-du-composant-dynamicform)
- [Formulaires multi-étapes — Stepper](#formulaires-multi-étapes--stepper)
- [provideNgxForms — configuration globale](#providengxforms--configuration-globale)
- [FormConfigService — gestion des schémas](#formconfigservice--gestion-des-schémas)
- [Stockage — LocalStorage vs API REST](#stockage--localstorage-vs-api-rest)
- [Form Builder — éditeur visuel](#form-builder--éditeur-visuel)
- [Composants de navigation](#composants-de-navigation)
- [Intégration dans un composant](#intégration-dans-un-composant)
- [Pages de démonstration](#pages-de-démonstration)
- [Exemples complets](#exemples-complets)
- [Personnalisation CSS](#personnalisation-css)
- [Interfaces TypeScript](#interfaces-typescript)
- [Auteur](#auteur)

---

## Présentation

**@suntelecoms/ngx-dynamic-form** est un plugin Angular 19 qui génère des formulaires réactifs dynamiquement à partir d'un objet de configuration TypeScript. Il s'appuie sur **Angular Reactive Forms** et **Angular Material**.

Depuis la **v1.2.0**, le plugin embarque :
- Le **Form Builder visuel** (`<ngx-form-builder>`) — avec mode **Stepper**
- Le **gestionnaire de formulaires** (`<ngx-mes-formulaires>`)
- La **page de rendu par code** (`<ngx-form-loader>`)
- Un **système de stockage abstrait** (localStorage par défaut, API REST Spring en option)
- Les **formulaires multi-étapes** (`<ngx-stepper-form>`) avec étape de récapitulatif

Résultat : dans n'importe quel projet Angular, il suffit de **2 lignes** pour avoir une interface complète de paramétrage de formulaires.

---

## Fonctionnalités

- **25 types de champs** — `text`, `email`, `password`, `number`, `tel`, `url`, `date`, `datetime-local`, `time`, `month`, `week`, `textarea`, `select`, `multiselect`, `radio`, `checkbox`, `switch`, `range`, `color`, `location`, `file`, `phone`, `hidden`, `divider`, `heading`
- **3 variantes d'apparence** — `outline`, `fill`, `simple` (label fixe au-dessus) — globale ou par champ _(v1.2.7)_
- **Datepicker Material** (`mat-datepicker`) pour le type `date` _(v1.2.8)_
- **Géolocalisation** — type `location` avec bouton GPS et API `navigator.geolocation` _(v1.2.8)_
- **Curseur de valeur** — type `range` rendu en `mat-slider` _(v1.2.8)_
- **Interrupteur** — type `switch` rendu en `mat-slide-toggle` _(v1.2.8)_
- **Styling par champ** — `fontSize`, `textAlign`, `color`, `backgroundColor`, `borderColor`, `labelColor`, `fontWeight`, `fontStyle`, `cssClass`, `styles` _(v1.2.7)_
- **Masque de saisie** — propriété `mask` (`#` = chiffre, autres chars = séparateurs) _(v1.2.7)_
- **Préfixe / suffixe texte** — propriétés `prefix` et `suffix` _(v1.2.7)_
- **Code unique par formulaire** — propriété `code` saisie par l'utilisateur, utilisée pour l'URL de partage et la récupération par code _(v1.2.9)_
- **Authentification Bearer token** — `auth` dans `provideNgxForms` + intercepteur `ngxFormsAuthInterceptor` pour les APIs sécurisées (login/mot de passe → JWT) _(v1.3.0)_
- **Floating label** Angular Material (`mat-form-field appearance="outline"`)
- **Validation déclarative** — `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, validateur personnalisé
- **Messages d'erreur personnalisés** par champ
- **Champ téléphone international** — type `phone`, sélecteur de pays (menu avec recherche, apparence Material native) + masque automatique, valeur E.164 dans le formulaire, 135 pays préconfigurés _(v1.4.0, sélecteur revu en v1.4.7)_ ; **valeur existante affichée en édition** — pays déduit de l'indicatif, numéro local masqué _(v1.7.20)_
- **Vérification d'unicité asynchrone** — propriété `uniqueCheck` sur un champ, appel API debounced (400 ms), auto-détection enveloppe SUNTELECOMS `{ data: boolean }` _(v1.4.0)_
- **Affichage conditionnel** (`showWhen`) avec 7 opérateurs
- **Logique ET / OU sur conditions multiples** — propriété `conditionLogic: 'AND' | 'OR'`, configurable depuis le Builder _(v1.4.0)_
- **Grille 12 colonnes** — propriété `col`
- **Icônes Material Icons** — préfixe et suffixe par champ
- **Préremplissage** via `[initialValues]`
- **Mode lecture seule / désactivé** par champ
- **Sections et séparateurs** (`heading`, `divider`)
- **API publique** — `getForm()`, `patchValues()` via `@ViewChild`
- **Événements** — `formSubmit`, `formChange`, `formReset`
- **Formulaires multi-étapes (Stepper)** — `<ngx-stepper-form>` avec navigation, validation par étape et récapitulatif automatique _(v1.2.0)_
- **Form Builder visuel** — interface 3 panneaux, supporte les modes Formulaire et Stepper _(v1.2.0)_
- **Options dynamiques depuis une API** — propriétés `url`, `displayName`, `displayValue` sur les champs `select`, `multiselect`, `radio` _(v1.2.5)_, avec extraction automatique des réponses enveloppées (`{ data: [...] }`) ou via `responseDataKey` _(v1.4.4)_
- **Listes en cascade** — propriété `dependsOn` : un champ recharge ses options (statiques ou distantes, avec interpolation `{{champ}}` dans `url`) quand un autre champ change _(v1.4.5)_
- **Stockage abstrait** — localStorage ou API REST Spring
- **FormConfigService** — Signal-based, Observable API
- **Auto-remplissage depuis une API** (`lookup`) — sur `blur`, requête GET/POST → remplit des champs cibles via une map `populate` en notation pointée _(v1.6.0)_
- **Required conditionnel** (`requiredWhen`) — `Validators.required` activé dynamiquement selon une `FieldCondition`, sans code impératif _(v1.6.0)_
- **Validation croisée min/max** (`maxField` / `minField`) — erreur posée sur le `FormControl` cible si la valeur dépasse/est inférieure à celle d'un autre champ _(v1.6.0)_
- **Au moins un obligatoire** (`atLeastOneOf`) — validateur de groupe sur `DynamicFormConfig` et `FormStep` _(v1.6.0)_
- **Guard de step asynchrone** (`beforeNext`) — appel HTTP avant l'avancement ; HTTP 4xx bloque l'étape et affiche le message d'erreur du serveur _(v1.6.0)_
- **Événement `fieldChange`** — émis par `DynamicFormComponent` et `StepperFormComponent` à chaque changement de champ, avec `{ key, value, form }` pour piloter la logique métier depuis l'hôte _(v1.6.0)_
- **Copie automatique de champ** (`copyFrom`) — propriété sur `DynamicFormField` : quand le champ source change, la valeur est copiée automatiquement dans ce champ sans code impératif dans l'hôte _(v1.7.2)_
- **Valeur par défaut** (`defaultValue`) — initialise le champ avec une valeur arbitraire à l'ouverture du formulaire _(v1.7.2)_
- **Form Builder complet** — le builder visuel expose désormais toutes les propriétés de `DynamicFormField` : `defaultValue`, `disabled`, `hidden`, `copyFrom`, `responseDataKey`, `dependsOn`/`dependsOnMode`/`dependsOnItemKey`/`dependsOnOffset`, `minDate`/`maxDate`, `lookup.triggerOn` _(v1.7.3)_
- **Recherche distante (typeahead)** — propriété `remoteSearch` sur un champ `select` : au lieu de charger toutes les options via `url`, affiche un `mat-autocomplete` qui interroge `url` avec le texte tapé (debounce, `minChars` avant la première requête, params statiques additionnels) — pour les listes trop grandes pour être chargées entièrement (ex : recherche client) _(v1.7.5)_
- **Locale du datepicker configurable** — le type `date` affiche `DD/MM/YYYY` par défaut (`fr-FR`, calendrier et noms de mois en français) au lieu du format US du navigateur ; personnalisable par champ (`dateLocale`) ou globalement (`provideNgxForms({ dateLocale })`) _(v1.7.6)_
- **Recherche dans « Mes Formulaires »** — filtre les formulaires sauvegardés par titre ou code, utile dès que le catalogue grandit _(v1.7.9)_
- **Filtre des options API** — propriété `optionsFilter` sur un champ `select`/`multiselect`/`radio` chargé via `url` : ne garde que les items dont une propriété correspond à une condition (ex: `status === 'ACTIF'`), avec logique ET/OU sur plusieurs conditions — même mécanique que `showWhen`, mais évaluée contre les propriétés brutes de chaque item API _(v1.7.10)_
- **Champ fichier fonctionnel** — le type `file` est maintenant relié au formulaire (dropzone clic/glisser-déposer, liste de fichiers choisis, `formControl.value` = `File`/`File[]`) ; avant v1.7.11 l'`<input type="file">` n'était pas connecté, aucune valeur ne remontait _(v1.7.11)_
- **Code ISO pays** — type `iso-country` : liste déroulante recherchable (nom ou code) avec drapeau, même référentiel que `country-dial-code` — garantit un code toujours valide au lieu d'une saisie libre _(v1.7.12, converti en liste déroulante en v1.7.14)_
- **Montants avec séparateur de milliers** — `thousandSeparator` sur un champ `number` : saisie et affichage « 20 000 », « 1 250 000,5 » (reformaté à la frappe, curseur conservé), valeur numérique `20000` dans le FormControl — validateurs `min`/`max`/`maxField` et payload inchangés ; case à cocher dans le Form Builder _(v1.7.21)_
- **Options statiques en cascade dans le builder** — pour une liste à options fixes, le builder propose « Dépend de » (`dependsOn`) et, sur chaque option, la valeur du champ parent pour laquelle elle est proposée (`dependsOnValue`, liste déroulante si le parent a lui-même des options fixes). À l'exécution, la comparaison se fait en texte et une valeur sélectionnée qui n'est plus proposée est effacée _(v1.7.22)_
- **Dates au calendrier uniquement** — le type `date` ne se saisit plus au clavier : un clic ouvre le calendrier (Material ou sélecteur natif en apparence `simple`), ce qui évite aussi la lecture US MM/JJ d'une date tapée _(v1.7.19)_
- **Période de dates** — `maxField` / `minField` sur les champs `date` (et `datetime-local`, `month`, `week`, `time`) : date de début ≤ date de fin, dates hors période grisées dans le calendrier, configurable dans le Form Builder _(v1.7.18)_
- **Label composé** — `displayName` accepte un chemin pointé (`personnePhysique.nom`) ou un modèle de concaténation (`'{{codeClient}} - {{telephone}}'`) pour les options chargées via `url` (y compris `remoteSearch`) _(v1.7.17)_
- **Indicatif téléphonique pays** — nouveau type `country-dial-code` : recherche de pays (nom/indicatif/code) avec drapeau, stocke uniquement l'indicatif choisi (ex: `'+221'`) comme attribut autonome, réutilise le référentiel du type `phone` _(v1.7.13)_

---

## Installation

### 1. Prérequis

- Angular **19+**
- Node.js **18+**

### 2. Installer les dépendances

```bash
npm install @suntelecoms/ngx-dynamic-form @angular/material@^19.0.0 @angular/cdk@^19.0.0
```

### 3. Ajouter le thème Material et les icônes

**angular.json :**
```json
"styles": [
  "@angular/material/prebuilt-themes/azure-blue.css",
  "src/styles.css"
]
```

**index.html :**
```html
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
```

### 4. Configurer le plugin (app.config.ts)

```typescript
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideNgxForms } from '@suntelecoms/ngx-dynamic-form';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideAnimations(),
    provideNgxForms({ storage: 'local' }),  // ← stockage localStorage
  ],
};
```

### 5. Ajouter les routes (app.routes.ts)

```typescript
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'builder',
    loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.FormBuilderComponent),
  },
  {
    path: 'mes-formulaires',
    loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.MesFormulairesComponent),
  },
  {
    path: 'form/:code',   // ← le formulaire est identifié par son code unique
    loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.FormLoaderComponent),
  },
];
```

**C'est tout.** Aucun code supplémentaire à écrire — le Builder, la liste des formulaires et la page de rendu sont entièrement fournis par le plugin.

---

## Démarrage rapide — formulaire statique

Utilisez `<ngx-dynamic-form>` directement avec une config TypeScript :

```typescript
import { Component } from '@angular/core';
import { DynamicFormComponent } from '@suntelecoms/ngx-dynamic-form';
import type { DynamicFormConfig, DynamicFormSubmitEvent } from '@suntelecoms/ngx-dynamic-form';

@Component({
  selector: 'app-contact',
  standalone: true,
  imports: [DynamicFormComponent],
  template: `
    <ngx-dynamic-form [config]="config" (formSubmit)="onSubmit($event)" />
  `,
})
export class ContactComponent {
  config: DynamicFormConfig = {
    submitLabel: 'Envoyer',
    showReset: true,
    fields: [
      { key: 'name',  type: 'text',  label: 'Nom complet', col: 6, icon: 'person',
        validation: { required: true } },
      { key: 'email', type: 'email', label: 'E-mail',      col: 6, icon: 'email',
        validation: { required: true, email: true } },
      { key: 'message', type: 'textarea', label: 'Message', rows: 5,
        validation: { required: true } },
    ],
  };

  onSubmit(event: DynamicFormSubmitEvent): void {
    console.log(event.value);
  }
}
```

---

## Démarrage rapide — stack complète

Pour disposer du **Form Builder + gestionnaire + rendu par URL** dans votre application, il suffit des 2 fichiers de configuration vus à l'installation. Après `ng serve` :

| URL | Page |
|---|---|
| `/builder` | Interface graphique de création de formulaires (simple ou stepper) |
| `/mes-formulaires` | Liste des formulaires sauvegardés, avec rendu à la volée |
| `/form/mon-code` | Rendu d'un formulaire sauvegardé par son code unique |

---

## Types de champs

### Saisie texte

| Type | Rendu | Description |
|---|---|---|
| `text` | `<input type="text">` | Champ texte standard |
| `email` | `<input type="email">` | Validation e-mail intégrée |
| `password` | `<input type="password">` | Mot de passe masqué |
| `number` | `<input type="number">` | Supporte `min`, `max` |
| `tel` | `<input type="tel">` | Téléphone — compatible masque de saisie |
| `url` | `<input type="url">` | URL |
| `textarea` | `<textarea>` | Zone de texte multi-lignes, propriété `rows` |

### Date & Heure

| Type | Rendu | Description |
|---|---|---|
| `date` | `mat-datepicker` | Calendrier Material — popup avec sélection mois/année _(v1.2.8)_ ; `minDate`/`maxDate` pour borner les dates sélectionnables, `'today'` ou une date ISO _(v1.6.6)_ ; affichage `DD/MM/YYYY` (locale `fr-FR`) par défaut, personnalisable par champ (`dateLocale`) ou globalement (`provideNgxForms({ dateLocale })`) _(v1.7.6)_ ; **sélection au calendrier uniquement** — saisie clavier bloquée, un clic sur le champ ouvre le calendrier, le bouton calendrier reste accessible au clavier (Tab puis Entrée) _(v1.7.19)_ |
| `datetime-local` | `<input type="datetime-local">` | Date et heure combinées |
| `time` | `<input type="time">` | Heure (HH:mm) |
| `month` | `<input type="month">` | Mois et année |
| `week` | `<input type="week">` | Semaine ISO |

### Choix

| Type | Rendu | Description |
|---|---|---|
| `select` | `<mat-select>` | Liste déroulante — 1 choix — options statiques ou API |
| `multiselect` | `<mat-select multiple>` | Liste déroulante — plusieurs choix |
| `radio` | `<mat-radio-group>` | Boutons radio — options statiques ou API |
| `checkbox` | `<mat-checkbox>` | Case à cocher booléenne |
| `switch` | `<mat-slide-toggle>` | Interrupteur on/off _(v1.2.8)_ |

### Spéciaux

| Type | Rendu | Description |
|---|---|---|
| `range` | `<mat-slider>` | Curseur de valeur — propriétés `min`, `max`, `step`, `showValue` _(v1.2.8)_ |
| `color` | `<input type="color">` | Sélecteur de couleur avec affichage du code hex _(v1.2.8)_ |
| `location` | `<input>` + bouton GPS | Champ adresse avec géolocalisation — bouton `my_location` déclenche `navigator.geolocation` _(v1.2.8)_ |
| `file` | Dropzone (clic ou glisser-déposer) + liste de fichiers choisis | Téléversement — `accept`, `multiple` ; `formControl.value` = `File` (ou `File[]` si `multiple`), à empaqueter en `FormData` par le consommateur dans `onSubmit` — le plugin ne fait aucun appel HTTP lui-même _(fonctionnel depuis v1.7.11 — avant cette version l'`<input>` n'était pas relié au formulaire)_ |
| `phone` | `mat-form-field` + menu pays | Téléphone international — sélecteur de pays (menu + recherche), masque automatique, valeur E.164 dans le FormGroup _(v1.4.0, v1.4.7)_ ; en édition (`initialValues`, `patchValue`), la valeur existante s'affiche : `'+22222123456'` ou `'0022222123456'` → pays déduit de l'indicatif (le plus long l'emporte, ex. `+1242` avant `+1`) + numéro local masqué ; sans indicatif (`'774127793'`), le pays `defaultCountry` est gardé _(v1.7.20)_ |
| `iso-country` | `mat-autocomplete` — recherche pays | Code ISO 3166-1 alpha-2 : liste déroulante recherchable (nom ou code) avec drapeau SVG (`flag-icons`, même rendu que `phone` — pas d'emoji, repli en deux lettres nues sur certains Windows/webviews), sur le même référentiel `PHONE_COUNTRIES` que `country-dial-code` (son `.code` EST le code ISO) — garantit un code toujours valide, pas de saisie libre ; validation `^[A-Za-z]{2}$` conservée en filet de sécurité si une valeur est posée par programmation _(v1.7.12 en saisie libre, converti en liste déroulante en v1.7.14, drapeau SVG en v1.7.15)_ |
| `country-dial-code` | `mat-autocomplete` — recherche pays | Choix de l'indicatif téléphonique d'un pays comme attribut autonome (ex: `Pays.indicatifTel = '+221'`) — distinct de `phone` (qui saisit un numéro complet E.164) ; recherche par nom, indicatif ou code sur le même référentiel `PHONE_COUNTRIES`, drapeau SVG (`flag-icons`, même rendu que `phone`) dans chaque résultat, `formControl.value` = l'indicatif choisi (ex: `'+221'`) _(v1.7.13, drapeau SVG en v1.7.15)_ |

### Mise en page

| Type | Rendu | Description |
|---|---|---|
| `hidden` | `<input type="hidden">` | Champ caché (présent dans le FormGroup) |
| `divider` | `<hr>` | Séparateur horizontal |
| `heading` | `<h1>` à `<h6>` | Titre de section — propriétés `text`, `level` |

---

## Configuration — DynamicFormConfig

```typescript
interface DynamicFormConfig {
  fields: DynamicFormField[];     // Liste des champs (obligatoire)
  submitLabel?: string;           // Libellé du bouton Envoyer (défaut : "Envoyer")
  resetLabel?: string;            // Libellé du bouton Réinitialiser (défaut : "Réinitialiser")
  showReset?: boolean;            // Afficher le bouton Réinitialiser (défaut : false)
  layout?: 'vertical' | 'horizontal' | 'inline';
  cssClass?: string;
  debug?: boolean;                // Affiche les valeurs JSON en temps réel
  // Apparence globale ← v1.2.7
  appearance?: 'outline' | 'fill' | 'simple'; // défaut : 'outline'
  floatLabel?: 'auto' | 'always';             // défaut : 'auto'
  // Validation de groupe ← v1.6.0
  atLeastOneOf?: string[];        // Au moins un des champs listés doit être renseigné
  atLeastOneOfMessage?: string;   // Message d'erreur (défaut : 'Au moins un champ est requis.')
}
```

---

## Propriétés d'un champ — DynamicFormField

```typescript
interface DynamicFormField {
  // Obligatoire
  key: string;                    // Identifiant unique (clé dans FormGroup)
  type: FieldType;

  // Affichage
  label?: string;
  placeholder?: string;
  hint?: string;
  icon?: string;                  // Icône préfixe (Material Icons)
  iconSuffix?: string;            // Icône suffixe (Material Icons)
  prefix?: string;                // Texte préfixe dans le champ (ex : '+221')  ← v1.2.7
  suffix?: string;                // Texte suffixe dans le champ (ex : 'FCFA')  ← v1.2.7

  // Valeur & état
  defaultValue?: any;
  disabled?: boolean;
  readonly?: boolean;
  hidden?: boolean;

  // Options statiques (select / multiselect / radio)
  options?: SelectOption[];

  // Options dynamiques chargées depuis une API  ← v1.2.5
  url?: string;          // endpoint relatif ('villes'), absolu ('https://...'),
                          // ou racine-relatif ('/autre-service/api/...') ← v1.4.4
  displayName?: string;  // label : clé, chemin pointé ou modèle '{{codeClient}} - {{telephone}}' (défaut : 'label')
  displayValue?: string; // propriété de la réponse API utilisée comme valeur (défaut : 'value')
  responseDataKey?: string; // clé du tableau si la réponse est enveloppée, ex: 'data'
                             // (défaut : essaie 'data' automatiquement si la réponse
                             // n'est pas déjà un tableau)  ← v1.4.4

  // Recherche distante : remplace le chargement eager de `url` par un typeahead
  // debouncé — voir Recherche distante (typeahead) — remoteSearch  ← v1.7.5
  remoteSearch?: RemoteSearchConfig; // type: 'select' uniquement

  // Filtre les items reçus de `url` avant mapping — voir Filtre des options API — optionsFilter  ← v1.7.10
  optionsFilter?: FieldCondition | FieldCondition[];
  optionsFilterLogic?: 'AND' | 'OR'; // défaut 'AND'

  // Copie automatique depuis un autre champ  ← v1.7.2
  copyFrom?: string; // clé du champ source — quand sa valeur change, elle est copiée ici automatiquement

  // Liste en cascade : recharge ses options quand le champ `dependsOn` change  ← v1.4.5
  dependsOn?: string;         // clé d'un autre champ du formulaire
  dependsOnMode?: 'server' | 'client'; // défaut 'server' — voir Listes en cascade  ← v1.4.6
  dependsOnItemKey?: string;  // mode 'client' : propriété de l'item à comparer   ← v1.4.6
  dependsOnOffset?: number;   // mode 'client' : décalage numérique avant comparaison ← v1.4.6

  // Validation
  validation?: FieldValidation;

  // Disposition
  col?: 1|2|3|4|6|8|9|12;       // Colonnes sur 12 (défaut : 12)
  cssClass?: string;             // Classe CSS ajoutée sur l'hôte  ← v1.2.7

  // Affichage conditionnel
  showWhen?: FieldCondition | FieldCondition[];
  conditionLogic?: 'AND' | 'OR';  // Logique entre conditions multiples (défaut : 'AND')  ← v1.4.0

  // Vérification d'unicité asynchrone  ← v1.4.0
  uniqueCheck?: {
    url: string;      // Endpoint POST — reçoit { value } — doit retourner boolean ou { data: boolean }
    message?: string; // Message d'erreur (défaut : 'Cette valeur existe déjà.')
  };

  // Auto-remplissage depuis une API  ← v1.6.0
  lookup?: FieldLookup;

  // Spécifique number — montant « 20 000 » (valeur 20000)  ← v1.7.21
  thousandSeparator?: boolean | string; // true = espace, ou le séparateur voulu (ex: '.')

  // Spécifique phone  ← v1.4.0
  defaultCountry?: string; // Code ISO 3166-1 alpha-2 (ex : 'FR', 'SN') — défaut : 'FR'

  // Spécifique file
  accept?: string;                // Ex : ".pdf,.jpg"
  multiple?: boolean;

  // Spécifique textarea
  rows?: number;                  // Défaut : 3

  // Spécifique date  ← v1.6.6
  minDate?: string;               // 'today', ou une date ISO ('2026-01-01')
  maxDate?: string;               // idem — borne le mat-datepicker et l'input natif (apparence simple)
  dateLocale?: string;            // Locale d'affichage (ex: 'fr-FR' → 25/08/2026) — remplace la
                                   // locale globale de provideNgxForms. Défaut global : 'fr-FR'  ← v1.7.6
  // Exemple — une date de mise en circulation ne peut pas être dans le futur :
  // { key: 'dateMiseCirculation', type: 'date', label: 'Date de mise en circulation', maxDate: 'today' }
  // Une date de rendez-vous ne peut pas être dans le passé :
  // { key: 'dateRdv', type: 'date', label: 'Date du rendez-vous', minDate: 'today' }

  // Spécifique heading
  text?: string;
  level?: 1|2|3|4|5|6;          // Défaut : 2

  // Spécifique range  ← v1.2.8
  min?: number;                   // Valeur minimale du curseur (défaut : 0)
  max?: number;                   // Valeur maximale du curseur (défaut : 100)
  step?: number;                  // Pas du curseur (défaut : 1)
  showValue?: boolean;            // Afficher la valeur courante à côté du curseur

  // Masque de saisie  ← v1.2.7
  // '#' = chiffre obligatoire, tout autre caractère = séparateur littéral
  // Ex : '## ### ## ##' → téléphone SN, '##/##/####' → date, '#### #### #### ####' → CB
  mask?: string;

  // Apparence individuelle (override la valeur globale du formulaire)  ← v1.2.7
  appearance?: 'outline' | 'fill' | 'simple';
  floatLabel?: 'auto' | 'always';

  // Style & Apparence  ← v1.2.7 / v1.2.8
  fontSize?: string;                        // Ex : '14px', '1.1rem'
  textAlign?: 'left' | 'center' | 'right';
  color?: string;                           // Couleur du texte saisi
  backgroundColor?: string;                 // Couleur de fond du champ
  borderColor?: string;                     // Couleur de la bordure  ← v1.2.8
  labelColor?: string;                      // Couleur du label       ← v1.2.8
  fontWeight?: 'normal' | 'bold';
  fontStyle?: 'normal' | 'italic';
  styles?: Record<string, string>;          // Styles CSS inline supplémentaires

  attrs?: Record<string, any>;
}
```

### SelectOption

```typescript
interface SelectOption {
  label: string;
  value: any;
  disabled?: boolean;
  group?: string;
  dependsOnValue?: any; // valeur du champ `dependsOn` pour laquelle cette option apparaît ← v1.4.5
                        // (comparée en texte : "3" = 3, "MOIS" = 'MOIS' — v1.7.22)
}
```

---

## Options dynamiques depuis une API

> **Nouveauté v1.2.5**

Pour les champs `select`, `multiselect` et `radio`, les options peuvent être chargées automatiquement depuis une API REST au lieu d'être définies statiquement. Il suffit d'utiliser `url` à la place de `options`.

### Comment ça fonctionne

1. Le composant détecte la propriété `url` sur le champ
2. Il effectue un `GET` sur l'URL complète (`dataUrl` + `url`)
3. Si la réponse n'est pas déjà un tableau, il essaie de l'extraire depuis `responseDataKey` (ou `'data'` par défaut) — voir [Réponse API attendue](#réponse-api-attendue) ← v1.4.4
4. Il mappe chaque objet du tableau en `{ label, value }` en utilisant `displayName` et `displayValue`
5. Pendant le chargement, le select affiche **"Chargement…"** avec un spinner

### Configuration dans `app.config.ts`

```typescript
import { provideHttpClient } from '@angular/common/http';
import { provideNgxForms } from '@suntelecoms/ngx-dynamic-form';

providers: [
  provideHttpClient(),                    // requis pour les options depuis API
  provideNgxForms({
    storage: 'local',
    dataUrl: 'https://api.monprojet.com', // base URL pour les options
  }),
]
```

### Exemples

**Options depuis une API (URL relative) :**
```typescript
{
  key: 'ville',
  type: 'select',
  label: 'Ville',
  col: 6,
  icon: 'location_city',
  url: 'villes',           // → GET https://api.monprojet.com/villes
  displayName: 'nom',      // propriété affichée dans la liste
  displayValue: 'id',      // propriété utilisée comme valeur du formulaire
  validation: { required: true },
}
```

**Options depuis une API (URL absolue) :**
```typescript
{
  key: 'compte',
  type: 'select',
  label: 'Compte',
  col: 6,
  url: 'https://autre-api.com/comptes', // URL absolue — dataUrl ignoré
  displayName: 'numero',
  displayValue: 'numero',
  validation: { required: true },
}
```

**Options depuis un backend différent, même origine (chemin racine-relatif) :** ← v1.4.4
```typescript
{
  key: 'role',
  type: 'select',
  label: 'Rôle',
  col: 12,
  // Commence par '/' → traité comme dataUrl : résolu par le navigateur
  // contre l'origine courante (donc via le même proxy/reverse-proxy déjà
  // configuré pour cette autre API), sans coder en dur un hôte complet
  // qui ne serait correct que dans un seul environnement.
  url: '/tp-security-service/api/roles?size=1000&page=0',
  responseDataKey: 'data',
  displayName: 'nom',
  displayValue: 'nom',
  validation: { required: true },
}
```

**Multi-sélection depuis une API :**
```typescript
{
  key: 'categories',
  type: 'multiselect',
  label: 'Catégories',
  col: 12,
  url: 'categories',
  displayName: 'libelle',
  displayValue: 'code',
}
```

### Réponse API attendue

L'API peut retourner soit un **tableau JSON** brut :

```json
[
  { "id": 1, "nom": "Dakar" },
  { "id": 2, "nom": "Thiès" },
  { "id": 3, "nom": "Saint-Louis" }
]
```

soit une réponse **enveloppée** (convention courante des API paginées : `{ data, total, ... }`) :

```json
{
  "data": [
    { "id": 1, "nom": "Dakar" },
    { "id": 2, "nom": "Thiès" },
    { "id": 3, "nom": "Saint-Louis" }
  ],
  "total": 3
}
```

Dans ce second cas, le tableau est automatiquement extrait depuis la clé `'data'` — aucune configuration requise. Si l'API utilise une autre clé (`'items'`, `'results'`, `'list'`…), indiquez-la explicitement avec `responseDataKey` ← v1.4.4 :

```typescript
{
  key: 'ville',
  type: 'select',
  url: 'villes',
  responseDataKey: 'items', // → extrait response.items au lieu de response.data
  displayName: 'nom',
  displayValue: 'id',
}
```

Avec `displayName: 'nom'` et `displayValue: 'id'`, le select affichera `Dakar`, `Thiès`, `Saint-Louis` et soumettra `1`, `2`, `3`.

### Résumé des propriétés

| Propriété | Type | Description |
|---|---|---|
| `url` | `string` | Endpoint relatif (`'villes'`), absolu (`'https://...'`) ou racine-relatif (`'/autre-service/...'`, résolu contre l'origine courante) ← v1.4.4. Quand défini, `options` est ignoré. |
| `displayName` | `string` | Propriété de l'objet API utilisée comme **label** affiché (défaut : `'label'`). Accepte un chemin pointé (`'personnePhysique.nom'`) ou un modèle de concaténation (`'{{codeClient}} - {{telephone}}'`) — voir [Label composé](#label-composé--displayname-concaténé) ← v1.7.17 |
| `displayValue` | `string` | Propriété de l'objet API utilisée comme **valeur** du formulaire (défaut : `'value'`) |
| `responseDataKey` | `string` | Clé du tableau si la réponse est enveloppée (défaut : essaie `'data'` automatiquement) ← v1.4.4 |
| `dependsOn` | `string` | Clé d'un autre champ : recharge les options quand sa valeur change — voir [Listes en cascade](#listes-en-cascade--dependson) ← v1.4.5 |
| `dependsOnMode` | `'server' \| 'client'` | Défaut `'server'` (re-fetch de `url` à chaque changement). `'client'` : charge `url` une seule fois puis filtre localement ← v1.4.6 |
| `dependsOnItemKey` | `string` | Mode `'client'` : propriété de chaque item API comparée à la valeur de `dependsOn` (défaut : la clé de `dependsOn`) ← v1.4.6 |
| `dependsOnOffset` | `number` | Mode `'client'` : décalage numérique ajouté à la valeur de `dependsOn` avant comparaison ← v1.4.6 |
| `remoteSearch` | `RemoteSearchConfig` | Transforme un `select` en typeahead distant au lieu de charger `url` intégralement — voir [Recherche distante (typeahead) — remoteSearch](#recherche-distante-typeahead--remotesearch) ← v1.7.5 |
| `optionsFilter` | `FieldCondition \| FieldCondition[]` | Ne garde que les items API dont une propriété correspond à une condition (ex: `status === 'ACTIF'`) — voir [Filtre des options API — optionsFilter](#filtre-des-options-api--optionsfilter) ← v1.7.10 |

> **Note :** `url` et `options` sont mutuellement exclusifs. Si `url` est défini, `options` est ignoré.
>
> Si la réponse n'est ni un tableau, ni un objet contenant un tableau à la clé attendue, les options restent vides et un avertissement est loggé dans la console (`[ngx-dynamic-form] ...`).

### Label composé — displayName concaténé

_(v1.7.17)_ `displayName` peut combiner plusieurs propriétés de l'item API, y compris imbriquées, avec la syntaxe `{{chemin}}` (la même que l'interpolation de `url`) :

```typescript
// Item API : { id: '37d2…', codeClient: 'CLI-PP-3F2247FB', telephone: '+221771234567',
//              personnePhysique: { nom: 'Diop', prenom: 'Amadou' }, personneMorale: null }

displayName: 'codeClient'                                          // → CLI-PP-3F2247FB (inchangé)
displayName: 'personnePhysique.nom'                                // → Diop
displayName: '{{codeClient}} - {{telephone}}'                      // → CLI-PP-3F2247FB - +221771234567
displayName: '{{personnePhysique.nom}} {{personnePhysique.prenom}}' // → Diop Amadou
```

- Une valeur absente (`null`/`undefined`) est remplacée par une chaîne vide et les espaces en trop sont réduits. On peut donc enchaîner des alternatives pour une liste mixte personnes physiques / morales : `'{{codeClient}} - {{personnePhysique.nom}} {{personnePhysique.prenom}}{{personneMorale.raisonSociale}}'`.
- Fonctionne pour `select`, `multiselect`, `radio`, `dependsOnMode: 'client'` et `remoteSearch`.
- `displayValue` reste une clé simple.

---

## Listes en cascade — dependsOn

> **Nouveauté v1.4.5**

Certains champs `select`/`multiselect`/`radio` doivent proposer des options différentes selon la valeur choisie dans un **autre** champ (ex : le champ « Rattaché à » d'un privilège ne doit proposer que les privilèges du niveau immédiatement inférieur au « Niveau » choisi). C'est le rôle de `dependsOn` : tant que le champ référencé n'a pas de valeur, les options restent vides ; à chaque changement de sa valeur, elles sont recalculées (statique) ou rechargées (`url`).

### Options statiques dépendantes

Chaque option statique concernée déclare la valeur de `dependsOn` pour laquelle elle doit apparaître, via `dependsOnValue` :

```typescript
{
  key: 'niveau',
  type: 'select',
  label: 'Niveau',
  col: 6,
  options: [
    { label: '1', value: 1 },
    { label: '2', value: 2 },
    { label: '3', value: 3 },
  ],
  validation: { required: true },
},
{
  key: 'parent',
  type: 'select',
  label: 'Rattaché à',
  col: 6,
  dependsOn: 'niveau',
  options: [
    { label: 'Tableau de bord',  value: 10, dependsOnValue: 2 },
    { label: 'Rapports',         value: 11, dependsOnValue: 2 },
    { label: 'Rapports mensuels', value: 20, dependsOnValue: 3 },
  ],
}
```

Ici, choisir `niveau = 2` ne propose que « Tableau de bord » et « Rapports » ; choisir `niveau = 3` ne propose que « Rapports mensuels ».

#### Comportement _(v1.7.22)_

- **Comparaison en texte** : `dependsOnValue` est comparé à la valeur du champ parent après conversion en chaîne — `"2"` (saisi dans le Form Builder) correspond donc à `2`, `"MOIS"` à `'MOIS'`.
- **Valeur devenue invalide effacée** : si la valeur sélectionnée n'est plus proposée après un changement du champ parent, elle est remise à `null` (pour un `multiselect`, seules les valeurs encore proposées sont conservées). Une valeur présente dans les deux listes est conservée.
- Tant que le champ parent est vide, la liste reste vide.

#### Configurer depuis le Form Builder _(v1.7.22)_

Plus besoin d'éditer le JSON : sur un champ `select` / `multiselect` / `radio` en options **Statiques** :

1. Choisir le champ parent dans **« Dépend de »** (`dependsOn`).
2. Pour chaque option, renseigner la colonne **« Si ‹parent› = »** (`dependsOnValue`) :
   - si le champ parent a lui-même des **options statiques**, c'est une liste déroulante de ses valeurs ;
   - sinon (parent texte, nombre, liste API…), c'est une saisie libre.
3. Une option sans valeur « Si ‹parent› = » n'est jamais proposée tant que `dependsOn` est renseigné.

Exemple — durée d'un contrat selon la périodicité :

```json
{
  "key": "periode",
  "type": "select",
  "label": "Périodicité",
  "options": [
    { "label": "Mensuelle",   "value": "MOIS" },
    { "label": "Journalière", "value": "JOUR" }
  ],
  "defaultValue": "MOIS"
},
{
  "key": "periodeValeur",
  "type": "select",
  "label": "Durée",
  "dependsOn": "periode",
  "options": [
    { "label": "3 mois",   "value": "3",  "dependsOnValue": "MOIS" },
    { "label": "6 mois",   "value": "6",  "dependsOnValue": "MOIS" },
    { "label": "12 mois",  "value": "12", "dependsOnValue": "MOIS" },
    { "label": "30 jours", "value": "30", "dependsOnValue": "JOUR" },
    { "label": "20 jours", "value": "20", "dependsOnValue": "JOUR" },
    { "label": "10 jours", "value": "10", "dependsOnValue": "JOUR" },
    { "label": "3 jours",  "value": "3",  "dependsOnValue": "JOUR" }
  ],
  "validation": { "required": true }
}
```

« Mensuelle » ne propose que 3, 6 et 12 mois ; « Journalière » que 30, 20, 10 et 3 jours. Passer de « 12 mois » à « Journalière » efface la durée ; « 3 » est conservé (présent dans les deux listes).

> Les valeurs saisies dans le Form Builder sont des chaînes (`"3"`). Si le backend attend un nombre, il les convertit en général lui-même ; sinon, convertissez dans l'hôte avant l'envoi.

### Options distantes dépendantes

Pour des options chargées depuis une API, `url` peut référencer la valeur courante de **n'importe quel** champ du formulaire via `{{cléDuChamp}}` — la requête est automatiquement relancée à chaque changement du champ `dependsOn` :

```typescript
{
  key: 'niveau',
  type: 'select',
  label: 'Niveau',
  col: 6,
  options: [
    { label: '1', value: 1 }, { label: '2', value: 2 }, { label: '3', value: 3 }, { label: '4', value: 4 },
  ],
  validation: { required: true },
},
{
  key: 'parent',
  type: 'select',
  label: 'Rattaché à',
  col: 6,
  dependsOn: 'niveau',
  url: '/api/privileges/select-options?niveau={{niveau}}', // → re-fetch à chaque changement de niveau
  responseDataKey: 'data',
  displayName: 'libelle',
  displayValue: 'id',
}
```

> **Note :** pour les options **distantes**, `dependsOn` ne modifie pas automatiquement la valeur déjà sélectionnée si elle devient invalide après un changement du champ parent — la validation du formulaire (`required`, etc.) s'en charge à la soumission. Pour les options **statiques**, la valeur invalide est effacée depuis v1.7.22 (voir ci-dessus).

### API distante qui ne filtre pas côté serveur — dependsOnMode: 'client'

> **Nouveauté v1.4.6**

Certaines API ne proposent pas de paramètre de filtre — le pattern classique côté client est alors de charger toute la liste **une seule fois**, puis de la filtrer localement à chaque changement du champ parent (c'est exactement ce que fait un formulaire écrit à la main avec `array.filter(...)` dans un handler `(selectionChange)`). `dependsOnMode: 'client'` reproduit ce pattern :

```typescript
{
  key: 'niveau',
  type: 'select',
  label: 'Niveau',
  col: 6,
  options: [
    { label: '1', value: 1 }, { label: '2', value: 2 }, { label: '3', value: 3 }, { label: '4', value: 4 },
  ],
  validation: { required: true },
},
{
  key: 'parent',
  type: 'select',
  label: 'Rattaché à',
  col: 6,
  dependsOn: 'niveau',
  dependsOnMode: 'client',       // charge tous les privilèges une seule fois
  url: '/tp-security-service/api/privileges?size=1000&page=0',
  responseDataKey: 'data',
  dependsOnItemKey: 'niveau',    // compare item.niveau à …
  dependsOnOffset: -1,           // … niveau - 1 (le niveau immédiatement inférieur)
  displayName: 'libelle',
  displayValue: 'id',
}
```

Équivalent à :
```typescript
// Chargé une seule fois, indépendamment de `niveau` :
const privileges = await fetchAll('/tp-security-service/api/privileges?...');
// Recalculé à chaque changement de `niveau` :
const options = privileges.filter(p => p.niveau === niveau - 1);
```

Sans `dependsOnOffset`, la comparaison est stricte (`item[dependsOnItemKey] === valeurDeDependsOn`) — utile quand l'item référence directement l'id du parent (ex : `item.paysId === pays` pour un couple pays/ville).

> **`server` vs `client` :** utilisez `server` (par défaut) quand l'API accepte un paramètre de filtre — une seule requête pertinente par changement. Utilisez `client` quand elle ne le fait pas — une seule requête au total, filtrage en mémoire ensuite (à réserver aux listes de taille raisonnable).

---

## Recherche distante (typeahead) — remoteSearch

> **Nouveauté v1.7.5**

Pour un champ `select` dont la liste d'options est trop grande pour être chargée entièrement (ex : recherche de client parmi plusieurs milliers), `remoteSearch` remplace le chargement eager de `url` par un **typeahead** : le rendu passe d'un `mat-select` classique à un `input` + `mat-autocomplete`, qui interroge `url` avec le texte tapé (debounce, requête annulée si l'utilisateur retape avant la réponse).

### Interface RemoteSearchConfig

```typescript
interface RemoteSearchConfig {
  paramName?: string;   // nom du query param recevant le texte tapé (défaut : 'q')
  minChars?: number;    // caractères minimum avant la première requête (défaut : 3)
  debounceMs?: number;  // délai après la dernière frappe (défaut : 300)
  params?: Record<string, string | number>; // params statiques additionnels, ex: { size: 20 }
}
```

### Exemple

```typescript
{
  key: 'clientId',
  type: 'select',
  label: 'Client',
  url: 'clients/recherche',       // → GET clients/recherche?q=<saisie>&page=0&size=20
  responseDataKey: 'content',     // réponse Spring Page : le tableau est sous "content"
  displayName: 'codeClient',
  displayValue: 'id',
  remoteSearch: {
    minChars: 3,
    params: { page: 0, size: 20 },
  },
  validation: { required: true },
}
```

- `url`, `displayName`, `displayValue` et `responseDataKey` sont **les mêmes propriétés** que pour un select classique chargé via API — `remoteSearch` ne fait que changer *quand* et *comment* `url` est appelée, pas comment la réponse est interprétée. Une API qui répond `{ data: [...] }` ou un tableau brut fonctionne donc aussi bien qu'une réponse `{ content: [...] }`.
- Si l'API attend un nom de param différent de `q` (ex : recherche par `telephone` ou par `nom`), indiquez-le via `paramName`.
- Aucune requête n'est envoyée tant que le texte tapé est plus court que `minChars` — évite les erreurs 400 des APIs qui imposent un minimum de caractères.
- En cas d'échec de la requête (réseau, 4xx/5xx), le comportement est identique à un select classique : erreur loggée en console, liste d'options vide — l'utilisateur peut retaper pour relancer une recherche.
- **Form Builder** _(v1.7.7)_ : une fois « Depuis une API » sélectionné pour un champ `select`, une case « Recherche à distance » expose `paramName`, `minChars` et `debounceMs` (`params` reste à définir en JSON brut pour l'instant, pas encore dans le Builder visuel).
- **Limites v1.7.5** : `multiselect` avec `remoteSearch` n'est pas supporté (uniquement `select`, un choix unique) ; en mode édition (valeur déjà présente dans le formulaire), le texte affiché initialement est la valeur brute du champ, faute d'un endpoint de résolution par id — pas de label pré-rempli tant que l'utilisateur n'a pas retapé une recherche.

---

## Filtre des options API — optionsFilter

> **Nouveauté v1.7.10**

Pour un champ `select`/`multiselect`/`radio` dont les options viennent d'une API (`url`), `optionsFilter` ne garde que les items dont une propriété correspond à une condition — avant leur mapping en `{ label, value }`. Utile pour exclure des lignes désactivées/supprimées d'une API qui renvoie tout (pas de paramètre de filtre côté serveur, ou pas confirmé).

### Interface

`optionsFilter` réutilise exactement la forme de [`FieldCondition`](#opérateurs-disponibles) (même 7 opérateurs que `showWhen`), avec `field` qui désigne ici une **propriété brute de l'item API** (ex: `'status'`), pas la clé d'un autre champ du formulaire :

```typescript
optionsFilter?: FieldCondition | FieldCondition[];
optionsFilterLogic?: 'AND' | 'OR'; // défaut 'AND' — indépendant de conditionLogic (showWhen)
```

### Exemple — ne garder que les pays actifs

```typescript
{
  key: 'paysId',
  type: 'select',
  label: 'Pays',
  url: 'pays',
  displayName: 'libelle',
  displayValue: 'id',
  optionsFilter: { field: 'status', operator: 'equals', value: 'ACTIF' },
}
```

Avec une réponse API contenant des pays `ACTIF`, `INACTIF` et `SUPPRIME`, seuls les `ACTIF` apparaissent dans la liste déroulante.

### Plusieurs conditions — ET / OU

```typescript
{
  key: 'paysId',
  type: 'select',
  label: 'Pays',
  url: 'pays',
  displayName: 'libelle',
  displayValue: 'id',
  optionsFilter: [
    { field: 'status', operator: 'equals', value: 'ACTIF' },
    { field: 'indicatifTel', operator: 'exists' },
  ],
  optionsFilterLogic: 'AND', // les deux conditions doivent être vraies (défaut)
}
```

- S'applique **avant** `dependsOnMode: 'client'` — le filtrage par `status` réduit la liste mise en cache, que `dependsOn` affine ensuite localement.
- Ne s'applique qu'aux options chargées via `url` — les `options` statiques ne sont pas concernées (l'auteur du formulaire les contrôle déjà directement).
- **Form Builder** _(v1.7.10)_ : section « Filtrer les résultats » dans le panneau « Depuis une API », avec le même éditeur de conditions ET/OU que « Afficher quand » (sauf que le nom de propriété se saisit en texte libre, pas dans un menu déroulant de champs du formulaire).

---

## Validation

```typescript
interface FieldValidation {
  required?: boolean;
  email?: boolean;
  minLength?: number;
  maxLength?: number;
  min?: number;
  max?: number;
  pattern?: string | RegExp;
  custom?: (value: any) => string | null;
  // Required conditionnel ← v1.6.0
  requiredWhen?: FieldCondition | FieldCondition[];    // required activé si la condition est vraie
  // Validation croisée ← v1.6.0
  maxField?: string;   // clé d'un autre champ — erreur si this.value > otherField.value
  minField?: string;   // clé d'un autre champ — erreur si this.value < otherField.value
  messages?: {
    required?: string;
    email?: string;
    minLength?: string;
    maxLength?: string;
    min?: string;
    max?: string;
    pattern?: string;
    // Messages pour les nouvelles contraintes ← v1.6.0
    requiredWhen?: string;
    maxField?: string;
    minField?: string;
    // type: 'date' uniquement — affiché quand la date choisie est hors des bornes minDate/maxDate du champ ← v1.6.6
    minDate?: string;
    maxDate?: string;
  };
}
```

**Exemples :**

```typescript
// Validateur avec message personnalisé
{
  key: 'email',
  type: 'email',
  label: 'E-mail',
  validation: {
    required: true,
    email: true,
    messages: {
      required: 'L\'e-mail est obligatoire.',
      email: 'Format e-mail invalide.',
    },
  },
}

// Validateur custom
{
  key: 'password',
  type: 'password',
  label: 'Mot de passe',
  validation: {
    required: true,
    custom: (v) => {
      if (!v || v.length < 8) return 'Minimum 8 caractères.';
      if (!/[A-Z]/.test(v)) return 'Au moins une majuscule.';
      return null;
    },
  },
}
```

---

## Validation par pattern — regex

La propriété `validation.pattern` accepte une chaîne de caractères (regex) ou un `RegExp`. Le **Form Builder** _(v1.4.0)_ expose un champ de saisie pour la regex et trois presets d'un clic :

| Preset | Regex | Usage |
|---|---|---|
| Chiffres uniquement | `^\d+$` | Numéro de commande, code |
| Lettres uniquement | `^[a-zA-ZÀ-ÿ\s]+$` | Nom, prénom |
| Alphanumérique | `^[a-zA-Z0-9]+$` | Identifiant, code |

```typescript
{
  key: 'code',
  type: 'text',
  label: 'Code article',
  validation: {
    required: true,
    pattern: '^[A-Z0-9]{4,8}$',
    messages: { pattern: 'Le code doit comporter 4 à 8 caractères majuscules ou chiffres.' },
  },
}
```

---

## Vérification d'unicité — uniqueCheck

> **Nouveauté v1.4.0**

La propriété `uniqueCheck` ajoute un **validateur asynchrone** à un champ : à chaque saisie (avec un debounce de 400 ms), le plugin envoie un `POST` à l'URL configurée et affiche une erreur si la valeur existe déjà.

### Configuration du champ

```typescript
{
  key: 'email',
  type: 'email',
  label: 'E-mail',
  validation: { required: true, email: true },
  uniqueCheck: {
    url: 'https://api.monprojet.com/check/email',
    message: 'Cet e-mail est déjà utilisé.',
  },
}
```

### Contrat de l'API

Le plugin envoie :
```json
POST /check/email
{ "value": "utilisateur@exemple.com" }
```

L'API peut retourner :
- Un **booléen brut** : `true` = valeur prise, `false` = disponible
- Une **enveloppe SUNTELECOMS** : `{ "data": true }` ou `{ "data": false }`

Les deux formats sont auto-détectés — aucune configuration supplémentaire.

### UniquenessValidatorService

Le service est exporté et peut être utilisé directement dans n'importe quel composant :

```typescript
import { inject } from '@angular/core';
import { UniquenessValidatorService } from '@suntelecoms/ngx-dynamic-form';

export class MonComposant {
  private uniqueness = inject(UniquenessValidatorService);

  myControl = new FormControl('', {
    asyncValidators: [
      this.uniqueness.build('https://api.monprojet.com/check/username', 'Nom d\'utilisateur déjà pris.'),
    ],
  });
}
```

> **Prérequis** : `provideHttpClient()` doit être présent dans `app.config.ts`.

---

## Champ téléphone international — phone

> **Nouveauté v1.4.0** · Sélecteur en menu avec recherche + apparence Material native _(v1.4.7)_ · Drapeaux en SVG _(v1.4.8)_

Le type `phone` affiche un champ numérique masqué automatiquement selon le pays, précédé d'un **sélecteur de pays** (drapeau + indicatif). La valeur stockée dans le `FormGroup` est toujours au format **E.164** (ex : `+33612345678`).

Deux rendus selon `appearance` :
- **`outline` / `fill`** (par défaut) : un vrai `<mat-form-field>` — même apparence Material native que les autres champs. Le sélecteur de pays est un bouton `matPrefix` (drapeau + indicatif) qui ouvre un menu avec un champ de recherche et la liste des 135 pays (drapeau, nom, indicatif) — cliquer un pays met à jour l'indicatif sans effacer le numéro déjà saisi. Les drapeaux sont des SVG (package `flag-icons`), pas des emoji : sur Windows et dans certains webviews, les emoji drapeau (séquences de deux « regional indicator symbols ») retombent sur les deux lettres du code pays faute de police couleur adéquate — le SVG rend à l'identique partout.
- **`simple`** : la liste déroulante HTML native `<select>` d'origine, pour rester sans dépendance à Angular Material. Les `<option>` ne peuvent afficher que du texte (aucune image/CSS possible dedans), donc ce mode garde l'emoji — limitation du `<select>` natif, pas du champ.

> **Prérequis (apparence `outline`/`fill`)** : installer `flag-icons` et charger sa feuille de style globalement.
> ```bash
> npm install flag-icons
> ```
> N'ajoutez **pas** directement `node_modules/flag-icons/css/flag-icons.min.css` dans `angular.json` : ce fichier référence deux dossiers de drapeaux (`flags/4x3/xx.svg` rectangulaire et `flags/1x1/xx.svg` carré) qui partagent le **même nom de fichier** par pays. En configuration `development` (assets non hashés), Angular échoue avec `Two output files share the same path but have different contents`. Ce champ n'utilise que la variante rectangulaire (`.fis` carré jamais utilisé) — désactivez la variante carrée au niveau Sass à la place :
> ```scss
> // src/styles/vendor/flag-icons.scss
> @use "flag-icons/sass/flag-icons" with (
>   $flag-icons-use-square: false
> );
> ```
> ```json
> // angular.json → projects.<app>.architect.build.options.styles
> "styles": ["src/styles/vendor/flag-icons.scss", "src/styles.css"]
> ```

### Exemple de champ

```typescript
{
  key: 'mobile',
  type: 'phone',
  label: 'Téléphone mobile',
  col: 6,
  defaultCountry: 'SN',  // Sénégal par défaut
  validation: { required: true },
}
```

### Pays disponibles (135)

Tous les pays et territoires courants, classés par continent (Afrique, Amériques, Asie, Europe, Océanie), avec drapeau, indicatif et masque de saisie propre à chaque pays. Liste complète : `PHONE_COUNTRIES` dans `projects/ngx-dynamic-form/src/lib/data/phone-countries.ts`.

### Interfaces et constantes exportées

```typescript
import type { PhoneCountry } from '@suntelecoms/ngx-dynamic-form';
import { PHONE_COUNTRIES } from '@suntelecoms/ngx-dynamic-form';

// PhoneCountry
interface PhoneCountry {
  code: string;     // 'FR'
  name: string;     // 'France'
  dialCode: string; // '+33'
  flag: string;     // '🇫🇷'
  mask: string;     // '# ## ## ## ##'  (#=chiffre, autres chars=séparateurs)
}
```

---

## Affichage conditionnel — showWhen

Un champ peut être affiché ou masqué en fonction de la valeur d'un autre champ, sans écrire de code Angular.

```typescript
// Condition unique — afficher "Entreprise" seulement si accountType === 'pro'
{
  key: 'company',
  type: 'text',
  label: 'Entreprise',
  showWhen: { field: 'accountType', operator: 'equals', value: 'pro' },
}

// Conditions multiples — logique AND (défaut)
{
  key: 'discount',
  type: 'number',
  label: 'Remise (%)',
  showWhen: [
    { field: 'accountType', operator: 'equals',      value: 'pro' },
    { field: 'orderTotal',  operator: 'greaterThan', value: 500 },
  ],
  // conditionLogic: 'AND',  // optionnel — AND est le défaut
}

// Conditions multiples — logique OU  ← v1.4.0
{
  key: 'promo',
  type: 'text',
  label: 'Code promo',
  showWhen: [
    { field: 'accountType', operator: 'equals', value: 'pro' },
    { field: 'accountType', operator: 'equals', value: 'vip' },
  ],
  conditionLogic: 'OR',  // afficher si pro OU vip
}
```

### Opérateurs disponibles

| Opérateur | Description |
|---|---|
| `equals` | Valeur exactement égale |
| `notEquals` | Valeur différente |
| `contains` | Contient (string ou tableau) |
| `greaterThan` | Valeur numérique supérieure |
| `lessThan` | Valeur numérique inférieure |
| `exists` | Champ renseigné (non vide, non null) |
| `notExists` | Champ vide ou null |

---

## Auto-remplissage depuis une API — lookup

> **Nouveauté v1.6.0**

La propriété `lookup` déclenche un appel HTTP à la perte de focus du champ et remplit d'autres champs du formulaire avec les données de la réponse.

### Interface FieldLookup

```typescript
interface FieldLookup {
  url: string;           // Endpoint — supporte {{key}} pour interpoler la valeur du champ
  method?: 'GET' | 'POST';  // défaut : 'GET'
  populate: Record<string, string>;
  // Clé = champ cible, Valeur = chemin dans la réponse (notation pointée)
  // Ex : { 'nom': 'data.nom', 'typeCode': 'data.typeCode' }
  triggerOn?: 'blur' | 'change'; // défaut : 'blur'
}
```

### Exemple — lookup souscripteur par téléphone

```typescript
{
  key: 'telephone',
  type: 'phone',
  label: 'Téléphone',
  lookup: {
    url: '/api/souscripteurs/check?telephone={{telephone}}',
    method: 'GET',
    populate: {
      'prenom':  'data.prenom',
      'nom':     'data.nom',
      'email':   'data.email',
      'typeCode':'data.typeCode',
    },
  },
}
```

Quand l'utilisateur quitte le champ `telephone`, le plugin appelle `GET /api/souscripteurs/check?telephone=<valeur>` et utilise la réponse pour remplir automatiquement les champs `prenom`, `nom`, `email` et `typeCode` via `patchValue`.

> Si l'API renvoie une erreur HTTP (4xx/5xx), le lookup est ignoré silencieusement — aucun champ n'est modifié.

---

## Required conditionnel — requiredWhen

> **Nouveauté v1.6.0**

`requiredWhen` active `Validators.required` uniquement si la condition est vraie — sans logique impérative dans le composant hôte.

```typescript
{
  key: 'raisonSociale',
  type: 'text',
  label: 'Raison sociale',
  validation: {
    requiredWhen: { field: 'typeCode', operator: 'equals', value: '3' },
    messages: { requiredWhen: 'La raison sociale est obligatoire pour une personne morale.' },
  },
}
```

Le validateur est réévalué à chaque changement du formulaire — si la condition devient fausse, l'erreur disparaît.

Accepte aussi un tableau de conditions (avec `conditionLogic: 'AND' | 'OR'` hérité du champ) :

```typescript
validation: {
  requiredWhen: [
    { field: 'typeCode',  operator: 'equals', value: '3' },
    { field: 'typeAssurance', operator: 'equals', value: 'PRO' },
  ],
}
```

---

## Validation croisée — maxField / minField

> **Nouveauté v1.6.0**

Les propriétés `maxField` et `minField` sur `FieldValidation` créent un validateur de groupe qui compare deux champs numériques ou deux dates (voir [Période de dates](#période-de-dates--date-de-début--date-de-fin)).

```typescript
// Exemple : valeur vénale ne peut pas dépasser la valeur à neuf
{
  key: 'valeurVenal',
  type: 'number',
  label: 'Valeur vénale (FCFA)',
  validation: {
    maxField: 'valeurNeuf',
    messages: { maxField: 'La valeur vénale ne peut pas dépasser la valeur à neuf.' },
  },
}
```

| Propriété | Comportement |
|---|---|
| `maxField: 'autreChamp'` | Erreur si `this.value > form.get('autreChamp').value` |
| `minField: 'autreChamp'` | Erreur si `this.value < form.get('autreChamp').value` |

L'erreur est posée directement sur le `FormControl` concerné (pas sur le groupe parent), ce qui l'affiche sous le champ comme une erreur de validation normale.

### Période de dates — date de début ≤ date de fin

_(v1.7.18)_ `maxField` / `minField` fonctionnent aussi sur les types `date`, `datetime-local`, `month`, `week` et `time`. Les valeurs peuvent être des `Date` (sélection au calendrier) ou des chaînes ISO (`'2026-06-01'`, valeurs pré-remplies depuis l'API en mode édition) ; la comparaison se fait au jour près pour une date.

```typescript
{
  key: 'dateEffet',
  type: 'date',
  label: "Date d'effet souhaitée",
  validation: { required: true, maxField: 'dateFinValidite' },
},
{
  key: 'dateFinValidite',
  type: 'date',
  label: 'Date de fin de validité',
  validation: { required: true, minField: 'dateEffet' },
}
```

- **Calendrier** : les dates hors période sont grisées — le calendrier de la date d'effet s'arrête à la date de fin choisie, celui de la date de fin commence à la date d'effet. Si `minDate` / `maxDate` est aussi défini, la borne la plus stricte s'applique.
- **Message par défaut** : `Doit être antérieure ou égale à « Date de fin de validité ».` (ou `postérieure ou égale à « … »` pour `minField`) — personnalisable via `messages.maxField` / `messages.minField`.
- Déclarer les **deux côtés** (`maxField` sur le début, `minField` sur la fin) affiche l'erreur sous le champ que l'utilisateur vient de modifier, quel qu'il soit.
- En Stepper, les deux champs doivent être dans la **même étape**.
- **Form Builder** : section *Validation* d'un champ date → « Max basé sur champ » / « Min basé sur champ ».

---

## Au moins un champ obligatoire — atLeastOneOf

> **Nouveauté v1.6.0**

`atLeastOneOf` est un validateur de groupe qui exige qu'au moins un des champs listés soit renseigné. Disponible sur `DynamicFormConfig` (formulaire simple) et `FormStep` (par étape dans le stepper).

```typescript
// Sur DynamicFormConfig
const config: DynamicFormConfig = {
  atLeastOneOf: ['email', 'telephone'],
  atLeastOneOfMessage: 'Renseignez au moins un moyen de contact.',
  fields: [
    { key: 'email',     type: 'email', label: 'E-mail' },
    { key: 'telephone', type: 'phone', label: 'Téléphone' },
  ],
};

// Sur FormStep
const step: FormStep = {
  label: 'Contact',
  atLeastOneOf: ['email', 'telephone'],
  atLeastOneOfMessage: 'Renseignez au moins un moyen de contact.',
  fields: [ /* ... */ ],
};
```

L'erreur est affichée sous le formulaire/l'étape jusqu'à ce qu'au moins un champ ait une valeur non vide.

---

## Guard de step — beforeNext

> **Nouveauté v1.6.0**

`beforeNext` sur `FormStep` effectue un appel HTTP avant d'autoriser l'avancement vers l'étape suivante. Une réponse HTTP 4xx bloque la navigation et affiche le message d'erreur retourné par le serveur.

### Interface StepGuard

```typescript
interface StepGuard {
  url: string;            // Endpoint POST/GET appelé avant d'avancer
  method?: 'GET' | 'POST'; // défaut : 'POST'
  withFormValues?: boolean; // Envoie le formulaire complet de l'étape dans le corps (défaut : true)
  resultKey?: string;     // Clé dans la réponse à stocker dans stepResults (défaut : aucun)
  errorMessage?: string;  // Message d'erreur de fallback si le serveur n'en retourne pas
}
```

### Exemple — vérification d'immatriculation

```typescript
const step: FormStep = {
  label: 'Véhicule',
  fields: [
    { key: 'immatriculation', type: 'text', label: 'Immatriculation' },
    { key: 'puissance', type: 'number', label: 'Puissance (CV)' },
  ],
  beforeNext: {
    url: '/api/souscriptions/check',
    method: 'POST',
    errorMessage: 'Vérification impossible, veuillez réessayer.',
  },
};
```

Le plugin envoie `POST /api/souscriptions/check` avec les valeurs de l'étape. Si le serveur répond `HTTP 400` avec `{ "message": "Une souscription existe déjà…" }`, ce message est affiché sous le stepper et la navigation est bloquée.

> **`resultKey`** : si renseigné, la réponse HTTP 2xx est stockée dans `stepResults[resultKey]` accessible via `getStepForm()` ou dans `formSubmit`. Utile pour propager un ID ou un résultat de calcul aux étapes suivantes.

### API publique StepperFormComponent

```typescript
@ViewChild('s') stepperRef!: StepperFormComponent;

// Accéder au FormGroup d'une étape spécifique
const step0Form = this.stepperRef.getStepForm(0);
console.log(step0Form.value);

// Mettre à jour des champs d'une étape
this.stepperRef.getStepForm(1).patchValue({ primeRC: 45000 });
```

---

## Icônes (Material Icons)

```typescript
{ key: 'email', type: 'email', label: 'E-mail', icon: 'email', iconSuffix: 'verified' }
```

| Champ | Icône recommandée |
|---|---|
| Nom, Prénom | `person`, `badge` |
| E-mail | `email` |
| Téléphone | `phone` |
| Mot de passe | `lock` |
| Adresse | `home`, `location_on` |
| Entreprise | `business` |
| Prix | `euro`, `attach_money` |
| Date | `calendar_today` |
| Pièce jointe | `attach_file` |

---

## Disposition en grille (col)

La propriété `col` détermine le nombre de colonnes sur 12 occupées par le champ.

| `col` | Largeur | Usage |
|---|---|---|
| `12` | 100% | Pleine largeur (défaut) |
| `6` | 50% | Deux champs côte à côte |
| `4` | 33% | Trois champs côte à côte |
| `3` | 25% | Quatre champs côte à côte |

> En dessous de 600 px, tous les champs passent automatiquement en pleine largeur.

**Exemple avec sections :**
```typescript
fields: [
  { key: '_s1', type: 'heading', text: 'Informations', level: 3, col: 12 },
  { key: 'name',  type: 'text',  label: 'Nom',    col: 6 },
  { key: 'email', type: 'email', label: 'E-mail', col: 6 },
  { key: '_div',  type: 'divider', col: 12 },
  { key: '_s2', type: 'heading', text: 'Adresse', level: 3, col: 12 },
]
```

---

## API du composant DynamicForm

### Inputs

| Propriété | Type | Description |
|---|---|---|
| `[config]` | `DynamicFormConfig` | Configuration du formulaire (obligatoire) |
| `[initialValues]` | `Record<string, any>` | Préremplissage des champs (mode édition) |

### Outputs

| Événement | Type | Description |
|---|---|---|
| `(formSubmit)` | `DynamicFormSubmitEvent` | Formulaire soumis et valide |
| `(formChange)` | `Record<string, any>` | Émis à chaque modification |
| `(formReset)` | `void` | Formulaire réinitialisé |
| `(fieldChange)` | `{ key: string; value: any; form: Record<string, any> }` | Émis à chaque changement de champ, avec la valeur et l'état complet du formulaire _(v1.6.0)_ |

### API publique via @ViewChild

```typescript
@ViewChild('f') formRef!: DynamicFormComponent;

// Accéder au FormGroup Angular
const fg = this.formRef.getForm();
console.log(fg.value, fg.valid);

// Mettre à jour des valeurs sans réinitialiser
this.formRef.patchValues({ name: 'Nouveau nom' });
```

```html
<ngx-dynamic-form
  #f
  [config]="config"
  [initialValues]="existingData"
  (formSubmit)="onSubmit($event)"
  (formChange)="onChange($event)"
/>
```

---

## Formulaires multi-étapes — Stepper

> **Nouveauté v1.2.0**

Un formulaire stepper découpe la saisie en plusieurs **étapes** numérotées. L'utilisateur avance étape par étape — chaque étape est validée avant de passer à la suivante. À la fin, une étape **Récapitulatif** affiche toutes les valeurs saisies avec la possibilité de revenir corriger une étape.

### Comment ça fonctionne

```
Étape 1        Étape 2        Étape 3        Récapitulatif
Identité  ──►  Adresse  ──►  Documents ──►  Vérification + Envoi
```

- **Navigation linéaire** : impossible de passer à l'étape suivante si l'étape en cours est invalide.
- **Bouton Modifier** dans le récapitulatif : permet de revenir à n'importe quelle étape.
- **Un seul `formSubmit`** : les données de toutes les étapes sont fusionnées dans un seul objet au moment de l'envoi.

### Utilisation du composant `<ngx-stepper-form>`

```typescript
import { Component } from '@angular/core';
import { StepperFormComponent } from '@suntelecoms/ngx-dynamic-form';
import type { StepperFormConfig, DynamicFormSubmitEvent } from '@suntelecoms/ngx-dynamic-form';

@Component({
  selector: 'app-inscription',
  standalone: true,
  imports: [StepperFormComponent],
  template: `
    <ngx-stepper-form [config]="config" (formSubmit)="onSubmit($event)" />
  `,
})
export class InscriptionComponent {
  config: StepperFormConfig = {
    showSummary: true,
    submitLabel: 'Valider',
    steps: [
      {
        label: 'Identité',
        icon: 'person',
        fields: [
          { key: 'firstName', type: 'text',  label: 'Prénom', col: 6, validation: { required: true } },
          { key: 'lastName',  type: 'text',  label: 'Nom',    col: 6, validation: { required: true } },
          { key: 'email',     type: 'email', label: 'E-mail', col: 12, validation: { required: true, email: true } },
        ],
      },
      {
        label: 'Adresse',
        icon: 'home',
        fields: [
          { key: 'street', type: 'text',   label: 'Rue',         col: 12, validation: { required: true } },
          { key: 'city',   type: 'text',   label: 'Ville',       col: 6,  validation: { required: true } },
          { key: 'zip',    type: 'text',   label: 'Code postal', col: 6,  validation: { required: true } },
        ],
      },
      {
        label: 'Confirmation',
        icon: 'check_circle',
        fields: [
          { key: 'terms', type: 'checkbox', placeholder: "J'accepte les CGU", col: 12,
            validation: { required: true } },
        ],
      },
    ],
  };

  onSubmit(event: DynamicFormSubmitEvent): void {
    console.log(event.value);
  }
}
```

### Configuration — StepperFormConfig

```typescript
interface StepperFormConfig {
  steps: FormStep[];            // Liste des étapes (obligatoire)
  submitLabel?: string;         // Libellé du bouton Envoyer (défaut : "Envoyer")
  showReset?: boolean;          // Afficher Réinitialiser (défaut : false)
  resetLabel?: string;          // Libellé du bouton Reset
  linear?: boolean;             // Bloque la navigation si l'étape est invalide (défaut : true)
  orientation?: 'horizontal' | 'vertical';
  showSummary?: boolean;        // Ajoute une étape Récapitulatif à la fin (défaut : false)
  summaryLabel?: string;        // Libellé de l'étape récapitulatif (défaut : "Récapitulatif")
  cssClass?: string;
  debug?: boolean;
}
```

### Configuration — FormStep

```typescript
interface FormStep {
  label: string;                // Titre de l'étape affiché dans la barre de navigation
  icon?: string;                // Icône Material Icons (optionnelle)
  description?: string;         // Sous-titre de l'étape (optionnel)
  fields: DynamicFormField[];
  // ← v1.6.0
  beforeNext?: StepGuard;       // Guard HTTP exécuté avant l'avancement vers l'étape suivante
  atLeastOneOf?: string[];      // Au moins un des champs listés doit être renseigné (étape)
  atLeastOneOfMessage?: string; // Message d'erreur personnalisé
}
```

### L'étape Récapitulatif

Quand `showSummary: true`, une étape supplémentaire est ajoutée automatiquement à la fin du stepper. Elle affiche :

- Toutes les étapes sous forme de **sections distinctes**
- Pour chaque champ rempli : son **label** et sa **valeur** formatée
- Un bouton **Modifier** par étape pour naviguer directement vers elle
- Les types spéciaux sont formatés intelligemment :
  - `checkbox` → "Oui" / "Non"
  - `select` / `radio` → le **label** de l'option (pas la valeur brute)
  - `date` → format `jj/mm/aaaa`
  - `password` → `••••••••`
  - `multiselect` → labels séparés par des virgules

### Inputs de StepperFormComponent

| Propriété | Type | Description |
|---|---|---|
| `[config]` | `StepperFormConfig` | Configuration du stepper (obligatoire) |
| `[initialValues]` | `Record<string, any>` | Préremplissage des champs |

### Outputs de StepperFormComponent

| Événement | Type | Description |
|---|---|---|
| `(formSubmit)` | `DynamicFormSubmitEvent` | Toutes les étapes valides — données fusionnées |
| `(formChange)` | `Record<string, any>` | Émis à chaque modification (toutes étapes) |
| `(stepChange)` | `number` | Index de la nouvelle étape active |
| `(fieldChange)` | `{ key: string; value: any; step: number; form: Record<string, any> }` | Émis à chaque changement de champ, avec l'index de l'étape _(v1.6.0)_ |

---

## provideNgxForms — configuration globale

`provideNgxForms()` est la fonction de configuration du plugin. Elle enregistre le `FormConfigService` et l'adaptateur de stockage dans le contexte Angular.

### Stockage localStorage (défaut)

```typescript
// app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { provideNgxForms } from '@suntelecoms/ngx-dynamic-form';

providers: [
  provideNgxForms({ storage: 'local' }),
]
```

**Avec options dynamiques depuis une API :**
```typescript
providers: [
  provideHttpClient(),
  provideNgxForms({
    storage: 'local',
    dataUrl: 'https://api.monprojet.com',
  }),
]
```

### Stockage API REST (Spring Boot) — API ouverte

```typescript
// app.config.ts
providers: [
  provideHttpClient(),
  provideNgxForms({
    storage: 'http',
    apiUrl: 'https://api.monprojet.com/api/forms',
    dataUrl: 'https://api.monprojet.com',
  }),
]
```

### Stockage API REST — API sécurisée (Bearer token) _(v1.3.0)_

Si votre API exige un token JWT, ajoutez `auth` et enregistrez l'intercepteur fourni :

```typescript
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ngxFormsAuthInterceptor, provideNgxForms } from '@suntelecoms/ngx-dynamic-form';

providers: [
  provideHttpClient(withInterceptors([ngxFormsAuthInterceptor])),
  provideNgxForms({
    storage: 'http',
    apiUrl: 'https://api.monprojet.com/api/forms',
    dataUrl: 'https://api.monprojet.com',
    auth: {
      loginUrl: 'https://api.monprojet.com/security-service/api/login',
      username: 'admin',
      password: 'secret',
    },
  }),
]
```

> L'intercepteur est **no-op** si `auth` n'est pas configuré. Les APIs ouvertes ne nécessitent aucune modification.
>
> Le token est mis en cache en mémoire et renouvelé automatiquement avant expiration (`expiresIn - 10s`).

### Options de configuration

| Option | Type | Description |
|---|---|---|
| `storage` | `'local' \| 'http'` | Moteur de stockage des schémas |
| `apiUrl` | `string` | _(mode `http` uniquement)_ URL de base de l'API de stockage |
| `dataUrl` | `string` | _(optionnel)_ Base URL pour le chargement des options de champs |
| `listPageSize` | `number` | _(mode `http` uniquement, optionnel, défaut `1000`)_ Taille de page envoyée sur `GET {apiUrl}?page=0&size=...` pour charger le catalogue de formulaires sauvegardés. À augmenter si vous dépassez ce nombre de formulaires enregistrés — n'affecte pas `fetchById`/`fetchByCode` (v1.7.0), qui ne chargent jamais la liste entière |
| `auth.loginUrl` | `string` | _(optionnel)_ Endpoint de login retournant `{ data: { accessToken, expiresIn } }` |
| `auth.username` | `string` | Identifiant pour le login |
| `auth.password` | `string` | Mot de passe pour le login |

### Endpoints REST attendus

L'API doit exposer les endpoints suivants :

```
GET    /api/forms?page=0&size=N  → SavedFormSchema[]  (N = listPageSize, défaut 1000)
GET    /api/forms/:id            → SavedFormSchema
GET    /api/forms/code/:code     → SavedFormSchema  (récupération par code unique)
POST   /api/forms                → SavedFormSchema  (création — l'API génère l'id UUID)
PUT    /api/forms/:id            → SavedFormSchema  (mise à jour)
DELETE /api/forms/:id            → void
```

> Depuis la v1.7.0, `GET /api/forms` est appelé avec `page`/`size` (voir `listPageSize` ci-dessus) — auparavant sans pagination, ce qui tronquait silencieusement le catalogue à la taille de page par défaut du serveur dès que le nombre de formulaires la dépassait.

---

## FormConfigService — gestion des schémas

`FormConfigService` est fourni par `provideNgxForms()`. Il gère les schémas de formulaires avec un **Signal réactif** et une **API Observable**.

Depuis la v1.2.9, chaque schéma possède un **`code`** unique saisi par l'utilisateur (ex : `"contact-form"`) et une date de création `createdAt`. La sauvegarde est séparée en **`create()`** (nouveau formulaire) et **`update()`** (formulaire existant).

```typescript
import { inject } from '@angular/core';
import { FormConfigService } from '@suntelecoms/ngx-dynamic-form';

export class MonComposant {
  private formService = inject(FormConfigService);

  // Signal réactif — liste mise à jour automatiquement
  readonly schemas = this.formService.schemas; // Signal<SavedFormSchema[]>

  // Lecture synchrone par id
  getById(id: string) {
    return this.formService.get(id); // SavedFormSchema | null
  }

  // Lecture synchrone par code
  getByCode(code: string) {
    return this.formService.getByCode(code); // SavedFormSchema | null
  }

  // Depuis v1.7.0 — fetch direct par id/code, sans dépendre de la liste
  // complète déjà chargée en mémoire. À préférer à get()/getByCode() dès
  // qu'on n'a besoin que d'UN formulaire précis (dialog, page dédiée...) :
  // reste rapide et correct même avec des centaines de formulaires
  // enregistrés, et évite la course avec le chargement initial de schemas().
  fetchById(id: string) {
    this.formService.fetchById(id).subscribe(schema => {
      console.log(schema); // SavedFormSchema | null
    });
  }

  fetchByCode(code: string) {
    this.formService.fetchByCode(code).subscribe(schema => {
      console.log(schema); // SavedFormSchema | null
    });
  }

  // Création (nouveau formulaire — l'id sera généré automatiquement)
  createForm(code: string, title: string, config: AnyFormConfig) {
    this.formService.create(code, title, config).subscribe(schema => {
      console.log('Créé avec id :', schema.id);
    });
  }

  // Mise à jour (formulaire existant)
  updateForm(schema: SavedFormSchema, title: string, config: AnyFormConfig) {
    this.formService.update(schema, title, config).subscribe(updated => {
      console.log('Mis à jour :', updated.updatedAt);
    });
  }

  // Suppression
  deleteForm(id: string) {
    this.formService.delete(id).subscribe(() => console.log('Supprimé'));
  }
}
```

### Interface SavedFormSchema

```typescript
interface SavedFormSchema {
  id: string;            // UUID généré automatiquement (côté serveur ou localStorage)
  code: string;          // Code unique saisi par l'utilisateur (ex : "contact-form")
  title: string;         // Nom affiché du formulaire
  config: AnyFormConfig; // DynamicFormConfig OU StepperFormConfig
  createdAt: string;     // ISO 8601 — date de création (non modifiable)
  updatedAt: string;     // ISO 8601 — date de dernière modification
}

// AnyFormConfig = DynamicFormConfig | StepperFormConfig
// Pour distinguer les deux : 'steps' in config → c'est un StepperFormConfig
```

---

## Stockage — LocalStorage vs API REST

Le plugin utilise une abstraction `FormStorageAdapter` qui permet de changer le backend de stockage sans toucher au reste de l'application.

### Résumé des adaptateurs

| Option | Classe | Quand utiliser |
|---|---|---|
| `storage: 'local'` | `LocalStorageAdapter` | Développement, démo, usage hors-ligne |
| `storage: 'http'` | `HttpStorageAdapter` | Production avec backend Spring Boot |

### Clé localStorage

Quand `storage: 'local'`, les données sont stockées sous :

```
localStorage['ngx-dynamic-form__schemas'] = [
  { id, code, title, config, createdAt, updatedAt },
  ...
]
```

### Implémenter son propre adaptateur

```typescript
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { FormStorageAdapter, SavedFormSchema } from '@suntelecoms/ngx-dynamic-form';

@Injectable()
export class IndexedDBAdapter extends FormStorageAdapter {
  list():                                          Observable<SavedFormSchema[]>      { /* ... */ }
  get(id: string):                                 Observable<SavedFormSchema | null> { /* ... */ }
  getByCode(code: string):                         Observable<SavedFormSchema | null> { /* ... */ }
  create(schema: Omit<SavedFormSchema, 'id'>):     Observable<SavedFormSchema>        { /* ... */ }
  update(schema: SavedFormSchema):                 Observable<SavedFormSchema>        { /* ... */ }
  delete(id: string):                              Observable<void>                   { /* ... */ }
}

// app.config.ts
providers: [
  { provide: FormStorageAdapter, useClass: IndexedDBAdapter },
  FormConfigService,
]
```

---

## Form Builder — éditeur visuel

Le **Form Builder** est une interface 3 panneaux intégrée dans le plugin. Il supporte deux modes : **Formulaire** (simple) et **Stepper** (multi-étapes).

### Ajouter la route

```typescript
{
  path: 'builder',
  loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.FormBuilderComponent),
},
// Éditer un formulaire existant — depuis la v1.7.0 (ajusté en v1.7.1),
// ajouter une seconde route avec :code charge automatiquement ce
// formulaire dans le Builder (fetch direct par code, ne dépend pas de
// la liste complète) :
{
  path: 'builder/:code',
  loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.FormBuilderComponent),
}
```

> Avant la v1.7.0, `FormBuilderComponent` n'avait aucune conscience de la route — naviguer vers `builder/:code` ouvrait un builder vide, et le lien "Éditer dans le Builder" de `MesFormulairesComponent` pointait vers `builderRoute` sans id/code, donc ne préchargeait jamais rien (seul moyen de charger un formulaire existant : cliquer sur son nom dans le panneau "Formulaires sauvegardés" du Builder lui-même — toujours possible, méthode `loadSchema(id)` inchangée). Corrigé en v1.7.0 par id, puis **basculé sur `code` en v1.7.1** — même convention que `FormLoaderComponent`, et certains backends ne résolvent pas correctement `GET /forms/:id` alors que `GET /forms/code/:code` fonctionne. Le lien "Éditer" et le bouton "Builder" (après soumission) de `MesFormulairesComponent` pointent vers `[builderRoute, schema.code]`, et `FormBuilderComponent` précharge automatiquement ce formulaire via `fetchByCode()`.

### Interface

```
┌──────────────────┬───────────────────────────────┬─────────────────────┐
│  Palette         │   [Formulaire] [Stepper]       │   Propriétés        │
│                  │   Schéma / Aperçu             │   (champ sélect.)   │
│  ÉTAPES (stepper)│                               │                     │
│  ① Étape 1    ✏X│  ┌────────────────────────┐   │  Label : Nom        │
│  ② Étape 2    ✏X│  │ Nom     (text)  ↑↓⎘🗑  │   │  Col : 6/12         │
│  + Ajouter       │  │ Email   (email) ↑↓⎘🗑  │   │  Icône : person     │
│                  │  └────────────────────────┘   │  Required : ✓       │
│  TYPES DE CHAMPS │                               │                     │
│  [Texte]         │  Paramètres du formulaire     │  Validation         │
│  [E-mail]        │  Code : [contact-form]        │  Options            │
│  [Select]  ...   │  Titre : [Mon formulaire]     │                     │
│                  │  Bouton Envoyer : [Envoyer]   │                     │
│  Formulaires     │  [ ] Réinitialiser            │                     │
│  sauvegardés     │  [✓] Récapitulatif (stepper)  │                     │
│                  │                               │                     │
│                  │  [Schéma] [Aperçu]            │                     │
└──────────────────┴───────────────────────────────┴─────────────────────┘
```

### Champ Code

La topbar du Builder expose un champ **Code** à côté du titre. Ce code :
- Est saisi librement par l'utilisateur (ex : `"contact-form"`, `"inscription-2024"`)
- Doit être **unique** — il est utilisé dans l'URL de partage (`/form/contact-form`) et pour la récupération par `getByCode()`
- Est **verrouillé après la première sauvegarde** (le code ne peut plus changer une fois l'identifiant attribué)

### Fonctionnalités du Builder

| Action | Description |
|---|---|
| Ajouter un champ | Clic sur un type dans la palette |
| Réordonner | Boutons ↑ ↓ sur chaque champ |
| Dupliquer | Copie d'un champ avec toutes ses propriétés |
| Supprimer | Icône corbeille |
| Éditer | Panneau droit — label, col, icône, validation, options… |
| Mode Stepper | Bascule Formulaire ↔ Stepper en haut de page |
| Gérer les étapes | Créer, renommer, supprimer les étapes |
| Récapitulatif | Case à cocher pour activer l'étape de synthèse |
| Aperçu | Onglet **Aperçu** — rendu réel en temps réel |
| Sauvegarder | Crée ou met à jour selon l'état courant (nouveau / existant) |
| Export JSON | JSON de la config dans un modal copiable |
| Export TypeScript | `const config: DynamicFormConfig = { ... }` ou `StepperFormConfig` |
| Partager | Copie l'URL `/form/<code>` dans le presse-papiers |
| **Pattern regex** _(v1.4.0)_ | Section Validation — champ regex + 3 presets + message d'erreur |
| **Afficher quand** _(v1.4.0)_ | Conditions d'affichage — toggle ET/OU, ajout/suppression de conditions |
| **Vérification d'unicité** _(v1.4.0)_ | Case à cocher + URL + message d'erreur personnalisé |
| **Pays par défaut (phone)** _(v1.4.0)_ | Sélecteur de pays initial pour les champs `phone` |

### Input du FormBuilderComponent

| Input | Type | Défaut | Description |
|---|---|---|---|
| `[backRoute]` | `string` | `'/'` | Route du lien "Retour" dans la topbar |

---

## Composants de navigation

### MesFormulairesComponent — `<ngx-mes-formulaires>`

Liste et aperçu de tous les formulaires sauvegardés (simples **et** stepper). Affiche le **code** de chaque formulaire et permet de l'ouvrir directement via son URL `/form/<code>`.

```typescript
{
  path: 'mes-formulaires',
  loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.MesFormulairesComponent),
}
```

| Input | Type | Défaut | Description |
|---|---|---|---|
| `[backRoute]` | `string` | `'/'` | Lien "Retour" |
| `[builderRoute]` | `string` | `'/builder'` | Route vers le Builder |
| `[formRoute]` | `string` | `'/form'` | Préfixe de la route de rendu (+ `/:code`) |

### FormLoaderComponent — `<ngx-form-loader>`

Charge et affiche un formulaire depuis son **code unique** — détecte automatiquement s'il s'agit d'un formulaire simple ou d'un stepper.

```typescript
{
  path: 'form/:code',
  loadComponent: () => import('@suntelecoms/ngx-dynamic-form').then(m => m.FormLoaderComponent),
}
```

| Input | Type | Défaut | Description |
|---|---|---|---|
| `[formCode]` | `string` | — | Code direct (sans routing, optionnel) |
| `[backRoute]` | `string` | `'/'` | Lien "Retour" |
| `[builderRoute]` | `string` | `'/builder'` | Lien "Éditer" |

> Depuis la v1.7.0, `FormLoaderComponent` charge son schéma via `fetchByCode()` (fetch direct par code, avec état `loading()`) au lieu d'une lecture synchrone sur la liste déjà chargée — corrige une course possible où le composant se rendait "introuvable" si la liste complète n'était pas encore arrivée.

---

## Intégration dans un composant

### Approche A — formulaire simple chargé par code

```typescript
import { Component, computed, inject, signal } from '@angular/core';
import { DynamicFormComponent, FormConfigService } from '@suntelecoms/ngx-dynamic-form';
import type { DynamicFormConfig, DynamicFormSubmitEvent } from '@suntelecoms/ngx-dynamic-form';

@Component({
  standalone: true,
  imports: [DynamicFormComponent],
  template: `
    @if (activeConfig(); as cfg) {
      <ngx-dynamic-form [config]="cfg" (formSubmit)="onSubmit($event)" />
    }
  `,
})
export class MonFormulaireComponent {
  private formService = inject(FormConfigService);
  selectedCode = signal('contact-form');
  activeConfig = computed(() =>
    this.formService.getByCode(this.selectedCode())?.config as DynamicFormConfig ?? null
  );
  onSubmit(event: DynamicFormSubmitEvent): void { console.log(event.value); }
}
```

> `getByCode()` (synchrone) suppose que `schemas()` est déjà peuplé — correct pour un composant monté après la liste (ex: dialog ouvert depuis un écran qui a déjà chargé la liste), mais risque une course sinon. Pour un composant qui peut être le premier point d'entrée (route directe, lien partagé), préférez `fetchByCode()` (v1.7.0, asynchrone, ne dépend pas de la liste complète) — voir [FormConfigService](#formconfigservice--gestion-des-schémas).

### Approche B — stepper chargé par code (détection automatique)

```typescript
import { Component, computed, inject, signal } from '@angular/core';
import { DynamicFormComponent, StepperFormComponent, FormConfigService } from '@suntelecoms/ngx-dynamic-form';
import type { DynamicFormConfig, StepperFormConfig, DynamicFormSubmitEvent } from '@suntelecoms/ngx-dynamic-form';

@Component({
  standalone: true,
  imports: [DynamicFormComponent, StepperFormComponent],
  template: `
    @if (flatConfig(); as flatCfg) {
      <ngx-dynamic-form [config]="flatCfg" (formSubmit)="onSubmit($event)" />
    }
    @if (stepperConfig(); as stepperCfg) {
      <ngx-stepper-form [config]="stepperCfg" (formSubmit)="onSubmit($event)" />
    }
  `,
})
export class MonFormulaireComponent {
  private formService = inject(FormConfigService);
  selectedCode = signal('inscription-2024');

  private anyConfig = computed(() => this.formService.getByCode(this.selectedCode())?.config ?? null);

  flatConfig = computed<DynamicFormConfig | null>(() => {
    const c = this.anyConfig();
    return c && !('steps' in c) ? c as DynamicFormConfig : null;
  });

  stepperConfig = computed<StepperFormConfig | null>(() => {
    const c = this.anyConfig();
    return c && 'steps' in c ? c as StepperFormConfig : null;
  });

  onSubmit(event: DynamicFormSubmitEvent): void { console.log(event.value); }
}
```

### Approche C — configuration TypeScript statique

```typescript
import { Component } from '@angular/core';
import { DynamicFormComponent } from '@suntelecoms/ngx-dynamic-form';
import type { DynamicFormConfig, DynamicFormSubmitEvent } from '@suntelecoms/ngx-dynamic-form';

@Component({
  standalone: true,
  imports: [DynamicFormComponent],
  template: `<ngx-dynamic-form [config]="config" (formSubmit)="onSubmit($event)" />`,
})
export class ContactComponent {
  config: DynamicFormConfig = {
    submitLabel: 'Envoyer',
    fields: [
      { key: 'nom',   type: 'text',  label: 'Nom',     col: 6, validation: { required: true } },
      { key: 'email', type: 'email', label: 'E-mail',  col: 6, validation: { required: true } },
    ],
  };
  onSubmit(event: DynamicFormSubmitEvent): void { console.log(event.value); }
}
```

> **Astuce :** créez le formulaire dans le Builder (`/builder`) → cliquez **Export TypeScript** → collez dans votre composant.

### Approche D — avec backend API REST (Spring Boot)

Configurez `provideNgxForms({ storage: 'http', apiUrl: '...' })` — tout le reste est identique.

```typescript
// app.config.ts
provideNgxForms({
  storage: 'http',
  apiUrl: 'https://api.monprojet.com/api/forms',
}),
provideHttpClient(),
```

```java
// Exemple Spring Boot — FormSchemaController.java
@RestController
@RequestMapping("/api/forms")
public class FormSchemaController {

  @GetMapping                      List<FormSchema> findAll()                              { ... }
  @GetMapping("/{id}")             FormSchema       findById(@PathVariable String id)      { ... }
  @GetMapping("/code/{code}")      FormSchema       findByCode(@PathVariable String code)  { ... }
  @PostMapping                     ResponseEntity<FormSchema> create(@RequestBody FormSchema s) { ... }
  @PutMapping("/{id}")             FormSchema       update(@PathVariable String id,
                                                          @RequestBody FormSchema s)       { ... }
  @DeleteMapping("/{id}")          ResponseEntity<Void> delete(@PathVariable String id)    { ... }
}
```

> L'id est généré par le serveur (`UUID.randomUUID()`) lors du `POST`. Le client envoie uniquement le `code` (choisi par l'utilisateur), le `title` et le `config`.

---

## Pages de démonstration

L'application de démonstration (`ng serve`) illustre le plugin sur plusieurs cas d'usage :

| Route | Description |
|---|---|
| `/` | Page d'accueil |
| `/inscription` | Formulaire multi-sections avec `showWhen` |
| `/contact` | Formulaire de contact (select, textarea, radio) |
| `/produit` | Fiche produit — modes vue / édition / création |
| `/builder` | **Form Builder** — modes Formulaire et Stepper |
| `/mes-formulaires` | **Gestionnaire** — liste et aperçu de tous les formulaires |
| `/form/:code` | **Rendu par code** — formulaire simple ou stepper automatiquement |

---

## Exemples complets

### Formulaire d'inscription avec showWhen

```typescript
config: DynamicFormConfig = {
  submitLabel: 'Créer mon compte',
  showReset: true,
  fields: [
    { key: '_info', type: 'heading', text: 'Informations', level: 3, col: 12 },
    { key: 'firstName', type: 'text',  label: 'Prénom', col: 6, icon: 'person',
      validation: { required: true } },
    { key: 'lastName',  type: 'text',  label: 'Nom',    col: 6, icon: 'badge',
      validation: { required: true } },
    { key: 'email',     type: 'email', label: 'E-mail', col: 12, icon: 'email',
      validation: { required: true, email: true } },
    { key: '_div', type: 'divider', col: 12 },
    { key: 'accountType', type: 'radio', label: 'Type de compte', col: 12,
      defaultValue: 'personal',
      options: [
        { label: 'Particulier',   value: 'personal' },
        { label: 'Professionnel', value: 'pro' },
      ] },
    { key: 'company', type: 'text', label: 'Entreprise', col: 6, icon: 'business',
      showWhen: { field: 'accountType', operator: 'equals', value: 'pro' } },
    { key: 'siret', type: 'text', label: 'SIRET', col: 6, icon: 'tag',
      showWhen: { field: 'accountType', operator: 'equals', value: 'pro' },
      validation: { minLength: 14, maxLength: 14 } },
    { key: 'terms', type: 'checkbox',
      placeholder: "J'accepte les CGU *", col: 12,
      validation: { required: true, messages: { required: 'Vous devez accepter les CGU.' } } },
  ],
};
```

### Formulaire stepper — processus d'inscription en 3 étapes

```typescript
config: StepperFormConfig = {
  showSummary: true,
  submitLabel: 'Créer mon compte',
  steps: [
    {
      label: 'Identité',
      icon: 'person',
      fields: [
        { key: 'firstName', type: 'text',     label: 'Prénom', col: 6, validation: { required: true } },
        { key: 'lastName',  type: 'text',     label: 'Nom',    col: 6, validation: { required: true } },
        { key: 'birthDate', type: 'date',     label: 'Date de naissance', col: 6 },
        { key: 'gender',    type: 'select',   label: 'Genre', col: 6,
          options: [{ label: 'Homme', value: 'M' }, { label: 'Femme', value: 'F' }] },
      ],
    },
    {
      label: 'Coordonnées',
      icon: 'contact_mail',
      fields: [
        { key: 'email', type: 'email', label: 'E-mail', col: 12,
          validation: { required: true, email: true } },
        { key: 'phone', type: 'tel',   label: 'Téléphone', col: 6 },
        { key: 'city',  type: 'text',  label: 'Ville', col: 6 },
      ],
    },
    {
      label: 'Sécurité',
      icon: 'lock',
      fields: [
        { key: 'password', type: 'password', label: 'Mot de passe', col: 6,
          validation: { required: true, custom: v => v?.length < 8 ? 'Minimum 8 caractères' : null } },
        { key: 'terms', type: 'checkbox', placeholder: "J'accepte les CGU", col: 12,
          validation: { required: true } },
      ],
    },
  ],
};
```

### Fiche produit avec préremplissage et mode vue/édition

```typescript
mode = signal<'view' | 'edit'>('edit');
prefill = { name: 'Casque Pro X1', price: 129.99, stock: 42 };

get config(): DynamicFormConfig {
  const ro = this.mode() === 'view';
  return {
    submitLabel: 'Sauvegarder',
    fields: [
      { key: 'name',  type: 'text',   label: 'Nom',   col: 8, icon: 'inventory', readonly: ro,
        validation: { required: true } },
      { key: 'price', type: 'number', label: 'Prix',  col: 4, icon: 'euro',      readonly: ro },
      { key: 'stock', type: 'number', label: 'Stock', col: 4, icon: 'inventory_2', readonly: ro },
    ],
  };
}
```

```html
<ngx-dynamic-form
  [config]="config"
  [initialValues]="prefill"
  (formSubmit)="onSubmit($event)"
/>
```

---

## Personnalisation CSS

```css
/* styles.css */
:root {
  --ngx-form-gap: 1.25rem;
  --ngx-color-border: #e2e8f0;
  --ngx-color-text: #1a202c;
  --ngx-color-hint: #718096;
}
```

Pour les couleurs des champs Material (outline, focus, erreur), personnalisez le thème Angular Material selon la [documentation officielle](https://material.angular.io/guide/theming).

---

## Interfaces TypeScript

```typescript
// Exports publics de @suntelecoms/ngx-dynamic-form

// Composants
export { DynamicFormComponent }     // <ngx-dynamic-form>
export { StepperFormComponent }     // <ngx-stepper-form>   ← v1.2.0
export { FormBuilderComponent }     // <ngx-form-builder>
export { MesFormulairesComponent }  // <ngx-mes-formulaires>
export { FormLoaderComponent }      // <ngx-form-loader>

// Services & Providers
export { FormConfigService }            // inject(FormConfigService)
export { UniquenessValidatorService }   // validateur async unicité  ← v1.4.0
export { FormStorageAdapter }           // classe abstraite de stockage
export { LocalStorageAdapter }          // implémentation localStorage
export { HttpStorageAdapter }           // implémentation API REST
export { FORM_API_URL }                 // InjectionToken<string>
export { provideNgxForms }              // function(options) → EnvironmentProviders
export { NGX_DATA_API_URL }             // InjectionToken<string> — base URL pour les options dynamiques  ← v1.2.5
export { ngxFormsAuthInterceptor }      // HttpInterceptorFn — ajoute Authorization: Bearer  ← v1.3.0
export { NgxFormsAuthService }          // service token/cache  ← v1.3.0
export { NGX_FORMS_AUTH }              // InjectionToken<NgxFormsAuthOptions>  ← v1.3.0

// Types
export type { NgxFormsOptions }         // { storage: 'local' } | { storage: 'http', apiUrl: string }
export type { NgxFormsAuthOptions }     // { loginUrl, username, password }  ← v1.3.0
export type { AnyFormConfig }         // DynamicFormConfig | StepperFormConfig  ← v1.2.0
export type { SavedFormSchema }       // { id, code, title, config, createdAt, updatedAt }  ← v1.2.9
export type { DynamicFormConfig }
export type { DynamicFormField }
export type { DynamicFormSubmitEvent }
export type { StepperFormConfig }     // ← v1.2.0
export type { FormStep }              // ← v1.2.0
export type { FieldType }
export type { FieldValidation }
export type { FieldCondition }
export type { SelectOption }

// Phone field  ← v1.4.0
export type { PhoneCountry }   // { code, name, dialCode, flag, mask }
export { PHONE_COUNTRIES }     // PhoneCountry[] — 135 pays préconfigurés

// Lookup & Guard  ← v1.6.0
export type { FieldLookup }    // { url, method?, populate, triggerOn? }
export type { StepGuard }      // { url, method?, withFormValues?, resultKey?, errorMessage? }
```

---

## Changelog

### v1.7.22
- **Options statiques en cascade configurables dans le Form Builder** : en options *Statiques*, nouveau choix « Dépend de » (`dependsOn`) et, sur chaque option, colonne « Si ‹parent› = » qui renseigne `dependsOnValue` — liste déroulante des valeurs du parent quand il a des options statiques, saisie libre sinon. Voir [Configurer depuis le Form Builder](#configurer-depuis-le-form-builder-v1722).
- **Comparaison `dependsOnValue` en texte** : `"3"` correspond à `3`, ce qui rend utilisables les valeurs saisies dans le Form Builder (toujours des chaînes).
- **Valeur invalide effacée** : un champ à options statiques dépendantes remet sa valeur à `null` quand elle n'est plus proposée après un changement du champ parent (`multiselect` : valeurs non proposées retirées).
  ```json
  { "key": "periodeValeur", "type": "select", "dependsOn": "periode",
    "options": [ { "label": "3 mois", "value": "3", "dependsOnValue": "MOIS" },
                 { "label": "30 jours", "value": "30", "dependsOnValue": "JOUR" } ] }
  ```

### v1.7.21
- **`thousandSeparator` sur les champs `number`** : saisie d'un montant avec séparateur de milliers — l'input affiche « 20 000 » / « 1 250 000,5 », le `FormControl` reçoit le nombre `20000` / `1250000.5`. Virgule ou point acceptés comme décimale, caractères non numériques ignorés, reformatage à chaque frappe sans déplacer le curseur, décimale en suspens (« 12, ») retirée en quittant le champ. `true` = espace ; une chaîne choisit le séparateur (ex: `'.'`). Opt-in : sans la propriété, le champ reste un `<input type="number">` natif.
  ```ts
  { key: 'valeurANeuf', type: 'number', label: 'Valeur à neuf', thousandSeparator: true, suffix: 'FCFA' }
  ```
- Implémenté par `ThousandsInputDirective` (exportée), accesseur de valeur de l'input : `mat-form-field` garde ses états d'erreur, les valeurs posées par `initialValues` / `patchValue` s'affichent formatées.
- **Form Builder** : case « Séparateur de milliers » dans la section *Saisie* des champs `number`.

### v1.7.20
- **Fix champ `phone` — valeur non affichée en modification** : la saisie du numéro n'était liée à aucune valeur (seul `applyPhoneMask` l'alimentait à la frappe) et `ngOnInit` ne lisait pas la valeur du `FormControl` — un formulaire ouvert en édition (`initialValues`, `patchValue`) montrait un téléphone vide alors que le `FormControl` contenait bien le numéro. La saisie suit désormais la valeur du contrôle, à l'initialisation et à chaque changement posé de l'extérieur :
  - `'+22222123456'` / `'0022222123456'` → pays déduit de l'indicatif (Mauritanie), saisie `22 12 34 56` selon le masque du pays ;
  - indicatifs partagés : le plus long l'emporte (`+1242` avant `+1`), à égalité le pays déjà sélectionné est conservé ;
  - numéro sans indicatif (`'774127793'`) : le pays courant (`defaultCountry`) est gardé, le numéro s'affiche masqué ;
  - la valeur du `FormControl` n'est pas réécrite tant que l'utilisateur ne modifie pas le champ.

### v1.7.4
- **Documentation README** — badge de version mis à jour, `copyFrom` ajouté dans les fonctionnalités, la table des propriétés et le changelog

### v1.7.3
- **Form Builder — propriétés manquantes ajoutées** : le builder visuel expose désormais toutes les propriétés de `DynamicFormField` qui n'avaient pas de contrôle UI — section *Saisie* : `defaultValue`, `disabled`, `hidden`, `copyFrom` ; section *Validation* : `minDate`/`maxDate` (champs de type date) ; section *Options → Depuis une API* : `responseDataKey`, `dependsOn`, `dependsOnMode`, `dependsOnItemKey`, `dependsOnOffset` ; section *Auto-remplissage* : `lookup.triggerOn`

### v1.7.2
- **`copyFrom`** : nouvelle propriété sur `DynamicFormField` — désigne la clé d'un champ frère ; à chaque changement de ce champ source, la valeur est automatiquement reflétée dans ce champ cible (utilise `valueChanges` avec `emitEvent: false` pour éviter les boucles). Exemple : synchroniser automatiquement "Valeur vénale" sur "Valeur à neuf" à la saisie.
- **`defaultValue`** : propriété déjà présente dans le modèle mais maintenant documentée et exposée dans le builder — initialise le champ avec une valeur arbitraire (`any`) à l'ouverture du formulaire.

### v1.7.1
- **Fix `FormBuilderComponent` / `MesFormulairesComponent` — édition par `code` au lieu de `id`** : le préchargement par route ajouté en v1.7.0 utilisait `fetchById()` / `:id`. Or certains backends ne résolvent pas correctement `GET /forms/:id` (renvoie `{ data: null }` même pour un id existant) alors que `GET /forms/code/:code` fonctionne. Basculé sur `fetchByCode()` / `:code` — aussi plus cohérent avec `FormLoaderComponent`, qui utilisait déjà `:code`. **Breaking pour qui aurait déjà adopté le `:id` de la v1.7.0** : route à renommer en `builder/:code`, et tout consommateur de `[builderRoute, schema.id]` doit passer à `schema.code`.

### v1.7.0
- **`FormConfigService.fetchById(id)` / `fetchByCode(code)`** : lecture asynchrone d'un seul formulaire directement via l'API (`GET {apiUrl}/{id}` et `GET {apiUrl}/code/{code}`), sans dépendre de la liste complète chargée en mémoire. À préférer à `get()`/`getByCode()` (toujours disponibles, inchangées) dès qu'un composant n'a besoin que d'UN formulaire — reste correct même avec des centaines de formulaires enregistrés. Alimente aussi le cache local (`schemas()`) au passage.
- **Option `listPageSize`** dans `provideNgxForms({ storage: 'http', ... })` (défaut `1000`) : taille de page envoyée sur `GET {apiUrl}?page=0&size=...` pour charger le catalogue. **Fix** : `list()` n'envoyait auparavant aucun paramètre de pagination, ce qui tronquait silencieusement le catalogue à la taille de page par défaut du serveur dès qu'elle était dépassée.
- **Fix `FormBuilderComponent` — édition par route** : le composant n'avait aucune conscience de la route (`ActivatedRoute`) et ignorait tout `:id` — naviguer vers une route d'édition ouvrait systématiquement un Builder vide. Il précharge désormais automatiquement le formulaire via `fetchById()` si monté avec un paramètre de route `:id` (ex: `path: 'builder/:id'`).
- **Fix `MesFormulairesComponent` — liens "Éditer" et "Builder"** : pointaient vers `builderRoute` sans id, donc ne préchargeaient jamais le formulaire sélectionné même une fois le fix ci-dessus en place. Pointent désormais vers `[builderRoute, schema.id]`.
- **Fix `FormLoaderComponent`** : `getByCode()` synchrone en `ngOnInit` risquait une course avec le chargement initial de la liste (formulaire annoncé "introuvable" alors qu'il existait). Utilise désormais `fetchByCode()` ; nouvel état `loading()` affiché pendant la requête.

### v1.6.6
- **Bornes de date `minDate` / `maxDate`** : propriétés sur `DynamicFormField` pour le type `date` — `'today'` (résolu dynamiquement à chaque rendu) ou une date ISO fixe (`'2026-01-01'`). Bornent le `mat-datepicker` (apparence `outline`/`fill`) et l'attribut natif `min`/`max` de l'`<input type="date">` (apparence `simple`). Message d'erreur dédié (`messages.minDate` / `messages.maxDate`) quand la date choisie sort de l'intervalle — auparavant ces cas remontaient un générique « Valeur invalide. »

### v1.6.0
- **Auto-remplissage `lookup`** : propriété `lookup: FieldLookup` sur `DynamicFormField` — à la perte de focus, requête GET ou POST vers `url` (interpolation `{{key}}`), remplissage des champs cibles via une map `populate` en notation pointée (`data.prenom`, etc.)
- **Required conditionnel `requiredWhen`** : propriété `requiredWhen: FieldCondition | FieldCondition[]` dans `FieldValidation` — active `Validators.required` dynamiquement sans code impératif dans l'hôte ; réévalué à chaque changement du formulaire
- **Validation croisée `maxField` / `minField`** : propriétés `maxField` et `minField` dans `FieldValidation` — validateur de groupe qui compare deux champs numériques et pose l'erreur directement sur le `FormControl` cible ; message personnalisable via `messages.maxField` / `messages.minField`
- **Au moins un obligatoire `atLeastOneOf`** : propriétés `atLeastOneOf: string[]` et `atLeastOneOfMessage` disponibles sur `DynamicFormConfig` et `FormStep` — validateur de groupe ; l'erreur est affichée sous le formulaire/l'étape
- **Guard de step `beforeNext`** : propriété `beforeNext: StepGuard` sur `FormStep` — requête HTTP avant l'avancement ; HTTP 4xx bloque la navigation et affiche le message d'erreur du serveur ; `resultKey` optionnel pour stocker la réponse 2xx dans `stepResults`
- **Événement `fieldChange`** : sortie `(fieldChange)` sur `DynamicFormComponent` (`{ key, value, form }`) et `StepperFormComponent` (`{ key, value, step, form }`) — permet de piloter la logique métier (cascades, calculs) depuis le composant hôte sans modifier la config du formulaire
- **`getStepForm(i)`** : méthode publique sur `StepperFormComponent` — accès au `FormGroup` de l'étape `i` pour `patchValue` depuis l'hôte
- **Nouveaux types exportés** : `FieldLookup`, `StepGuard`

### v1.4.9
- **Fix espacement du menu pays** (champ `phone`, apparence `outline`/`fill`) : le drapeau, le nom et l'indicatif de chaque ligne du menu étaient collés sans espace. Cause : `MatMenuItem` projette tout contenu non-icône dans son propre `<span class="mat-mdc-menu-item-text">` interne — le `gap` posé sur le `<button>` lui-même n'avait donc aucun effet sur ses petits-enfants. Corrigé en ciblant ce span interne directement

### v1.4.8
- **Drapeaux en SVG** pour le sélecteur de pays du champ `phone` (apparence `outline`/`fill`) : remplace l'emoji Unicode par le package `flag-icons` (`fi fi-<code ISO2>`), qui rend correctement même sur Windows/webviews où les emoji drapeau retombent sur les deux lettres du code pays. Nouvelle peer dependency `flag-icons` — voir [Prérequis](#champ-téléphone-international--phone) (compiler la variante rectangulaire seule via Sass, le CSS prêt-à-l'emploi du package fait collisionner ses noms de fichiers carré/rectangulaire en build dev non hashé). L'apparence `simple` garde l'emoji (limite du `<select>` HTML natif, qui ne peut afficher que du texte dans ses `<option>`)

### v1.4.7
- **Champ `phone` revu** : en apparence `outline`/`fill`, rendu désormais dans un vrai `<mat-form-field>` (même apparence Material native que les autres champs) au lieu d'une bordure recréée à la main. Le sélecteur de pays devient un bouton `matPrefix` (drapeau + indicatif) ouvrant un `mat-menu` avec un champ de recherche et les 135 pays (drapeau, nom, indicatif) — inspiré de `ngx-intl-tel-input`, sans nouvelle dépendance (réutilise les emoji drapeau déjà présents dans `PHONE_COUNTRIES`). L'apparence `simple` garde le `<select>` HTML natif d'origine
- **Fix changement de pays** : sélectionner un nouveau pays ne duplique plus l'ancien indicatif dans le numéro (le calcul relisait à tort l'indicatif précédent depuis la valeur E.164 complète du `FormGroup` au lieu des seuls chiffres locaux déjà saisis)

### v1.4.6
- **`dependsOnMode: 'client'`** : pour les options distantes en cascade dont l'API ne supporte pas de paramètre de filtre — charge `url` une seule fois puis filtre localement à chaque changement du champ `dependsOn`, avec `dependsOnItemKey` (propriété de l'item à comparer) et `dependsOnOffset` (décalage numérique, ex : `-1` pour « le niveau immédiatement inférieur ») — reproduit le pattern `fetch once + array.filter(...)` des formulaires écrits à la main

### v1.4.5
- **Listes en cascade** (`dependsOn`) : un champ `select`/`multiselect`/`radio` peut référencer la clé d'un autre champ via `dependsOn` — ses options restent vides tant que le champ référencé n'a pas de valeur, puis sont recalculées à chaque changement. Options statiques : filtrage via `dependsOnValue` sur chaque `SelectOption`. Options distantes : `url` accepte désormais des placeholders `{{cléDuChamp}}` interpolés avec les valeurs courantes du formulaire, et la requête est relancée automatiquement à chaque changement du champ `dependsOn`

### v1.4.4
- **Réponses API enveloppées** : `loadRemoteOptions()` détecte automatiquement les réponses `{ data: [...] }` (ou toute autre clé via la nouvelle propriété `responseDataKey`) en plus des tableaux bruts — un avertissement est loggé si la forme de la réponse est inattendue plutôt que de planter
- **URL racine-relative pour les options distantes** : `url` commençant par `/` est désormais traité comme une URL complète résolue contre l'origine courante (comme les URLs `http...`), permettant à un champ de charger ses options depuis un service différent de `dataUrl` via le proxy déjà configuré par l'application hôte

### v1.4.0
- **Champ téléphone international** (`type: 'phone'`) : sélecteur de pays (19 pays), masque automatique par pays, valeur stockée en **E.164** dans le `FormGroup` ; propriété `defaultCountry` pour choisir le pays initial ; interfaces `PhoneCountry` et constante `PHONE_COUNTRIES` exportées
- **Logique ET / OU sur conditions multiples** : propriété `conditionLogic: 'AND' | 'OR'` sur `DynamicFormField` — `'AND'` toutes les conditions requises (comportement existant), `'OR'` au moins une condition — configurable depuis le Builder avec un toggle ET/OU qui apparaît dès qu'il y a plus d'une condition
- **Vérification d'unicité asynchrone** (`uniqueCheck`) : propriété `{ url, message? }` sur `DynamicFormField` — le plugin envoie `POST url { value }` avec debounce de 400 ms — auto-détecte `boolean` brut ou enveloppe `{ data: boolean }` ; `UniquenessValidatorService` exporté pour usage direct
- **Pattern builder UI** : dans le Form Builder, section Validation enrichie d'un champ regex, trois presets cliquables (chiffres, lettres, alphanumérique) et un champ de message d'erreur personnalisé pour la contrainte `pattern`
- **URL absolue dans le Builder pour les selects** : le champ URL des options depuis API accepte désormais une URL complète (ex. `http://mon-service/api/villes`) — prioritaire sur la `dataUrl` globale, adapté aux architectures microservices

### v1.3.1
- **Fix boucle infinie auth** : `NgxFormsAuthService` utilise désormais `HttpBackend` au lieu de `HttpClient` pour la requête de login — l'intercepteur `ngxFormsAuthInterceptor` n'intercepte plus la requête de login elle-même, évitant une récursion infinie qui empêchait le chargement des formulaires

### v1.3.0
- **Authentification Bearer token** : nouvelle option `auth` dans `provideNgxForms({ auth: { loginUrl, username, password } })`
- **`ngxFormsAuthInterceptor`** : intercepteur HTTP fonctionnel à ajouter dans `provideHttpClient(withInterceptors([ngxFormsAuthInterceptor]))` — injecte automatiquement le header `Authorization: Bearer <token>` sur toutes les requêtes
- **`NgxFormsAuthService`** : service de gestion du token JWT — login automatique au premier appel, cache en mémoire, renouvellement automatique 10 secondes avant expiration
- **No-op sans auth** : l'intercepteur est transparent si `auth` n'est pas configuré — aucune modification requise pour les APIs ouvertes
- **Compatibilité** : fonctionne avec les modes `storage: 'local'` et `storage: 'http'`

### v1.2.9
- **Code unique par formulaire** : nouvelle propriété `code` dans `SavedFormSchema` — identifiant court saisi par l'utilisateur (ex : `"contact-form"`), unique dans le stockage
- **Date de création** : nouvelle propriété `createdAt` dans `SavedFormSchema` — ISO 8601, non modifiable après création
- **API de sauvegarde séparée** : `FormConfigService.create(code, title, config)` pour les nouveaux formulaires, `update(schema, title, config)` pour les modifications — remplace l'ancienne méthode `save()`
- **Récupération par code** : `FormConfigService.getByCode(code)` et `FormStorageAdapter.getByCode(code)` — permet de charger un formulaire depuis son code sans connaître son id
- **Routage par code** : `FormLoaderComponent` lit désormais le paramètre de route `:code` (était `:id`) et appelle `getByCode()` — l'input `formCode` remplace `formId`
- **MesFormulairesComponent** : les liens vers `/form/:code` utilisent maintenant `schema.code` — affiche "Code :" à la place de "ID :" dans la liste
- **Form Builder** : champ **Code** dans la topbar, verrouillé après la première sauvegarde — `copyFormUrl()` génère `/form/<code>` au lieu de `/form/<id>`
- **Endpoint REST** : `GET /api/forms/code/:code` ajouté ; `POST /api/forms` pour la création (id UUID auto-généré côté serveur) ; `PUT /api/forms/:id` pour la mise à jour seule

### v1.2.8
- **6 nouveaux types de champs** : `switch` (`mat-slide-toggle`), `range` (`mat-slider`), `color` (color picker + affichage hex), `location` (géolocalisation GPS), `time`, `month`, `week`, `datetime-local` accessibles depuis le Builder
- **Datepicker Material** : le type `date` utilise désormais `mat-datepicker` (calendrier popup Material) — conversion string → Date automatique pour les valeurs préremplies
- **Géolocalisation** : le type `location` embarque un bouton GPS qui déclenche `navigator.geolocation.getCurrentPosition()` et remplit le champ avec `"lat,lng"`
- **Curseur range** : min, max, step configurables depuis le Builder — affichage de la valeur courante en temps réel
- **Couleurs bordure et label** : nouvelles propriétés `borderColor` et `labelColor` sur `DynamicFormField` — appliquées via CSS custom properties, ciblent les composants Material (MDC) via `:host[style*="..."]`

### v1.2.7
- **Apparence `simple`** : 3ème variante — label fixe au-dessus, input borduré avec `border-radius: 8px` — sans `mat-form-field`. Disponible globalement ou par champ
- **Masque de saisie** : propriété `mask` — `#` = chiffre, autres chars = séparateurs. Présets dans le Builder (téléphone SN, international, date, heure, CB, code postal)
- **Préfixe / suffixe texte** : propriétés `prefix` et `suffix` — affichés à l'intérieur du champ
- **Styling complet par champ** : `fontSize`, `textAlign`, `color`, `backgroundColor`, `fontWeight`, `fontStyle`, `cssClass`, `styles` (CSS inline arbitraire)
- **Apparence globale configurable** : `DynamicFormConfig.appearance` et `.floatLabel` — le Builder expose ces réglages dans "Paramètres du formulaire"

### v1.2.6
- Fix : en mode édition, les champs `select`/`multiselect`/`radio` avec `url` reçoivent correctement leur valeur initiale même si l'API retourne un objet imbriqué
- Fix appliqué aussi dans `StepperFormComponent`

### v1.2.5
- Options dynamiques depuis une API pour les champs `select`, `multiselect`, `radio` : propriétés `url`, `displayName`, `displayValue`
- Token `NGX_DATA_API_URL` et option `dataUrl` dans `provideNgxForms()`
- Form Builder : toggle **Statiques / Depuis une API** dans le panneau Options
- Spinner de chargement dans le dropdown pendant le fetch des options

### v1.2.0
- Ajout du composant `<ngx-stepper-form>` pour les formulaires multi-étapes
- Étape **Récapitulatif** automatique (`showSummary: true`) avec boutons Modifier par étape
- Mode **Stepper** dans le Form Builder
- `AnyFormConfig` = `DynamicFormConfig | StepperFormConfig`
- `MesFormulairesComponent` et `FormLoaderComponent` détectent et rendent les deux types

### v1.1.0
- Grille 12 colonnes (`col`)
- Form Builder, MesFormulaires et FormLoader intégrés dans le plugin
- Système de stockage abstrait (localStorage / API REST)
- `FormConfigService` Signal-based

---

## Auteur

**Amadou Diallo** — [@amepro](https://github.com/amepro)
📧 [amadoumacbookpro38@gmail.com](mailto:amadoumacbookpro38@gmail.com)

Développé dans le cadre du projet **SUNTELECOMS** — Plugin Angular 19 standalone.

---

*MIT License — Libre d'utilisation, de modification et de distribution.*
