Angular – Créer une liste de sélection complexe avec ng-template (list-view)

Angular permet de créer facilement des composants Web très puissant grâce à la gestion de template. C’est ce que nous allons utiliser pour créer une liste complexe.

1. Le besoin

En HTML, la gestion des listes est très basique et le rendu est (moche) non personnalisable. Le simple code suivant affiche une liste dont les éléments sont du simple texte non personnalisable.

<select>
    <option value="0"></option>
    <option value="1">Facebook</option>
    <option value="2">Google</option>
    <option value="3">Microsoft</option>
</select>
Liste sous Chrome

Le besoin est par exemple d’avoir une liste pour laquelle le logo de la société est affichée à gauche du nom, ou pouvoir avoir une présentation complexe.

2. La conception

Pour implémenter notre liste, nous allons créer un composant spécialisé qui va gérer le menu de la liste. Le développeur gérera lui-même le tag de l’input, le composant viendra s’accrocher dessus.

<input type="text" name="select" #monInput>
<list-view [field]="monInput" ...>
   ...
</list-view>

Cette solution est très simple à mettre en oeuvre et laisse le développeur libre d’ajouter du style ou des erreurs à l’input.

3. L’implémentation

Nous créons un composant classique Angular avec les fichiers :
– Le contrôleur : list-view.component.ts
– Le template : list-view.component.html
– Le style : list-view.component.less

3.1. Définition du contrôleur

Toutes les propriétés sont décrites en commentaire, mais rien de très compliqué 😉

Component({
    selector: 'list-view',
    templateUrl: 'list-view.component.html'
})
export class ListViewComponent implements OnInit {
    constructor() { }

    // le lien vers l'input
    @Input() field: HTMLInputElement; 
    // liste des items de la liste
    @Input() items: any[];
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyDisplay = 'text';

    // valeur sélectionnée entrée / sortie
    @Input() ngModel: any;
    @Output() ngModelChange = new EventEmitter<any>();

    // Est-ce que le menu de la liste est visible ?
    public visible = false;

    //

    // Initialisation
    ngOnInit() { }
}

Ensuite, nous allons appliquer un style sur notre « input » pour qu’il ressemble le plus possible à un « select » et restreindre les possibilité d’écriture.

private stylyzeField(field: HTMLInputElement) {
    field.setAttribute('autocomplete', 'off'); 
    field.setAttribute('readonly', 'readonly');
    field.style.background = '#FFFFFF url(./arrow-bottom.png) no-repeat right';
    field.style.backgroundSize = '23px 25px';
    field.style.cursor = 'pointer';
    field.style.paddingRight = '28px';
    field.style.userSelect = 'none';

    // Empêche la sélection du texte dans l'input text
    field.addEventListener('select', () => {
        field.selectionStart = field.selectionEnd;
    });
}

ngOnInit() {
    if (this.field) this.stylyzeField(this.field);
}

En résumé, l’input est mis en lecture seule avec une image de flèche positionnée à droite. L’image utilisée est la suivante.

Enfin, nous allons gérer la visibilité du menu de la liste. L’événement « click » de l’input est accroché et on inverse la valeur de visibilité de menu de la liste.

ngOnInit() {
    if (this.field) {
        this.stylyzeField(this.field);
        this.field.addEventListener('click', () => {
            this.visible = !this.visible;
         });
    }
}

Nous ajoutons également la méthode permettant de sélectionner un élément dans la liste et de le renvoyer vers le composant parent. Egalement, nous forçons l’affichage du texte dans l’input..

public select(model: any) {
    this.ngModelChange.emit(model); // envoyer la sélection vers le parent
    this.field.value = model[this.propertyDisplay];
    this.visible = false; // fermeture du menu
}

3.2 Le template

Cette partie est plus complexe car elle intègre la notion de template personnalisé.
Voici le début de l’implémentation :

<div class="list-view" *ngIf="visible">
   <div *ngFor="let i of items" (click)="select(i)">
   </div>
</div>

Le HTML décrit notre menu dont l’affichage dépend du paramètre « visible ». Lui-même contient la liste des item générés par un classique « *ngFor ».
Au clic sur l’un des items, la méthode « select » est appelée.

Pour gérer notre template personnalisé, nous allons ajouter la déclaration suivante dans la partie contrôleur :

@ContentChild(TemplateRef, { static: false }) template;

Cette ligne permet déclarer une variable « template » et donne accès à l’élément déclaré dans le HTML du composant. Dans l’exemple ci-dessous, cela correspond à la balise « <ng-template> » qui est de type Angular « TemplateRef ».

<input type="text" name="select" #monInput>
<list-view [field]="monInput">
    <ng-template></ng-template>
</list-view>

Retournons maintenant dans le template de notre composant pour le compléter :

