Web Component dans un formulaire

Publié le

Avec un Web Component, on peut réaliser tout un tas de choses. A l’intérieur d’un tag HTML personnalisé, on peut écrire le code javascript que l’on veut pour mettre en place exactement ce que l’on souhaite.
Un player video qui remplace en lieu et place le tag <video>, mais avec des controls personnalisés, c’est possible. Un outil qui gère l’installation et la mise à jour d’un service Worker aussi.
L’avantage majeure étant que le Web Component maîtrise exactement ce qu’il souhaite exposer ou non, et est donc responsable de tout ce qu’il contient.

Et quant il s’agit des formulaires et de leurs <input>, ça devient plus compliqué.
Imaginons que l’on veuille remplacer un <select> natif par un Web Component pour proposer des fonctionnalités supplémentaires, comme une recherche et un visuel beaucoup plus joli.
C’est faisable depuis longtemps en rajoutant des couches de javascript qui fonctionne tous plus ou moins de la même manière : cacher le select et rajouter toute une interface devant pour réaliser ce que l’on veut.

Ce qu’il est possible de faire aujourd’hui, c’est de remplacer litéralement le <select> par un <my-select> et que ce Web Component propose toutes les fonctionnalités natives :

Ce Web Component de remplacement d’un select, j’en ai écris une version.
Je reviendrai peut-être un jour ici dessus.

Mais Aujourd’hui, je vous propose de mettre en place un Web Component plus simple pour comprendre comment gérer tous les éléments associés au formulaire.

Un remplaçant de l’input password

Le Web Component que je vous propose permet de remplacer un <input type="password">, en ajoutant la fonctionnalité d’afficher/cacher le mot de passe. C’est faisable facilement en javascript en changeant simplement le type de l’input.
Mais ce que l’on souhaite ici, c’est qu’un intégrateur puisse modifier directement en passant de <input type="password"> à <my-password> et que tout fonctionne sans autre modification.

Tout ce qui concerne le visuel et l’intégration CSS ne sera pas abordé ici.

Pour commencer, on va donc mettre en place un Web Component qui va intégrer un champ password dans son Shadow DOM, et mettre en place un attribut booléen show qui permet d’afficher ou cacher le mot de passe.

const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
    display: inline-block;
}
:host(:focus) {
    outline: 2px solid #000;
}
input {
    outline: none;
}
.toggle a {
    text-decoration: none;
}
.toggle .hide,
:host([show]) .toggle .show  {
    display: none;
}
:host([show]) .toggle .hide {
    display: inline;
}
</style>
<div>
    <input type="password" />
    <nav class="toggle">
        <a href="#" class="show" tabindex="-1">Show</a>
        <a href="#" class="hide" tabindex="-1">Hide</a>
    </nav>
