Web Components : un élément <TR> personnalisé (1/2)

Web Components est un ensemble de technologies apparues dans le cadre de HTML5 : custom elements, templates et shadow DOM - les imports HTML aussi, mais ils ne sont toujours pas standardisés. D'après Can I use... (ici, ici et ), ces technologies sont désormais assez largement adoptées dans leurs dernières versions - la v1. En particulier, Firefox gère parfaitement cela depuis l'automone dernier - la version 63.
Pour faire court, cet ensemble permet de créer un élément ou une variante d'élément HTML dans une page Web, et de faire exécuter du code JavaScript au moment où le navigateur cherche à l'interpréter, ce qui permet d'injecter dans la page Web ce que vous voulez. Par exemple, on peut écrire... :
<param-number name="Delay" property="g.shaderUI.delay" step="1" iseditable="true" buttonclassname="uiButton" numberclassname="uiNumber" layout="middle"></param-number>
...et récupérer une référence sur cet élément au moment où le navigateur cherche quoi en faire. Ici, il serait possible d'afficher un champ de saisie entouré de deux boutons, l'un pour décrémenter et l'autre pour incrémenter la valeur figurant dans le champ :
Un élément personnalisé
On comprend qu'il serait alors facile d'utiliser un tel composant à volonté, comme par exemple ici :
Une page Web utilisant généreusement des éléments personnalisés
Mais il pourrait tout autant s'agir d'effectuer une requête asynchrone à une base de données - une bonne occasion d'utiliser une promise -, afin de récupérer des informations sur un client et de les afficher quand elles sont disponibles, sans bloquer l'affichage du reste de la page - soyons progressifs.
Le sujet des Web Components est bien documenté sur MDN, et la spécification HTML qui doit servir de référence est pour sa part assez claire. Pourquoi en parler alors ?
Il faut en parler, car le sujet reste assez nouveau, et car la pratique montre que le développeur peut être confronté à des choix assez cornéliens. L'exemple pris ici est celui de la création d'un élément custom pour rajouter des lignes à un tableau. Ce sujet s'impose de lui-même dès que l'on commence à manipuler les Web Components, car la première idée qu'on y voit est de factoriser des éléments redondants, à ce titre souvent affichés dans un tableau.
Cet article est le premier d'un série de deux. Il s'agit de présenter l'intégralité du code requis pour faire fonctionner l'élément <param-number> évoqué. Dans le second article, plusieurs choix techniques et perspectives des technologies utilisées feront l'objet d'une discussion.
A écouter en lisant l'article...

Petit rappel sur les Web Components

Sans trop s'attarder sur le sujet puisqu'il a été bien documenté par ailleurs (MDN, Google, etc.), rappelons brièvement les bases des Web Components, en partant d'un exemple pour être un peu original.
Soit l'exemple suivant, que vous pouvez tester ici :
<template id="mon-template">
<style>
div { color:red; }
.gras { font-weight: bold; }
</style>
<div id="monDIV"><slot name="message">Ceci est le message par défaut</slot></div>
</template>

<div>Ce message n'est pas en rouge car les styles du composant sont définis dans un shadow DOM, donc ce <div> n'est pas affecté</div>

<mon-element important="true"><span slot="message">Ce message est mis en forme par l'élément : il est en gras, car important</span></mon-element>

<mon-element important="false"><span slot="message">Ce message est mis en forme par l'élément : il n'est pas en gras, car pas important</span></mon-element>

<script>
class MonElement extends HTMLElement {
	constructor () {
		super ();
	}
	connectedCallback () {
		var i;
		var shadowRoot = this.attachShadow ({ mode:"open" });
		shadowRoot.appendChild (document.getElementById ("mon-template").content.cloneNode (true));
		if (this.getAttribute ("important") == "true")
			shadowRoot.getElementById ("monDIV").className = "gras";
	}
}

