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 :
- lorsque le formulaire est envoyé, vérification et affichage d’erreur si besoin
- lorsque le formulaire est envoyé, les données sont envoyées comme n’importe quel autre type de champ
- focus lorsque son label est cliqué
- changement de sa valeur via un getter
- et tout ce qu’on pourrait imaginer en plus.
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 :
- Définir un Web Component
<my-password>
- intégrer un input password et les liens pour afficher/cacher le mot de passe
- écouter le click sur ces 2 liens pour changer la valuer de
show
- écouter le changement de l’attribut
show
pour modifier le type de l’input
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 :
- avoir un rendu visuel très proche du natif et presque utilisable tel quel
- modifier toutes les couleurs et polices avec CSS
- changer les éléments qui permettent d’afficher/cacher le mot de passe (pour y mettre des SVG par exemple)
En espérant que vous y trouviez inspiration et envie d’utiliser des Web Components natifs.