<div *ngFor="let i of items" (click)="select(i)">
    <ng-container *ngTemplateOutlet="template; context:{item: i}">
    </ng-container>
</div>

La nouvelle ligne « ng-container » utilise l’attribut « *ngTemplateOutlet » pour injecter le « template » définie dans la partie contrôleur. De plus, la propriété « context » permet de décrire un objet contextuel au template. Nous y déclarons une variable « item » à laquelle l’item courant est affecté.

3.3 Le style

La dernière partie concerne le style de notre menu qui est décrit dans un fichier LESS.

.list-view {
    background: #FFFFFF;
    color: #444444;
    box-shadow: 3px 0 3px 0 rgba(0,0,0,0.1), 0 2px 2px 0 rgba(0,0,0,0.15);
    max-height: 250px;
    overflow-y: auto;
    position: absolute;
    user-select: none;
    z-index: 9999;

    & > div {
        align-items: stretch;
        justify-content: stretch;
        border-bottom: 1px solid #AAAAAA;
        cursor: pointer;
        display: flex;
        flex-direction: row;
        font-size: 1rem;
        line-height: 29px;
        padding: 5px 10px;

        &:hover {
            background: #AAAAAA;
        }
        &:last-child {
            border-bottom: none;
        }
    }
}

4. L’utilisation

Nous avons enfin défini notre liste personnalisée, il ne reste plus qu’à l’utiliser 😀
Imaginons un composant Angular dans lequel nous avons défini une liste de sociétés avec leur icône. Le but est d’en sélectionnée une.

Déclaration dans le contrôleur
Nous déclarons la valeur qui sera sélectionnée, ainsi que la liste des éléments sélectionnables : un id, un texte et une icône (url externe).