customElements.define ("mon-element", MonElement);
</script>
Qu'observe-t-on ici ? Trois choses :
  • Un élément <template> qui définit... un modèle (template), c'est-à-dire un bloc de HTML qui n'est pas affiché par le navigateur, mais qui l'est dès que son contenu est ajouté au document.
    En l'occurrence, le modèle contient des styles, dont on verra dans un instant qu'ils n'interfèrent pas avec ceux du document.
  • Une classe MonElement, qui hérite de HTMLElement, et qui contient tout ce qu'il faut pour créer et gérér l'IHM de l'élément.
    C'est la classe d'un élément autonome (autonomous custom element), par opposition à un élément personnalisé (customized custom element). Pour rappel, la différence est qu'un élément autonome ne dérive pas d'une des classes de base des différents éléments HTML, comme HTMLTableRowElement qui sera mobilisée plus loin.
    Attention, la dérivation s'entend au sens de celle qui est déclarée via un argument optionnel { extends: ... } lors de l'appel à customElements.define (), et non de celle déclarée via extends lors de la définition de la classe.
    Parlant de l'appel à customElements.define (), c'est lui qui permet d'indiquer au navigateur le lien entre <mon-element> et MonElement.
  • La racine d'un DOM fantôme (shadow root), créé par la méthode .connectedCallback (), que la navigateur appelle une fois que l'emplacement de l'élément dans le DOM du document où il figure a été réservé.
    La racine est celle d'un DOM à part, propre à l'élément. Dans ces conditions, exception faite de propriétés CSS héritées - si le style div { color: green } était défini dans le document, il s'appliquerait aux <div> de <mon-element> -, les styles du document où figure l'élément autonome ne s'appliquent pas aux éléments du document de cet élément. A l'inverse, les styles du document de l'élément autonome ne s'appliquent pas aux éléments du document où figure l'élément autonome. C'est ce qui explique l'isolation des styles évoquée plus tôt.
    Notez que rien n'oblige d'utiliser un DOM fantôme : depuis .connectedCallback (), il est parfaitement possible de manipuler le DOM du document où figure l'élément personnalisé.
Ici, .connectedCallback () se contente de créer une racine fantôme, et d'y rajouter une clone du contenu du modèle. Pour finir, il attribue le nom de la classe CSS gras - définie dans le modèle - au <div> que le clone contient si l'attribut important a été défini dans l'élément autonome.
Autonome ou personnalisé, un élément doit toujours porter un nom qui comporte au moins un tiret ; en l'espèce, c'est mon-element. Il s'utilise sous ce nom dans un document, comme n'importe quel autre élément HTML par défaut : <mon-element>, sans toutefois pouvoir être auto-fermant - ie, pas de <mon-element/>.

Les bases du spinner

Dans cet article, nous allons donc créer un élément personnalisé qui constitue un spinner. Le fonctionnement de cet élément pourra être ajusté à l'aide de différents attributs, certains obligatoires, d'autres non :
disabled Désactivé
hidden Dissimulé
step Valeur retirée / rajoutée à chaque décrémentation / incrémentation par le bouton + / -
numberClassName Nom(s) de classe(s) CSS pour le champ de saisie / <div>
buttonClassName Nom(s) de classe(s) CSS pour les boutons - et +
decimal Le nombre est un décimal
precision Nombre de décimales si le nombre est un décimal
editable Le nombre peut être saisi directement
min Valeur minimum que le nombre peut prendre
max Valeur maximum que le nombre peut prendre
layout
Positionnement du champ de saisie / <div> par rapport aux boutons :
left A gauche des boutons
right A droite des boutons
middle Entre les boutons
A l'expression des ces besoins, nous allons en rajouter quelques autres :
  • L'élément personnalisé doit pouvoir être créé en écrivant du HTML - un élément <param-number> auquel les informations précédentes sont communiquées via des attributs - ou en appelant une fabrique appelant un constructeur - createNumber () appelant ParamNumber (), à laquelle ces informations sont communiquées via des arguments. C'est qu'un développeur peut souhaiter créer à la volée un <param-number>, ou tout simplement ne pas utiliser Web Components.
  • L'élément personnalisé doit pouvoir être activé / désactivé, et affiché / dissimulé de la manière la plus simple qui soit, c'est-à-dire via des propriétés .disabled et .hidden, et non des méthodes .disable (), .enable (), .hide () et .show (). C'est qu'un développeur n'a pas de temps à perdre à saisir des caractères inutiles.
  • Très classiquement, lorsque l'utilisateur modifie la valeur de la propriété liée au <param-number> en pressant un des boutons ou en la saisissant dans le champ soit généré ou non, un événement est généré. La valeur de la propriété liée à l'élément personnalisé doit pouvoir être spécifiée en demandant ou à ce que cet événement soit généré. C'est qu'un développeur peut souhaiter simuler l'action d'un utilisateur. Ce doit être l'objet d'une méthode .setValue (). Notez que ce n'est pas particulièrement standard, car en HTML, la modification d'une propriété d'un élément via le code ne déclenche pas l'éventuel événement associé - ce qui est généralement heureux, mais parfois bloquant.
  • En théorie, une fois la propriété liée au <param-number>, sa valeur devrait être sous le contrôle exclusif de ce dernier. En pratique, un développeur peut souhaiter modifier la valeur. Dès lors, il faut que le développeur puisse signaler cette modification au <param-number> pour que ce dernier se resynchronise sur la propriété. Ce doit être l'objet d'une méthode .refresh ().
