UX Icons JS : les icônes de Symfony UX, enfin en JavaScript, React et Vue

Vos icônes ux_icon() en JavaScript vanilla, dans un contrôleur Stimulus, un composant React ou Vue. Mêmes attributs, même HTML, au caractère près.

UX Icons JS : les icônes de Symfony UX, enfin en JavaScript, React et Vue

Si vous avez un projet Symfony récent, il y a de bonnes chances que vos icônes passent par Symfony UX Icons. Et c'est vrai que c'est agréable : une ligne de Twig, n'importe laquelle des plus de 200 000 icônes d'Iconify, un ux:icons:lock avant de déployer, et c'est réglé.

{{ ux_icon('lucide:trash-2', { class: 'text-danger', 'aria-label': 'Supprimer' }) }}

Tout va bien. Jusqu'au jour où cette même poubelle doit apparaître dans un contrôleur Stimulus. Ou dans le composant React de la page d'à côté. Là, Twig ne peut plus rien pour vous, et on finit tous par écrire ce genre de joyeuseté :

// assets/controllers/delete_controller.js
const TRASH_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`

this.buttonTarget.innerHTML = TRASH_ICON

Un SVG copié-collé depuis le navigateur, des attributs recopiés à la main depuis ux_icons.yaml (ou pas), et un innerHTML qui fera tiquer votre Content Security Policy. Côté React, c'est lucide-react qui s'invite dans le package.json : une deuxième source d'icônes, avec ses propres versions et ses propres attributs par défaut. Et le jour où vous changez default_icon_attributes, vos icônes Twig changent. Pas les autres. Le fameux "je synchroniserai plus tard".

L'histoire d'une PR refusée

Mon premier réflexe a été de proposer la fonctionnalité directement à Symfony UX : symfony/ux#4072. L'idée : sur le warmup du cache, UX Icons exporte les icônes locales dans un module JavaScript, avec les attributs que ux_icon() leur donne. Un petit package npm les affiche ensuite en JavaScript, React et Vue.

La PR a été fermée. Et pour être honnête, les arguments se tiennent : les bundlers savent déjà importer des .svg (avec SVGR ou vite-svg-loader), et l'ajout de React et Vue en dépendances de toutes les applications qui utilisent UX Icons, ce n'était pas un bon deal.

🤓 « Mais alors, SVGR fait déjà le job, non ? »

Pas tout à fait. SVGR n'a pas vu de release depuis août 2023, et vite-svg-loader ne connaît que Vite et Vue. Surtout, aucun des deux ne couvre le JavaScript vanilla d'un contrôleur Stimulus. Et aucun ne lit votre config ux_icons : vos icônes JS n'auront jamais les mêmes attributs que vos icônes Twig.

Le code était écrit, testé, et il me servait. Je l'ai donc sorti en bundle à part, qui se branche sur un UX Icons non modifié. Open source, évidemment : je vous présente UX Icons JS Bundle. Et React et Vue y sont des dépendances optionnelles : vous n'installez que ce dont vous vous servez.

Une icône, trois façons de l'afficher

Le bundle dumpe vos icônes dans var/icons/index.js, un export nommé par icône. Vous importez celle dont vous avez besoin, comme n'importe quel module :

// En JavaScript vanilla, par exemple dans un contrôleur Stimulus
import { createIconElement } from '@silarhi/ux-icons-js'
import { LucideTrash2Icon } from '../../var/icons/index.js'

this.buttonTarget.append(createIconElement(LucideTrash2Icon, { class: 'text-danger', 'aria-label': 'Supprimer' }))
// En React (ou '@silarhi/ux-icons-js/vue' pour Vue)
import { UXIcon } from '@silarhi/ux-icons-js/react'
import { LucideTrash2Icon } from '../var/icons/index.js'

<UXIcon icon={LucideTrash2Icon} className="text-danger" aria-label="Supprimer" />

Et voilà ce que vous obtenez : exactement le HTML de ux_icon(). Pas "à peu près le même". Le même, caractère pour caractère, avec vos default_icon_attributes, les icon_attributes de vos icon sets, vos alias et vos suffixes. Le tout sans un seul innerHTML.

La magnifique démo de cet article : 👉 https://labs.silarhi.fr/icons

En résumé, avant / après :

Sans UX Icons JS Avec UX Icons JS
Source des icônes SVG copiés-collés, ou une lib par framework Vos icônes UX Icons, et rien d'autre
Attributs Recopiés à la main depuis ux_icons.yaml Lus depuis la config, comme ux_icon()
JavaScript vanilla innerHTML et une chaîne de caractères createIconElement(), sans parser de HTML
React / Vue lucide-react, @heroicons/vue… Un composant UXIcon
Taille du bundle Ça dépend de la lib Seules les icônes importées
Nom d'icône venant d'une API Tout embarquer, ou un switch géant loadIcon() : un chunk par icône
Nouvelle icône Télécharger, copier, coller Écrire l'import, ux:icons:lock

C'est parti

Deux packages, publiés depuis le même tag git : le bundle Symfony qui dumpe les icônes, et le package npm qui les affiche.

composer require silarhi/ux-icons-js-bundle
pnpm add @silarhi/ux-icons-js

Si Flex ne l'a pas fait pour vous, enregistrez le bundle après UXIconsBundle, dont il réutilise la configuration :

// config/bundles.php
return [
    // ...
    Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
    Silarhi\UxIconsJsBundle\SilarhiUxIconsJsBundle::class => ['all' => true],
];

Et c'est tout pour la configuration. Les icônes sont dumpées au prochain warmup du cache, ou à la main pour voir ce qui se passe :

$ php bin/console ux-icons-js:dump -v
 * lucide:trash-2
 * user

 [OK] Dumped 2 icon(s).

Le dossier var/icons/ est généré : il n'a rien à faire dans git. Si var/ est déjà dans votre .gitignore (c'est le cas par défaut), vous n'avez rien à faire. Avec AssetMapper, le bundle mappe le dossier tout seul, il ne reste qu'à lui donner un nom dans importmap.php. Tout est détaillé dans la doc.

Le plus dur est fait ! TOP.

Les détails qui m'ont donné du fil à retordre

Comme souvent, le gros du travail n'est pas là où on l'attend.

Le même HTML, vraiment

Promettre "le même HTML que ux_icon()", c'est facile. Le tenir, c'est une autre histoire. Les attributs passés à ux_icon() suivent les règles de la fonction html_attr() de Twig, et elles ont leurs subtilités :

renderIcon(LucideHeartIcon) // … aria-hidden="true">
renderIcon(LucideHeartIcon, { 'aria-label': 'Favori' }) // … aria-label="Favori">
renderIcon(LucideHeartIcon, { class: ['btn-icon', isActive && 'active'] }) // … class="btn-icon" si isActive est false
renderIcon(LucideHeartIcon, { height: null }) // plus de height du tout
renderIcon(LucideHeartIcon, { 'data-config': { id: 1 } }) // … data-config="{&quot;id&quot;:1}">

aria-hidden="true" est ajouté sauf si l'icône a une alternative textuelle, null supprime un attribut (même configuré), les tableaux deviennent des listes de classes, les objets des data-* en JSON… Le côté JavaScript reproduit tout ça, jusqu'à la position des attributs : un attribut redéfini garde sa place dans la balise <svg>, comme en PHP. C'est d'ailleurs un bug que j'avais laissé passer dans la PR, et que le bundle corrige.

Pour être sûr de ne pas se raconter d'histoires, les tests comparent la sortie de renderIcon() à celle de ux_icon(), octet par octet. La démo le fait aussi, en direct dans votre navigateur.

Un export par icône, pour le tree-shaking

Chaque icône est un export nommé : le nom de l'icône en PascalCase, suivi de Icon. Votre bundler ne garde que celles que vous importez, et les autres restent au placard.

Icône Export
user UserIcon
lucide:trash-2 LucideTrash2Icon
fa7-brands:x-twitter Fa7BrandsXTwitterIcon
500 _500Icon

Petite subtilité : deux icônes peuvent donner le même nom d'export (sub:check et un alias sub-check donnent tous les deux SubCheckIcon). Plutôt que d'en écraser une en silence, le dump échoue et vous donne les deux noms. Une icône qui change toute seule en prod, c'est le genre de bug qu'on met une après-midi à comprendre.

Pour TypeScript, un index.d.ts est dumpé à côté, avec le nom littéral de chaque icône.

Et quand le nom de l'icône vient d'une API ?

C'est LE cas qui m'a fait sortir une version 0.2.0 dans la foulée. Un import nommé, c'est très bien quand vous connaissez l'icône en écrivant le code. Mais quand votre API renvoie { "icon": "lucide:server" }, vous ne la connaissez qu'au runtime. Et embarquer toutes les icônes "au cas où", c'est exactement ce qu'on voulait éviter.

Le bundle dumpe donc aussi un loaders.js, qui associe chaque nom d'icône au module de cette icône seule :

import { createIconElement, loadIcon } from '@silarhi/ux-icons-js'
import { loaders } from '../var/icons/loaders.js'

const data = await (await fetch('/api/status')).json()

const icon = await loadIcon(loaders, data.icon) // null pour un nom inconnu
if (icon) {
    badge.append(createIconElement(icon, { class: 'text-success' }))
}

Votre bundler découpe chaque icône dans son propre chunk : importer loaders ne télécharge aucune icône, et loadIcon() ne télécharge que celle demandée. Une seule fois, même si dix composants la demandent en même temps. En React et Vue, c'est le composant UXLazyIcon, avec un fallback pendant le chargement :

import { UXLazyIcon } from '@silarhi/ux-icons-js/react'
import { loaders } from '../var/icons/loaders.js'

<UXLazyIcon name={service.icon} loaders={loaders} fallback={<span className="placeholder" />} />

Et le loaders.d.ts exporte l'union des noms dumpés, IconName, pour typer vos réponses d'API ou vos props : une faute de frappe dans un nom d'icône ne passe plus la compilation.

ux:icons:lock, côté JavaScript

Avec UX Icons, le workflow classique est d'écrire ux_icon('lucide:mail') dans un template, puis de lancer ux:icons:lock : la commande trouve l'icône dans vos templates et la télécharge depuis Iconify. Je voulais exactement la même chose en JavaScript :

// assets/controllers/mail_controller.js
import { LucideMailIcon } from '../../var/icons/index.js'
$ php bin/console ux:icons:lock
 ✓ Imported lucide:mail (License: ISC). Render with: {{ ux_icon('lucide:mail') }}
 JavaScript: import { LucideMailIcon }
 // Dumped 3 icon(s) for JavaScript.

Vous écrivez l'import d'une icône qui n'existe pas encore, vous lancez la commande, et elle existe. Le bundle scanne vos fichiers .js, .jsx, .ts, .tsx, .vue et .svelte à la recherche des imports nommés depuis un module dont le chemin contient icons, et retrouve le nom de l'icône depuis le nom de l'export. Pas toujours une seule : LucideTrash2Icon peut venir de lucide:trash-2 ou de lucide:trash2. Tous les candidats sont proposés à ux:icons:lock, qui ignore ceux qu'Iconify ne connaît pas.

PHP 8.2 et Symfony 6.4

La PR visait la dernière version de UX Icons, donc PHP 8.4. Le bundle, lui, descend jusqu'à PHP 8.2, Symfony 6.4 et symfony/ux-icons 2.36.1 : parce que dans la vraie vie, les projets ne sont pas tous à jour (les miens non plus 🤫). Il y a juste deux limites sur UX Icons 2.x, ux:icons:lock n'y scanne pas le JavaScript, et les booléens n'y sont pas rendus pareil. Tout est dans les notes de compatibilité.

Ça donne quoi au final ?

La démo de cet article : 👉 https://labs.silarhi.fr/icons

Vous y trouverez la même icône rendue par Twig, par JavaScript et par React (avec la comparaison octet par octet), un bouton "copier" en Stimulus qui change d'icône sans innerHTML, une notation en étoiles en React, et un tableau de statut dont les icônes viennent d'une API : ouvrez l'onglet réseau, chaque icône arrive dans son propre petit chunk. Le code est sur silarhi/symfony-docker-ci, le projet qui me sert depuis des années pour les démos de ce blog.

Pour les curieux, sous le capot :

  • PHP 8.2+, Symfony 6.4, 7.x et 8.x, symfony/ux-icons 2.36.1+ ou 3.5+
  • React 18 / 19 et Vue 3 en dépendances optionnelles
  • Webpack Encore, Vite, AssetMapper : n'importe quel bundler fait l'affaire
  • Le corps du SVG est dumpé sous forme d'arbre, React et Vue le rendent en éléments : zéro dangerouslySetInnerHTML, zéro v-html
  • 100 % du code couvert par les tests, et chaque combinaison de versions testée par la CI

Voilà ! Vos icônes n'ont plus d'excuse pour exister en deux exemplaires. N'hésitez pas à laisser un commentaire si vous l'utilisez, ou si quelque chose coince. Et si le bundle vous fait gagner du temps, une petite ⭐ sur GitHub fait toujours plaisir.

Pour aller plus loin