public itemSelected: any;
public itemList = [
   { id: 1, text: 'Facebook', icon: 'https://upload.wikimedia.org/wikipedia/commons/1/16/Facebook-icon-1.png' },
   { id: 2, text: 'Google', icon: 'https://upload.wikimedia.org/wikipedia/commons/5/53/Google_%22G%22_Logo.svg' },
   { id: 3, text: 'Microsoft', icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Microsoft_Office_logo_%282019%E2%80%93present%29.svg' }
];

Déclaration dans le template
C’est la partie qui nous intéresse le plus car elle décrit notre template personnalisé.
Dans la balise « ng-template », nous créons une variable « oneItem » grâce à l’attribut « let-oneItem » à laquelle nous affectons « item » qui est la valeur définie dans le « context » de notre contrôleur (voir 3.2).

Le début de nom d’attribut « let- » est reconnu par Angular et permet de déclarer une variable locale. Nous pouvons ainsi utiliser la variable « oneItem » pour accéder à l’objet d’une société de la liste « itemList » dans le template « ng-template ».
Cela nous permet de donner une forme personnalisée à notre liste.

<input type="text" name="select" #monInput>
<list-view [field]="monInput" [items]="itemList" [(value)]="itemSelected">
    <ng-template let-oneItem="item">
        <div style="display: flex; align-items: center;">
           <img src="{{oneItem.icon}}" style="width: 25px; height: 25px; padding-right: 10px;" />
           <div>{{oneItem.text}}</div>
        </div>
    </ng-template>
</list-view>

Pour des raisons de simplicité, j’ai directement intégré le CSS dans le template, mais il est plus propre de le sortir dans un fichier CSS global à part.

Le résultat est beaucoup plus sympa que la version d’origine 🙂

5. Quelques améliorations

Cet exemple vous donne la base pour créer des template personnalisé avec répétition. Dans l’exemple de la liste, il manque quelques points pour la rendre plus propre.

Fermer la liste si l’on clique à côté du menu
Il faut ajouter le code suivant qui vérifie si l’utilisateur clique en dehors du menu.

<div #menuList>
    <div class="list-view" *ngIf="visible">
        <div *ngFor="let i of items" (click)="select(i)">
            <ng-container *ngTemplateOutlet="template; context:{item: i}"></ng-container>
        </div>
    </div>
</div>
@ViewChild('menuList', { read: ElementRef, static: true }) resultList: ElementRef;

@HostListener('document:click', ['$event'])
clickedOutside(event: Event) {
    const elt = event.target as Element;
    if (elt !== this.field) {
        if (this.menuList && this.menuList.nativeElement !== event.target 
           && !this.menuList.nativeElement.contains(elt)) {
            this.visible = false;
        }
    }
}

Sélectionner une valeur initiale
Il faut ajouter le code suivant qui sélectionne la valeur par défaut à l’initialisation.

// nom de la propriété de l'item qui sera affichée dans l'input
@Input() propertyId = 'id';
// sélectionner la 1ère valeur par défaut
@Input() selectFirstValue = true;

export class ListViewComponent implements OnInit, OnChanges {
...
    ngOnChanges() {
        this.initializeDefaultSelection();
    }

    private initializeDefaultSelection() {
        if (this.items && this.items.length > 0) {
            let item = null;

            if (this.value) {
                item = this.items.find(x => 
                       x[this.propertyId] === this.value[this.propertyId]);
            } else if (this.selectFirstValue) {
                item = this.items[0];
            }
            if (item !== null) this.select(item);
        }
    }
}

Il est encore possible d’aller plus loin :
– Remplacer l’input par l’affichage du template personnalisé
– Permettre de taper du texte dans l’input pour rechercher les éléments
– Faire un appel asynchrone pour récupérer une liste d’élément depuis un serveur
– Modifier la largeur du menu pour qu’il est la même que l’input
– …

6. Code source complet

Pour finir, voici le code source complet des 3 fichiers.

list-view.component.ts

import {
    Component,    ContentChild,    ElementRef,    EventEmitter,
    HostListener,    Input,    OnChanges,    OnInit,    Output,
    TemplateRef,    ViewChild,
} from '@angular/core';

@Component({
    selector: 'list-view',
    templateUrl: 'list-view.component.html',
    styleUrls: ['./list-view.component.less']
})
export class ListViewComponent implements OnInit, OnChanges {
    constructor() { }

    //// les entrées / sorties

    // le lien vers l'input
    @Input() field: HTMLInputElement;
    // liste des items de la liste
    @Input() items: any[];
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyId = 'id';
    // nom de la propriété de l'item qui sera affichée dans l'input
    @Input() propertyDisplay = 'text';
    // sélectionner la 1ère valeur par défaut
    @Input() selectFirstValue = true;

    // valeur sélectionnée entrée / sortie
    @Input() value: any;
    @Output() valueChange = new EventEmitter<any>();

    //

    // Est-ce que le menu de la liste est visible ?
    public visible = false;

    @ViewChild('menuList', { read: ElementRef, static: true }) menuList: ElementRef;
    @ContentChild(TemplateRef, { static: false }) template;

    //

    ngOnInit() {
        if (this.field) {
            this.stylyzeField(this.field);

            this.field.addEventListener('click', () => {
                this.visible = !this.visible;
            });
        }
    }

    ngOnChanges() {
        this.initializeDefaultSelection();
    }

    @HostListener('document:click', ['$event'])
    clickedOutside(event: Event) {
        const elt = event.target as Element;
        if (elt !== this.field) {
            if (this.menuList && this.menuList.nativeElement !== event.target && !this.menuList.nativeElement.contains(elt)) {
                this.visible = false;
            }
        }
    }

    public select(model: any) {
        this.valueChange.emit(model);
        this.field.value = model[this.propertyDisplay];
        this.visible = false;
    }

    //

    private stylyzeField(field: HTMLInputElement) {
        field.setAttribute('autocomplete', 'off');
        field.setAttribute('readonly', 'readonly');
        field.style.background = '#FFFFFF url(/Styles/images/arrow-bottom.png) no-repeat right';
        field.style.backgroundSize = '23px 25px';
        field.style.cursor = 'pointer';
        field.style.paddingRight = '28px';
        field.style.userSelect = 'none';

        field.addEventListener('select', () => {
            field.selectionStart = field.selectionEnd;
        });
    }

    private initializeDefaultSelection() {
        if (this.items && this.items.length > 0) {
            let item = null;

            if (this.value) {
                item = this.items.find(x => x[this.propertyId] === this.value[this.propertyId]);
            } else if (this.selectFirstValue) {
                item = this.items[0];
            }
            if (item !== null) this.select(item);
        }
    }

}

list-view.component.html

<div #menuList>
    <div class="list-view" *ngIf="visible">
        <div *ngFor="let i of items" (click)="select(i)">
            <ng-container *ngTemplateOutlet="template; context:{item: i}"></ng-container>
        </div>
    </div>
</div>

list-view.component.less

.list-view {
    background: #FFFFFF;
    color: #444444;
    box-shadow: 3px 0 3px 0 rgba(0,0,0,0.1), 0 2px 2px 0 rgba(0,0,0,0.15);
    max-height: 250px;
    overflow-y: auto;
    position: absolute;
    user-select: none;
    z-index: 9999;

    & > div {
        align-items: stretch;
        justify-content: stretch;
        border-bottom: 1px solid #AAAAAA;
        cursor: pointer;
        display: flex;
        flex-direction: row;
        font-size: 1rem;
        line-height: 29px;
        padding: 5px 10px;

        &:hover {
            background: #AAAAAA;
        }
        &:last-child {
            border-bottom: none;
        }
    }
}