Le spinner est l'un des éléments personnalisés que j'ai développement dans le cadre du développement d'une bibliothèque d'éléments permettant de composer des IHM de configuration riches en paramètres. Comme il partage un certain nombre de fonctionnalités avec d'autres éléments, ces dernières sont factorisées dans une classe de base ParamElement, d'une part, et un constructeur de base, Param. On peut légitimement se demander pourquoi il faut une classe et un constructeur pour assurer le double accès qu'on souhaite au développeur : les classes JavaScript ne sont-elles pas du sucre syntaxique ? Cette énigme sera élucidée dans le second article de cette série, et la révélation ne fera pas plaisir à tout le monde.
Dans cette bibliothèque, les classes sont réduites à ce qu'elles sont : de malheureuses nécessités pour permettre d'utiliser les objets créés par les constructeurs. La classe Param n'est donc essentiellemet qu'un passe-plat vers l'objet ParamNumber qu'elle créée et qu'elle référence via sa propriété .param : est la suivante :
ParamElement = class extends HTMLTableRowElement {
	constructor () {
		super ();
		this.param = null;
	}
	get hidden () { return (this.param.hidden); }
	set hidden (value) { this.param.hidden = value; }
	get disabled () { return (this.param.disabled); }
	set disabled (value) { this.param.disabled = value; }
	refresh () { this.param.refresh (); }
	setValue (value, triggerEvent = false) { this.param.setValue (value, triggerEvent); }
};
Le détail est le suivant :
.param L'objet ParamNumber
.hidden Permet d'activer / désactiver le <param-number>
.disabled Permet d'afficher / dissimuler le <param-number>
.refresh () Permet de resynchroniser le <param-number> sur la valeur de la propriété liée.
.setValue (value, triggerEvent = false) Permet de modifier la valeur affichée par le <param-number>, en déclenchant éventuellement l'événement associé à sa modification.
On en vient maintenant au constructeur de base, Param (). Le code de ce dernier est nécessairement plus conséquent. Commençons par les propriétés en propre de l'objet créé :
function Param () {
	this.tag = null;
	this._hidden = false;
	Object.defineProperty (this, "hidden", {
		get: function () { return (this._hidden); },
		set: function (value) {
				this._hidden = value;
				if (this._hidden)
					this.hide ();
				else
					this.show ();
		},
		enumerable: true,
		configuratble: true
	});
	this._disabled = false;
	Object.defineProperty (this, "disabled", {
		get: function () { return (this._disabled); },
		set: function (value) {
				this._disabled = value;
				if (this._disabled)
					this.disable ();
				else
					this.enable ();
		},
		enumerable: true,
		configuratble: true
	});
	this.propertyName = "";
	Object.defineProperty (this, "propertyValue", {
		get: function () {
			var bond;

			bond = this.bind (this.propertyName, this.tag);
			if (bond)
				return (bond.object[bond.property]);
			return (null);
		},
		set: function (value) {
			var bond;

			bond = this.bind (this.propertyName, this.tag);
			if (bond)
				bond.object[bond.property] = value;
		},
		enumerable: true,
		configuratble: true
	});
};
Les propriétés .hidden et .disabled sont assez explicites. Derrière, on trouve les inévitables accesseurs get () et set (), qui attaquent des propriétés internes ._hidden et ._disabled. Toutefois, les setters ne se contentent pas de cela, car un <param> particulier pourrait avoir besoin de procéder à un traitement spécifique - d'ailleurs, le <param-number> a besoin de désactiver / activer deux boutons et un champ de saisie. C'est pourquoi les setters appellent des méthodes .enable (), .disable (), .hide () et .show (), destinées à être surchargées par les constructeurs des objets héritiers. On aurait pu imaginer une redéfinition des accesseurs, mais bof : la fonction de ces derniers reste ainsi cantonnée à de la cosmétique.
Après ces trivialités, la propriété .propertyValue constitue un cas intéressant. Comme on peut le constater, elle appelle une méthode .bind () et utilise l'objet retourné pour accéder à une certaine propriété d'un certain objet. Cette propriété, c'est celle à laquelle le <param-number> est liée. Autrement dit, celle que le développeur pointera sous la forme d'un nom totalement qualifié via l'attribut property du <param-number>. Par exemple :
<param-number property="config.radius.value" ...>
Un point essentiel, c'est que ce nom doit être réévalué à chaque accès en lecture ou en écriture à la propriété. A défaut, le <param-number> ne permettrait de modifier que la valeur de la propriété auquel le nom correspond à l'instanciation, et ce genre d'incident se produirait. Supposons qu'avant l'instanciation du <param-number>... :
var config = { radius: { value: 666 } };
...et qu'après l'instanciation du <param-number> :
config.radius = { value: 7 };
Dans ces conditions, le <param-number> ne permettrait pas de modifier { value: 7 }, car le nom renverrait au précédent objet { value: 666 } !
Par conséquent, dans tout le code qui va suivre, la propriété sera référencée via la .propertyValue qui décodera le nom pour accéder à l'objet auquel il renvoie sur l'instant. Le décodage du nom est assuré par une méthode .bind (). Elle remonte généreusement des erreurs, car c'est généralement en renseignant l'attribut property que le développeur fera des erreurs :
Param.prototype.bind = function (name, tag) {
	var properties, i, o;

	if (name == "") {
		error ("Cannot bind void name", tag);
		return (null);
	}
	properties = name.split (".");
	if (properties[0] == "window") {
		if (properties.length == 1) {
			error ("window is not a valid property", tag);
			return (null);
		}
		i = 1;
	}
	else
		i = 0;
	o = window;
	while (i != properties.length - 1) {
		o = o[properties[i]];
		if (o === undefined) {
			error (`Cannot bind ${properties[i]}`, tag);
			return (null);
		}
		i ++;
	}
	return ({ object: o, property: properties[properties.length - 1] });
};
Le reste n'est que l'intendance qui doit suivre, à savoir les méthodes .enable (), .disable (), .hide (), .show () et .setValue (), laquelle illustre d'ailleurs une utilisation de .propertyValue :
Param.prototype.disable = function () {
	this._disabled = true;
	this.tag.disabled = true;
};