</div>
`;

class MyPassword extends HTMLElement {

    static get observedAttributes() {
        return [
            'show'
        ];
    }

    attributeChangedCallback(name, prev, next) {
        if (name === 'show') {
            this._setType();
        }
    }

    connectedCallback() {
        this.attachShadow({
            mode: 'open'
        });
        this.shadowRoot.append(template.content.cloneNode(true));

        this._input = this.shadowRoot.querySelector('input');

        this.shadowRoot.querySelector('.toggle').addEventListener('click', (e) => {
            e.preventDefault();
            const toggle = e.target.closest('a');
            if (toggle) {
                this.show = toggle.classList.contains('show');
            }
        });
    }

    get show() {
        return this.hasAttribute('show');
    }

    set show(show) {
        if (show) {
            this.setAttribute('show', '');
        } else {
            this.removeAttribute('show');
        }
    }

    _setType() {
        this._input.type = this.show ? 'text' : 'password';
    }

}

window.customElements.define('my-password', MyPassword);

export default MyPassword;

Ce que fait le code ci-dessus :

De cette façon, le changement d’affichage peut se faire par l’utilisateur ou par le code en mettant à true ou false la valeur de show.

Pour l’instant ce Web Component n’a aucun lien avec le formulaire dans lequel il serait insérer.
Pire, la valeur du champ input ne sera même pas utiliser car dans un Shadow DOM

La magie de attachInternals

Pour que les navigateurs utilisent ce Web Component dans un formulaire, il faut ajouter quelques éléments à notre Web Component.
La première chose à ajouter :

    static get formAssociated() {
        return true;
    }

    constructor() {
        super();
        this._internals = this.attachInternals();
    }

Par ces simples définitions, on indique que ce Web Component est un peu spécial et nécessite une attention particulière au sein d’un formulaire.
Le this._internals est à utiliser ensuite pour donner tout un tas d’information utile aux formulaires. Pour s’approcher au mieux d’un champ natif, on peut mettre en place :

    checkValidity() {
        return this._internals.checkValidity();
    }

    reportValidity() {
        return this._internals.reportValidity();
    }

    setValidity(flags, message, anchor) {
        return this._internals.setValidity(flags, message, anchor || this._input);
    }

    get form() {
        return this.internals.form;
    }

    get name() {
        return this.getAttribute('name');
    }

    get type() {
        return this.localName;
    }

    get validity() {
        return this.internals_.validity;
    }

    get validationMessage() {
        return this.internals_.validationMessage;
    }

    get willValidate() {
        return this.internals_.willValidate;
    }

Ces appels font pour la majorité des appels à internals.
Seul le get name utilise l’attribut name du Web Component, et c’est cette valeur qui sera utilisée lors de l’envoi du formulaire pour le nom de la donnée.

La gestion de la value

Il reste maintenant à gérer la valeur pour qu’elle soit bien récupérée lors de l’envoi du formulaire.
Il suffit de faire un appel à this._internals.setFormValue() pour donner la valeur actuelle.
Il faut donc effectuer ces appels dès qu’on a un changement de la valeur par l’utilisateur.
Et comme on veut être au plus proche du natif, on va aussi ajouter un getter et setter pour la value :

    connectedCallback() {
        // ...
        this._input.addEventListener('input', () => {
            this._setValue();
        });

        this._input.addEventListener('change', () => {
            this._setValue();
        });
        // ...

        if (this.hasAttribute('value')) {
            this.value = this.getAttribute('value');
        }
    }

    get value() {
        return this._input.value;
    }

    set value(value) {
        this._input.value = value;
        this._setValue();
    }

    _setValue() {
        this._internals.setFormValue(this._input.value);
    }

La valeur réelle est donc “stockée” dans l’input du shadow Dom.
On passe par une fonction _setValue apellée dès qu’on a un changement, qu’il soit de l’utilisateur ou d’un changement via l’attribut value.

Dans le connectedCallback, on vérifie si l’attribue a un attribut value pour bien l’utiliser et remplir la valeur si besoin.

Et avec ce code là, vous avez déjà un remplaçant fonctionnel d’un password natif.
Lorsqu’il est inclus dans un formulaire avec un attribut name, la valeur de l’input sera correctement envoyée comme tous les autres champs natifs.

La gestion du required

Un mot de passe est souvent obligatoire… Et ce qu’on aime faire depuis un moment déjà sur les formulaires, c’est mettre des attributs required qui interdise à l’utilisateur l’envoi du formulaire si la valeur est vide.
Encore quelques lignes de code, et tout ceci sera aussi fonctionel.
Pour cela, On peut utiliser la fonction setValidity qui va permettre de définir si le Web Component est en erreur ou valide.
On peut déterminer tout un tas d’erreurs différentes, correspondant aux erreurs natives du navigateurs.
La seule qui va nous intéresser ici c’est valueMissing que l’on va mettre à true si la valeur est vide.

    static get observedAttributes() {
        return [
            'required',
            'show'
        ];
    }

    attributeChangedCallback(name, prev, next) {
        if (name === 'required') {
            this._setMyValidity();
        } else if (name === 'show') {
            this._setType();
        }
    }

    connectedCallback() {
        // ...
        this._setMyValidity();
    }

    get required() {
        return this.hasAttribute('required');
    }

    set required(required) {
        if (required) {
            this.setAttribute('required', '');
        } else {
            this.removeAttribute('required');
        }
    }

    _setValue() {
        this._internals.setFormValue(this._input.value);
        this._setMyValidity();
    }

    _setMyValidity() {
        if (this.required && this._input && !this._input.value) {
            this.setValidity({
                valueMissing: true
            }, valueMissingMessage, this._input);
        } else {
            this.setValidity({});
        }
    }

La fonction _setMyValidity permet de mettre le champ en erreur ou non. Et on appelle cette fonction dès qu’un changement de valeur est déclenchée, ou lorsque required est modifié.
Un appel dans le connectedCallback permet aussi de bien gérer la validité à l’intialisation.

Le message affiché à l’utilisateur est le second paramètre, ici valueMissingMessage.
Pour récupérer le message natif, le code suivant peut être utilisé :

const valueMissingMessage = (() => {
    const input = document.createElement('input');
    input.required = true;

    return input.validationMessage;
})();

Si vous essayez de nouveau à ce moment là, vous devriez avoir un message d’erreur si le champ est vide lors de l’envoi du formulaire, avec une erreur native exactement comme tout autre champ.

Bonus 1 : le placeholder

Pour gérer le placeholder, on applique le même principe de lecture/écriture que le required. Et là, aucune autre vérification à mettre en place :

    static get observedAttributes() {
        return [
            'required',
            'placeholder',
            'show'
        ];
    }

    attributeChangedCallback(name, prev, next) {
        if (name === 'required') {
            this._setMyValidity();
        } else if (name === 'placeholder') {
            this.placeholder = next;
        } else if (name === 'show') {
            this._setType();
        }
    }

    connectedCallback() {
        // ...
        if (this.hasAttribute('placeholder')) {
            this.placeholder = this.getAttribute('placeholder');
        }
    }

    get placeholder() {
        return this._input.placeholder;
    }

    set placeholder(placeholder) {
        if (this._input) {
            this._input.placeholder = placeholder;
        }
    }

On peut utilisé l’attribut placeholder sur le Web Component en HTML ou en Javascript comme on le fait avec d’autres éléments natifs.

Bonus 2 : Cacher le mot de passe au submit

Lorsque l’utilisateur envoie le formulaire, il est préférable de cacher le mot de passe automatiquement.
L’envoi du formulaire peut prendre du temps et une personne pourrait alors voir le mot de passe.
Grâce à attachInternals, on peut récupérer simplement le formulaire associé, et donc écouter le submit du formulaire :

    connectedCallback() {
        if (this._internals.form) {
            this._internals.form.addEventListener('submit', () => {
                this.show = false;
            });
        }
    }

Bonus 3 : la gestion du focus

Afin que l’élément puisse être accessible au clavier, avec la touche Tabulation notamment, cela nécessite un peu de travail encore.
L’idée est assez simple : Rendre le Web Component “Focusable”, et dès qu’il reçoit le focus, le mettre sur l’input interne.

    connectedCallback() {
        // ...
        if (!this.hasAttribute('tabindex')) {
            this.setAttribute('tabindex', '0');
        }

        this.addEventListener('focus', (e) => {
            if (e.relatedTarget && e.relatedTarget.matches('[type="submit"]')) {
                return;
            }
            this._input.focus();
        });
        // ...
    }

Compatibilité navigateur et polyfill

La compatibilité navigateur est très bonne.
Le seul vrai problème que j’ai rencontré concerne les versions de Safari antérieures à 16.4.
Il existe un polyfill pour attach Internals qui fonctionne très bien.
Son usage est très simple, vous pouvez par exemple ajouter ceci dans le head :

<script src="https://cdn.jsdelivr.net/npm/element-internals-polyfill@1.3/dist/index.min.js"></script>

Disponibilité sur Github

Le tout est disponible sur mon GitHub :

En plus dans le code, vous y trouverez tout le nécessaire pour :

En espérant que vous y trouviez inspiration et envie d’utiliser des Web Components natifs.