Aller au contenu

[Snippet] Proportions d'une recette en JavaScript

J'entretiens un blog persionnel privé où je note — entre autre — des recettes de cuisine. Et ça faisait un moment que je voulais pouvoir recalculer à la volée et simplement les quantités en fonction — par exemple — du nombre de convives.

Ayant passé ledit blog sur Mkdocs aussi récemment, j'ai cherché une solution qui ne nécessite pas de développer un plugin : reposer simplement sur JavaScript et HTML.

Objectif

À terme, je voulais avoir un HTML ressemblant à ceci :

<ul class="ingredients">
    <li><span clas="qty">100</span> g de farine de sarrasin</li>
    <li><span clas="qty">1</span> oeuf</li>
    <li><span clas="qty">150</span> mL de lait</li>
</ul>
<label>
    Multiplier les quantités par :
    <input type="text" class="multiplier" value="1" title="Possible formats: .5 — 2/3 — 2 — 3,5">
</label>

Les saisies dans le <input> pourrait être un nombre entier ou décimal, avec un séparateur de décimal à la française (,) ou à l'anglaise (.) et un support basique des fractions serait intéressant aussi (2/3).

N'écrire que la liste

Dans chaque article, j'aimerais éviter d'avoir à écrire à la main à chaque fois le <input> (« copier-coller », c'est le mal !), donc autant l'insérer à la volée au chargement de la page après chaque liste d'ingrédients :

// Insertion du multiplicateur après chaque liste d'ingrédients
document.querySelectorAll('.ingredients').forEach(ingredientsList => {
    const label = document.createElement('label');
    label.innerHTML = `Multiplier les quantités par : <input type="text" class="multiplier" value="1" title="Possible formats: .5 — 2/3 — 2 — 3,5">`;
    label.querySelector('.multiplier').addEventListener('input', applyMultiplier);
    ingredientsList.insertAdjacentElement('afterend', label);
});

Alléger l'écriture de la liste

Cette partie est facultative et discutable

Vous pouvez tout aussi bien vous en passer et écrire directement les balises <li> comme évoqué plus haut pour avoir une meilleure accessibilité de votre site.

Certains n'aimeront peut-être pas cette approche, mais je tenais à diminuer la quantité de code à écrire à chaque fois que je rédige une liste d'ingrédients (l'essentiel de mon texte est en Markdown, je n'ai pas envie de me palucher du HTML lourdingue au milieu de ça).

J'ai donc fait le choix d'écrire plutôt :

<ul class="ingredients">
    <x-ingr qty="100">g de farine de sarrasin</x-ingr>
    <x-ingr qty="1">oeuf</x-ingr>
    <x-ingr qty="150">mL de lait</x-ingr>
</ul>

J'en convient, le gain est minime : 10 caractères par ingrédient, sachant que ça ajoute 29 caractères pour le <ul>, ça ne laisse — dans l'exemple présent — qu'un gain de 1 caractère ! 🧐

Mais c'était aussi l'occasion pour moi d'expérimenter avec les éléments HTML personnalisés (cf MDN). J'envisageais au départ de distinguer 3 informations (quantité, unité de mesure, nom d'ingrédient), ce qui aurait pu rendre l'usage d'élément personnalisé plus intéressant, peut-être.

Pour déclarer l'élément personnalisé :

// Élément HTML personnalisé représentant un ingrédient de recette
class IngredientElement extends HTMLElement {
    constructor() {
        super();
        const qty = this.getAttribute('qty');
        const text = this.textContent.trim();
        const li = document.createElement('li');
        li.innerHTML = `<span class="qty">${qty}</span> ${text}`;
        this.attachShadow({ mode: 'open' }).appendChild(li);
    }
}
customElements.define('x-ingr', IngredientElement);

Fait étonnant : la norme nous imposer de choisir un nom d'élément comportant un tiret. Une contrainte qui rallonge le nom de ma balise personnalisée ! 😠

Recalculer les proportions

// Interpréter une string contenant une fraction
function parseFraction(input) {
    const [num, denom, err] = input.split('/').map(parseFloat);
    if (num && denom && err === undefined)
        return num / denom;
    return parseFloat(input.replace(',', '.')) || 0;
}

// Calcul
function applyMultiplier() {
    const multiplier = parseFraction(this.value.trim()); // (1)!
    this.parentNode.previousElementSibling
        .querySelectorAll('x-ingr')
        .forEach(ingr => {
            ingr.shadowRoot.querySelector('.qty').textContent = 1*
                (parseFloat(ingr.getAttribute('qty')) * multiplier)
                .toPrecision(3);
        });
}
  1. Si vous ne voulez pas de gestion de fractions, supprimez la fonction parseFraction et remplacez son appel ici par la ligne avec parseFloat surlignée plus haut.

Notez en particulier le mécanisme suivant :

= 1 * parseFloat(...).toPrecision(3)
  • parseFloat() permet de lire une valeur décimale dans une chaîne ;
  • .toPrecision(3) pour ne conserver que 3 chiffres significatifs (qu'ils soient avant ou après la virgule) ;
  • 1 * afin de ne pas conserver de zéro inutile après la virgule (1.00 deviendra 1).

Profit

Le fichier complet :

recipe-ingredients.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Élément HTML personnalisé représentant un ingrédient de recette
class IngredientElement extends HTMLElement {
    constructor() {
        super();
        const qty = this.getAttribute('qty');
        const text = this.textContent.trim();
        const li = document.createElement('li');
        li.innerHTML = `<span class="qty">${qty}</span> ${text}`;
        this.attachShadow({ mode: 'open' }).appendChild(li);
    }
}
customElements.define('x-ingr', IngredientElement);

// Insertion du multiplicateur après chaque liste d'ingrédients
document.querySelectorAll('.ingredients').forEach(ingredientsList => {
    const label = document.createElement('label');
    label.innerHTML = `Multiplier les quantités par : <input type="text" class="multiplier" value="1" title="Possible formats: .5 — 2/3 — 2 — 3,5">`;
    label.querySelector('.multiplier').addEventListener('input', applyMultiplier);
    ingredientsList.insertAdjacentElement('afterend', label);
});

// Interpréter une string contenant une fraction
function parseFraction(input) {
    const [num, denom, err] = input.split('/').map(parseFloat);
    if (num && denom && err === undefined)
        return num / denom;
    return parseFloat(input.replace(',', '.')) || 0;
}

// Calcul
function applyMultiplier() {
    const multiplier = parseFraction(this.value.trim());
    this.parentNode.previousElementSibling
        .querySelectorAll('x-ingr')
        .forEach(ingr => {
            ingr.shadowRoot.querySelector('.qty').textContent = 1*
                (parseFloat(ingr.getAttribute('qty')) * multiplier)
                .toPrecision(3);
        });
}

NB : j'ai essayé de raccourci au maximum mon code pour qu'il cible l'essentiel et rentre en 40 lignes, parfois au dépend de la facilité de lecture.

Keep cooking and KISS on!