Param.prototype.enable = function () {
	this._disabled = false;
	this.tag.disabled = false;
};

Param.prototype.hide = function () {
	this.tagTR.style.display = "none";
	this._hidden = true;
};

Param.prototype.show = function () {
	this.tagTR.style.display = "";
	this._hidden = false;
};

Param.prototype.refresh = function (e) {
	this.setValue (this.propertyValue);
};

La classe du spinner

ParamNumberElement est la classe du spinner. Elle dérive ParamElement, qui vient d'être présentée, et contient donc ce qui est propre à <param-number>.
class ParamNumberElement extends ParamElement {
	constructor () {
		super ();
	}

	connectedCallback () {
		if (document.readystate != "complete")
			window.addEventListener ("load", (e) => this.setup.call (this), { capture: false, once: true });
		else
			setup ();
	}

	setup () {	// Attention à bien parser toute valeur récupérée via un attribut qui n'est pas une chaîne
		var styles, style, i;
		styles = {
			_disabled: false,
			set disabled (value) { this._disabled = ((value == "true") || (value == "1")); },
			get disabled () { return (this._disabled); },
			_hidden: false,
			set hidden (value) { this._hidden = ((value == "true") || (value == "1")); },
			get hidden () { return (this._hidden); },
			_step: 1,
			set step (value) { this._step = parseFloat (value); },
			get step () { return (this._step); },
			numberClassName: "",
			buttonClassName: "",
			_editable: false,
			set editable (value) { this._editable = ((value == "true") || (value == "1")); },
			get editable () { return (this._editable); },
			_decimal: false,
			set decimal (value) { this._decimal = ((value == "true") || (value == "1")); },
			get decimal () { return (this._decimal); },
			_precision: -1,
			set precision (value) { this._precision = parseInt (value); },
			get precision () { return (this._precision); },
			_min: -Number.MAX_VALUE,
			set min (value) { this._min = parseFloat (value); },
			get min () { return (this._min); },
			_max: Number.MAX_VALUE,
			set max (value) { this._max = parseFloat (value); },
			get max () { return (this._max); },
			layout: "left"
		}
		for (style in styles) {
			if (this.hasAttribute (style))
				styles[style] = this.getAttribute (style);
			else
				delete styles[style];
		}

		this.param = createNumber (
			this.parentNode,
			this.getAttribute ("label"),
			this.getAttribute ("property"),
			styles);
	}
}
Plusieurs points sont dignes d'intérêt dans cette classe. Pour commencer, il faut constater que le constructeur constructor () se borne à appeler celui de la classe parent. L'exécution de code utile ne débute pas avant que la navigateur appelle la fonction de rappel connectedCallback (), une fois que le <param-number> a été rattaché au DOM du document où il figure. Encore faut-il constater que cette fonction repousse encore les échéances en demandant à ce que la méthode propriétaire .setup () soit appelée une fois que l'événement load de l'objet window aura été déclenché. Diantre! pourquoi tant de procrastination ?
Le fait est que pour afficher le <param-number>, il faut pouvoir accéder à propriété qui y est liée afin d'en lire la valeur. Or dans une page Web, si le code qui crée la propriété figure dans une section <script> - par défaut d'exécution immédiate -, tout dépendra de la position de balise <param-number>, par rapport à cette section. Si la section figure après, la propriété n'aura pas été définie quand connectedCallback () sera appelée. Dans l'exemple suivant, console.log (maPropriete) affiche undefined :
<mon-element></mon-element>
<script>
class MonElement extends HTMLElement {
	constructor () { super (); }
	connectedCallback () { console.log (maPropriete); }
}
customElements.define ("mon-element", MonElement);
var maPropriete = 666;
</script>
Plus généralement, on voit qu'il est nécessaire de synchroniser l'exécution du code de <param-number> qui va créer le spinner, avec l'exécution de celui qui va créer les ressources auxquelles ce code a besoin d'accéder. En l'espèce, l'exécution du code de <param-number> va simplement être repoussée à la fin du chargement de la page, en faisant l'hypothèse que les ressources seront alors disponibles.
Toutefois, dans des cas plus complexes, cette hypothèse pourrait se révéler insuffisante, notamment parce qu'une ressource ne serait disponible qu'au terme d'un chargement asynchrone initié par le code exécuté au chargement de la page. Dans ce cas, il faudrait synchroniser autrement, notamment en utilisant un objet Promise, longuement présenté ici. Par exemple :
<script>
function getResource () {
	return (new Promise ((resolve, reject) => {
		// Simulation d'un chargement asynchrone
		window.setTimeout (() => resolve (666), 2000);
	}));
}

class MonElement extends HTMLElement {
	constructor () { super (); }
	connectedCallback () {
		// Attente de la fin du chargement asynchrone
		getResource ().then ((result) => console.log (result));
	}
}
customElements.define ("mon-element", MonElement);
</script>
Sans plus m'étendre sur ce sujet, je me permets d'insister sur la nécessité de synchroniser rigoureusement. C'est qu'en matière de synchronisation, on tombe parfois sur des exemples où le développeur se contente de retarder l'exécution de son code par un setTimeout () en faisant l'hypothèse que la ressource dont ce code a besoin trouvera ainsi le temps d'être chargée. C'est abominable.
Pour revenir à la classe ParamNumberElement, un autre point digne d'intérêt est qu'elle se contente donc de lire les attributs du <param-number> pour appeler la fabrique createNumber () - qui appellera donc le constructeur ParamNumber. A cette occasion, elle fait preuve à la fois de tolérance et de rigueur :
  • tolérance, en acceptant que des valeurs d'attributs puissent être fournies de diverses manières, comme 1 ou true pour un boléen ;
  • rigueur, en convertissant bien ces valeurs dans le type de base JavaScript requis, comme les nombres avec parseFloat () ou parseInt ().
Enfin, la classe est enregistrée pour que le navigateur puisse la lier à l'élément <param-number> :
customElements.define ("param-number", ParamNumberElement, { extends: "tr" });

Le constructeur du spinner

Appelée directement ou par la méthode .setup () de la classe ParamNumberElement, la fabrique createNumber () appelle le constructeur ParamNumber () pour créer l'objet qui centralise le code véritablement utile.
S'il n'est en rien compliqué, le code du constructeur est un peu longuet, ce qui justifie sa présentation par partie.
Après avoir appelé le constructeur de Param, le constructeur initialise diverses propriétés à usage interne en fonction de paramètres obligatoires - un libellé, la propriété liée au <param-number> - et de facultatifs. Ces derniers sont réunies dans un objet dit de styles, et prennent des valeurs par défaut s'ils ne sont pas fournis par le développeur via les attributs de <param-number>, ou directement les arguments du constructeur :
function ParamNumber (tagTBODY, label, propertyName, userStyles) {
	var step, tagTD, tagSPAN, tagINPUT;

	var styles = {
		disabled: false,
		hidden: false,
		step: 1,
		numberClassName: "",
		buttonClassName: "",
		editable: false,
		decimal: false,
		precision: -1,
		min: -Number.MAX_VALUE,
		max: Number.MAX_VALUE,
		layout: "left"
	}, style;

	Param.call (this);

	this.propertyName = propertyName;
	if (userStyles !== undefined) {
		for (style in styles) {
			if (userStyles[style] !== undefined)
				styles[style] = userStyles[style];
		}
	}
	if (styles.precision == -1) {	// L'utilisateur n'a pas spécifié de précision
		if (styles.decimal) {
			styles.precision = 0;
			step = styles.step;
			while (!Math.trunc (step)) {
				styles.precision ++;
				step *= 10;
			}
			if (!styles.precision)
				styles.precision = 1;
		}
		else
			styles.precision = 0;
	}
	this.step = styles.step;
	this.decimal = styles.decimal;
	this.precision = styles.precision;
	this.editable = styles.editable;
	this.min = styles.min;
	this.max = styles.max;

// (à suivre)
Le constructeur procède alors à la génération de l'arborescence des éléments qui composent son IHM. Ici, deux stratégies sont possibles :
  • Comme à l'ancienne, créer l'arborescence manuellement. Autrement dit, créer chaque élément par appel à document.createElement (), spécifier la valeur de chacun de ses attributs, décrire ses styles CSS, le rattacher à un autre élément via .appendChild (), etc.
  • De manière plus moderne, créer l'arborescence automatiquement. Autrement dit, créer un modèle en écrivant du HTML, récupérer une référence dessus via document.getElementById (), et en cloner le contenu via .content.cloneNode (true).
Généralement, la seconde solution s'utilise conjointement à un DOM fantôme.
En effet, puisqu'il faut par la suite pouvoir accéder à certains éléments de l'IHM du <param-number>, par exemple les boutons pour les activer / désactiver, il faut que ces éléments disposent d'identifiants uniques dans le document. Or si plusieurs <param-number> sont ajouté au même document, autant d'éléments porteront le même identifiant dans le document, et il sera impossible de retrouver un élément donné d'un <param-number> donné - document.getElementByID () ne retourne qu'un élément, un identifiant étant présumé unique dans un document. Le fait que l'arborescence des éléments réside dans un DOM spécifique au <param-number> permet de préserver l'unicité des identifiants : c'est comme si chaque <param-number> venait avec son propre petit document.
Tout cela se traduirait par quelque chose comme :
<!-- Modèle avec les boutons à droite du champ de saisie -->

<template id="param-number-template-right">
<div id="param-number-label" style="white-space:nowrap"></div>
<div>
	<input id="param-number-less" type="button" value="-" style="margin-right:5px"/>
	<input id="param-number-more" type="button" value="+" style="margin-right:5px"/>
	<input id="param-number-text" type="text"/>
</div>
</template>
// Quelque part, dans le code du constructeur

var shadowRoot = tagDIV.attachShadow ({ mode: "open" });
shadowRoot.appendChild (document.getElementById (`param-number-template-${styles.layout}`).content.cloneNode (true));
shadowRoot.getElementById ("param-number-label").innerText = `${label}:`;
this.tagMore = shadowRoot.getElementById ("param-number-more");
this.tagLess = shadowRoot.getElementById ("param-number-less");
this.tagText = shadowRoot.getElementById ("param-number-text");
Toutefois, comme cela a été dit, un DOM fantôme est totalement isolé. En l'espèce, les styles que le développeur pourrait transmettre via les attributs facultatifs buttonClassName et textClassName du <param-number> seraient inaccessibles, car ils seraient nécessairement définis dans un autre DOM, celui du document où le développeur aurait rajouté le <param-number>.
Franchir la barrière CSS du DOM fantôme
Il faut tempérer ce qui vient d'être dit. Des styles définis dans le document peuvent passer la barrière erigée à l'entrée du DOM fantôme, mais il ne peut s'agir que de propriétés héritées. Par exemple :
<style>
div { color: red; }
</style>
...s'appliquera aux <div> du DOM fantôme. Mais...
<style>
.someStyle { color: red; }
</style>
...ne pourra pas être appliqué dans le DOM fantôme.
Cela pose des difficultés pour permettre à l'utilisateur d'un élément custom d'en personnaliser l'apparence via CSS. En effet, ce dernier ne peut donc pas communiquer au code de l'élément custom des références sur des classes à associer aux éléments HTML qu'il crée pour composer le contenu du DOM fantôme. Tout au plus peut-il redéfinir des variables CSS. Par exemple... :
<style>
:root { --some-variable: red; }
</style>
...s'appliquera à un tel élément HTML figurant dans le DOM fantôme :
<div style="color:var(--some-color)">Hello world</div>
Cliquez ici pour tester ces exemples.
Devoir travailler à ce niveau de granularité est non seulement pénible car il faut multiplier les variables, mais aussi limité car seules les propriétés dont les valeurs sont des variables peuvent être ainsi ajustées. Cette difficulté est devenue plus criante avec la version 1 du DOM fantôme, comme expliqué ici. Il semble bien qu'une solution a été imaginée, à base de @apply comme évoquée ici, mais elle sa standardisation aurait fait long feu.
Au vu des solutions de contournement, on peut se demander s'il n'est pas plus sage de se passer de DOM fantôme dès lors qu'isoler le DOM n'est pas un enjeu. Mais se passer de DOM impliquerait de se passer de modèle, puisqu'il ne serait donc plus possible de garantir l'unicité des identifiants.
Bah! vu la simplicité de l'arborescence des éléments qui composent l'IHM de <param-number>, autant ne pas chercher midi à quatorze heures et créer cette arborescence manuellement. Ce qui donne :
// (suite)

	this.tagTR = document.createElement ("tr");

	tagTD = document.createElement ("td");
	this.tagTR.appendChild (tagTD);
	tagTD.style.whiteSpace = "nowrap";
	tagTD.innerHTML = label + " :";

	if (this.editable) {
		tagINPUT = document.createElement ("input");
		if (styles.numberClassName != "")
			tagINPUT.className = styles.numberClassName;
		else {
			tagINPUT.size = "3";
			tagINPUT.style.textAlign = "center";
		}
		tagINPUT.type = "text";
		tagINPUT.addEventListener ("change", (e) => this.onChange.call (this, e), false);
		this.tag = tagINPUT;
	}
	else {
		tagSPAN = document.createElement ("span");
		if (styles.numberClassName != "")
			tagSPAN.className = styles.numberClassName;
		this.tag = tagSPAN;
	}
	if (styles.numberClassName == "") {
		if ((styles.layout == "left") || (styles.layout == "middle"))
			this.tag.style.marginRight = "5px";
		if ((styles.layout == "right") || (styles.layout == "middle"))
			this.tag.style.marginLeft = "5px";
	}

	tagINPUT = document.createElement ("input");
	if (styles.buttonClassName != "")
		tagINPUT.className = styles.buttonClassName;
	else {
		tagINPUT.style.width = "25px";
		tagINPUT.style.height = "25px";
		tagINPUT.style.padding = "0px";
	}
	tagINPUT.type = "button";
	tagINPUT.value = "-";
	tagINPUT.addEventListener ("click", (e) => this.onLess.call (this, e), false);
	this.tagLess = tagINPUT;

	tagINPUT = document.createElement ("input");
	if (styles.buttonClassName != "")
		tagINPUT.className = styles.buttonClassName;
	else {
		tagINPUT.style.width = "25px";
		tagINPUT.style.height = "25px";
		tagINPUT.style.padding = "0px";
	}
	tagINPUT.type = "button";
	tagINPUT.value = "+";
	tagINPUT.addEventListener ("click", (e) => this.onMore.call (this, e), false);
	this.tagMore = tagINPUT;

	tagTD = document.createElement ("td");
	this.tagTR.appendChild (tagTD);
	tagTD.style.whiteSpace = "nowrap";
	switch (styles.layout) {
		case "left":
			tagTD.appendChild (this.tag);
			tagTD.appendChild (this.tagLess);
			tagTD.appendChild (this.tagMore);
			break;
		case "right":
			tagTD.appendChild (this.tagLess);
			tagTD.appendChild (this.tagMore);
			tagTD.appendChild (this.tag);
			break;
		case "middle":
			tagTD.appendChild (this.tagLess);
			tagTD.appendChild (this.tag);
			tagTD.appendChild (this.tagMore);
			break;
	}
	tagTBODY.appendChild (this.tagTR);

// (à suivre)
Comme il est possible de le constater, les gestionnaires d'événement des boutons et du champ de saisie font appel à des méthodes de l'objet ParamNumber. Elle seront décrites plus loin.
Une notation fléchée est utilisée pour définir ces gestionnaires. Pour rappel, dans une fonction fléchée, this n'existe pas. Toute référence à this y est interprétée comme une référence à une variable portant ce nom. Si cette variable n'est pas définie localement dans le corps de la fonction fléchée, c'est donc qu'elle doit être recherchée dans un scope englobant, et sa référence locale préservée grâce à une closure. C'est ainsi que l'utilisation de .call () permet de transmettre à la méthode appelée par un gestionnaire le this dont elle a besoin.
Pour finir avec le constructeur, ce dernier s'achève par des opérations cosmétiques consistant à afficher la valeur courante de la propriété liée au <param-number> et à activer / désactiver et afficher / dissimuler l'IHM de ce dernier comme demandé :
// (suite)

	this.setValue (this.propertyValue);
	this.disabled = styles.disabled;
	this.hidden = styles.hidden;
};
ParamNumber.prototype = Object.create (Param.prototype);

Les méthodes du spinner

L'objet ParaNumber comporte plusieurs catégories de méthodes. La première regroupe les trois gestionnaires d'événement appelés lorsque l'utilisateur saisit une valeur (.onChange ()), lorsqu'il presse le bouton "+" (.onMore ()), et lorsqu'il presse le bouton "-" (.onLess ()). Tous ces gestionnaires se contentent de contrôler la possibilité de l'action demandée par l'utilisateur, et de l'exécuter si elle est légitime :
ParamNumber.prototype.onChange = function (e) {
	if (!/^\-?[0-9]+(\.[0-9]+)?$/.test (this.tag.value)) {	// Car isNaN () tolère des nombres inacceptables ("3e4", etc.)
		error ("Not a number", this.tag);
		if (this.editable)
			this.tag.value = this.value.toFixed (this.precision);
		else
			this.tag.innerHTML = this.value.toFixed (this.precision);
		return;
	}
	this.propertyValue = (this.decimal ? parseFloat (this.tag.value) : parseInt (this.tag.value));
	if (this.value == this.propertyValue) {
		error ("Cannot set this value", this.tag);
		if (this.editable)
			this.tag.value = this.value.toFixed (this.precision);
		else
			this.tag.innerHTML = this.value.toFixed (this.precision);
		return;
	}
	this.value = this.propertyValue;
};

ParamNumber.prototype.onLess = function (e) {
	if (this.editable && isNaN (this.tag.value)) {
		error ("Not a number", this.tag);
		return;
	}
	if ((this.value - this.step) < this.min)
		return;
	this.value -= this.step;
	if (this.editable)
		this.tag.value = this.value.toFixed (this.precision);
	else
		this.tag.innerHTML = this.value.toFixed (this.precision);
	this.propertyValue = this.value;
};

ParamNumber.prototype.onMore = function (e) {
	if (this.editable && isNaN (this.tag.value)) {
		error ("Not a number", this.tag);
		return;
	}
	if ((this.value + this.step) > this.max)
		return;
	this.value += this.step;
	if (this.editable)
		this.tag.value = this.value.toFixed (this.precision);
	else
		this.tag.innerHTML = this.value.toFixed (this.precision);
	this.propertyValue = this.value;
};
La deuxième catégorie de méthodes regroupe des méthodes d'usage interne. En l'espèce, ce sont des redéfinitions des méthodes .disabled () et .enable () de l'objet Param, afin d'adapter l'activation / la désactivation par défaut à l'IHM de <param-number> :
ParamNumber.prototype.disable = function () {
	this.tagLess.disabled = true;
	this.tagMore.disabled = true;
	if (this.editable)
		this.tag.disabled = true;
};

ParamNumber.prototype.enable = function () {
	this.tagLess.disabled = false;
	this.tagMore.disabled = false;
	if (this.editable)
		this.tag.disabled = false;
};
La dernière catégorie de méthodes est celle des méthodes véritablement "publiques", que le développeur utilisant <param-number> peut appeler. Tout a été fait pour les réduire à la portion congrue en créant plutôt des propriétés agrémentées d'accesseurs, comme .disabled et .hidden. Reste une méthode qui permet au développeur de modifier la valeur apparente du <param-number>, rendue nécessaire pour offrir la possibilité au développeur de forcer le déclenchement de l'événement qui doit conduire à mettre à jour la valeur de la propriété avec la valeur spécifiée :
ParamNumber.prototype.setValue = function (value, triggerEvent = false) {
	this.value = value;
	if (this.editable)
		this.tag.value = this.value.toFixed (this.precision);
	else
		this.tag.innerHTML = this.value.toFixed (this.precision);
	if (triggerEvent)
		this.propertyValue = this.value;
};
Ceci clôt la présentation du code du <param-number>. Cliquez ici pour tester le spinner dans ses oeuvres.
Il reste à discuter les choix qui ont présidé à l'écriture de ce code, ainsi que les perspectives ouvertes par les technologies de la famille Web Components. Le second article de cette série y sera consacré.