Components

All available UI components with usage examples.

Button

Variants, sizes, and icon support.

Variants

Soft colors

Sizes

With icons

<%- include('../ui/button', { label: 'Save', variant: 'regular', color: 'primary', size: 'md' }) %>
<%- include('../ui/button', { label: 'Warning', variant: 'soft', color: 'warning', size: 'md' }) %>
<%- include('../ui/button', { label: 'Next', variant: 'outline', color: 'primary', size: 'md', iconRight: 'arrow-right' }) %>

Badge

Variants

Default Muted Success Warning Danger Info

With icons

Active Warning Error Info New
<%- include('../ui/badge', { label: 'Active', variant: 'success' }) %>
<%- include('../ui/badge', { label: 'Active', variant: 'success', iconName: 'circle-check' }) %>

Alert

// Usage
<%- include('ui/alert', {
  variant: 'info',    // info | success | warning | danger
  title: 'Titre',   // optionnel
  message: 'Contenu HTML autorisé.',
}) %>

Spinner

Indicateur de chargement CSS-only. S'intègre dans les boutons, cards ou sections.

Tailles

sm
md
lg

Bouton — prop loading (SSR)

Bouton — toggle JS setLoading()

Cliquer pour simuler un chargement de 2s
// Spinner standalone
<%- include('ui/spinner', {
  size: 'md',  // sm | md | lg
}) %>
// Bouton avec état loading (SSR)
<%- include('ui/button', {
  label: 'Enregistrer',
  variant: 'regular',
  color: 'primary',
  loading: true,  // désactive + masque le label + affiche le spinner
}) %>
// Toggle dynamique via JS
import { setLoading } from '/ui/utils.js';
 
const btn = document.querySelector('#my-btn');
btn.addEventListener('click', async () => {
  setLoading(btn, true);
  await fetch('/api/save', { method: 'POST' });
  setLoading(btn, false);
});

Skeleton

Placeholder CSS-only pour le contenu en cours de chargement. Trois presets et un mode custom.

Text

Card

List

Custom — classe .ui-skeleton directement

// Presets
<%- include('ui/skeleton', { type: 'text', lines: 3 }) %>  // type: text | card | list
<%- include('ui/skeleton', { type: 'card' }) %>
<%- include('ui/skeleton', { type: 'list', items: 4 }) %>
// Composition custom avec la classe .ui-skeleton
<div class="flex items-center gap-3">
  <div class="ui-skeleton h-10 w-10 rounded-full"></div>  // avatar
  <div class="flex-1 space-y-2">
    <div class="ui-skeleton h-4 w-3/4"></div>                                      // nom
    <div class="ui-skeleton h-3 w-1/2"></div>                                      // sous-titre
  </div>
</div>
// Injection dynamique via JS (post-SSR)
import { showSkeleton } from '/ui/utils.js';
 
const container = document.querySelector('#results');
 
showSkeleton(container, 'list', { items: 4 });  // affiche le skeleton
 
const data = await fetch('/api/users').then(r => r.json());
container.innerHTML = renderUsers(data);       // remplace naturellement

Card

Card title

Card subtitle or description

Card body content goes here. It can contain any HTML.

Simple card with inline padding, no header/footer slots needed.

<div class="ui-card">
  <div class="ui-card-header"><h3>Title</h3><p>Subtitle</p></div>
  <div class="ui-card-body">Content</div>
  <div class="ui-card-footer">Footer</div>
</div>

Drawer

<button data-ui="drawer-trigger" data-target="my-drawer">Open</button>
<%- include('../ui/drawer', {
  id: 'my-drawer',
  title: 'Settings',
  titleIcon: 'settings',                 <%# optional %>
  side: 'right',                   <%# left | right | top | bottom %>
  body: '...',
}) %>

Sliding Drawer

A panel anchored to a screen edge with a persistent grab handle. Click or drag to open/close. Backdrop opacity follows drag progress.

side="right"

side="left"

side="bottom"

side="top"

size="full"

side="right" size="full"

side="bottom" size="full"

handle variants

handle="arrow"

handle="icon"

handle="text"

<%- include('../ui/sliding-drawer', {
  id: 'my-panel',
  side: 'right', <%# left | right | top | bottom %>
  size: 'auto', <%# auto | full %>
  overlay: true, <%# backdrop (default: true) %>
  handle: 'grip', <%# grip | arrow | icon | text %>
  handleIcon: 'settings',<%# Lucide icon (handle="icon") %>
  handleText: 'Menu', <%# label (handle="text") %>
  body: '...',
}) %>

Anchor Nav

Page section navigation with scrollspy, sticky desktop placement, and optional drawer support on mobile.

Overview

This fake documentation block gives the preview real anchor targets. Click the nav items to jump between sections inside the sandbox.

Usage

The nav stays sticky while this panel scrolls, and the active link follows the visible fake section.

API

Items are explicit, so the nav stays predictable even when the page contains headings that should not appear in the sidebar.

items: [{ id, label }]
const sections = [
  { id: 'overview', label: 'Overview' },
  { id: 'usage',    label: 'Usage' },
  { id: 'api',      label: 'API' },
];
<%- include('../ui/anchor-nav-trigger', { target: 'page-nav-drawer' }) %>
<%- include('../ui/anchor-nav', {
  id: 'page-nav',                                                                                                                      // root id
  items: sections,                                                                                        // [{ id, label }]
  mobileDrawer: { id: 'page-nav-drawer', side: 'right' },   // optional mobile drawer
}) %>
// Autonomous scroll container
<div id="page-nav-sandbox" class="h-96 overflow-y-auto">
  <%- include('../ui/anchor-nav', {
    id: 'sandbox-nav',                                                      // independent nav id
    items: sections,                                                       // links to render
    targetSelector: '[data-section]',                        // observed sections
    scrollContainer: '#page-nav-sandbox',             // scroll root
    offsetSelector: '',                                         // no page header offset
  }) %>
  <section id="overview" data-section>...</section>
</div>

Accordion

mode: "single" — un seul panneau ouvert à la fois

A shadcn/ui-inspired design system for Express + EJS. No React, no Bootstrap — pure Tailwind CSS and Vanilla JS.

No bundler needed. Tailwind CSS is compiled via the CLI. JavaScript uses native ES modules loaded directly by the browser.

Create an EJS partial in src/views/ui/, a JS module in public/ui/components/, register it in the loader map, and add CSS in assets/css/input.css.

mode: "multiple" — plusieurs panneaux peuvent rester ouverts simultanément

Orders are shipped within 2 business days. Express delivery is available at checkout.

Items can be returned within 30 days of purchase. Contact support to initiate a return.

We accept Visa, Mastercard, PayPal, and bank transfers. All transactions are secured.

open: true — panneau ouvert au chargement

Ce panneau est ouvert par défaut grâce à open: true.

Ce panneau est fermé au chargement.

Ce panneau est fermé au chargement.
// mode: 'single' (défaut) — ouvrir un item ferme les autres
<%- include('../ui/accordion', {
  mode: 'single',
  items: [
    { id: 'q1', title: 'Question ?', content: 'Réponse.', open: true },  // ouvert au chargement
    { id: 'q2', title: 'Autre ?', content: 'Réponse.' },
  ],
}) %>
// mode: 'multiple' — chaque item s'ouvre/ferme indépendamment
<%- include('../ui/accordion', {
  mode: 'multiple',
  items: [
    { id: 'a', title: 'Un', content: '...', open: true },  // plusieurs open: true possibles
    { id: 'b', title: 'Deux', content: '...', open: true },
  ],
}) %>

Tooltip

<%- include('../ui/tooltip', {
  content: 'Helpful hint',
  placement: 'top',
  trigger: '<button class="ui-btn ui-btn-outline ui-btn-color-primary ui-btn-sm">Hover me</button>',
}) %>

Popover

<%- include('../ui/popover', {
  placement: 'bottom',
  trigger: '<button class="ui-btn ui-btn-outline ui-btn-color-primary ui-btn-md">Open</button>',
  body: '<p>Popover content here.</p>',
}) %>

Toast

Variants

Positions

Toasts are triggered via JavaScript — no EJS include needed. One container per position is injected automatically on first use.

// Basic usage
window.UI.toast({ variant: 'success', message: 'Saved.' })
// All options
window.UI.toast({
  title: 'Optional title',     // string — bold heading
  message: 'Body text here.',   // string — supports HTML
  variant: 'success',          // info | success | warning | danger
  duration: 4000,              // ms — 0 = sticky (no auto-dismiss)
  position: 'bottom-right',    // bottom-right | bottom-center | bottom-left | top-right | top-center | top-left
})
// Custom event (usable from any module)
document.dispatchEvent(new CustomEvent('ui:toast', {
  detail: { variant: 'warning', message: 'Unsaved changes.', position: 'top-center' }
}))

Tabs

Navigation par onglets accessible (ARIA). Chaque onglet peut recevoir une icône Lucide positionnée au début ou à la fin du label. L'onglet actif au chargement est défini par active: true sur l'item correspondant.

Sans icône

Manage your account settings and preferences.

Avec icônes

Paramètres du compte.

Onglet actif par défaut (2e)

Historique des actions récentes.

// Usage complet
<%- include('ui/tabs', {
  id: 'my-tabs',                               // requis, doit être unique sur la page
  class: 'w-full',                                // classes extra sur le conteneur racine
  items: [
    {
      label: 'Compte',                       // texte de l'onglet
      icon: 'user',                        // nom Lucide (optionnel)
      iconPosition: 'start',                      // 'start' | 'end' (default: 'start')
      content: '<p>Contenu HTML du panneau</p>', // HTML brut (<%- %>)
      active: true,                         // onglet affiché au chargement (1 seul)
    },
    { label: 'Sécurité', icon: 'lock', content: '…' },
  ],
}) %>

Navigation clavier

← → Naviguer entre les onglets Home / End Premier / dernier onglet Tab Passer au panneau actif

Table

Composant de style : les classes CSS gèrent l'apparence, le JS gère le tri (clic → event table:sort), le choix du nombre de lignes, et les offsets des lignes/colonnes figées. Le rechargement des données reste à la charge de l'application.

Basique

Email Rôle Statut
Alice Martin alice@example.com Admin Actif
Bob Dupont bob@example.com Éditeur Actif
Clara Schmidt clara@example.com Lecteur En attente
David Leroy david@example.com Éditeur Inactif
Eva Nguyen eva@example.com Lecteur Actif

Tri actif (name asc)

Abonnements

Alice Martin Pro 12 jan. 2024 29,00 €
Bob Dupont Gratuit 03 mar. 2024 0,00 €
Clara Schmidt Entreprise 27 nov. 2023 99,00 €

Striped + compact

Pays Code Utilisateurs
France FR 12 483
Allemagne DE 9 201
Espagne ES 7 654
Italie IT 6 890
Pays-Bas NL 4 312
Belgique BE 3 107

Sélection de lignes

Nom Email Équipe Statut
Alice Martin alice@example.com Produit Actif
Bob Dupont bob@example.com Support Actif
Clara Schmidt clara@example.com Finance En attente
David Leroy david@example.com Ops Verrouillé

Lignes + colonnes figées

Équipe Owner Jan Fév Mar Avr Mai Juin Juil Août Total
Total Toutes équipes 1 245 1 382 1 517 1 604 1 721 1 890 1 943 2 031 13 333
Acquisition Lina Moreau 182 210 238 249 272 301 318 330 2 100
Activation Hugo Bernard 154 168 191 207 225 248 263 276 1 732
Retention Nora Klein 203 229 251 268 294 327 341 359 2 272
Support Marc Vidal 121 137 145 153 166 181 194 202 1 299
Finance Emma Rossi 98 104 116 123 131 144 149 155 1 020
Produit Sofia Martin 173 192 216 229 244 267 281 296 1 898
Data Noah Dubois 133 148 167 178 191 209 218 231 1 475
Ops Iris Lefèvre 94 106 118 129 141 153 160 169 1 070
Partenariats Tom Alvarez 87 88 75 68 57 60 19 13 467

État vide

Nom Email Rôle
Aucun utilisateur trouvé.
// Usage EJS
<%- include('ui/table', {
  id: 'my-table',                                   // optionnel
  tableTitle: 'Utilisateurs', tableTitleAs: 'h2',  // titre visible
  striped: true,                                         // lignes alternées
  compact: true,                                         // padding réduit
  sort: { key: 'name', dir: 'asc' },                // tri actif initial
  rowsLimit: { value: 25, defaultValue: 25, options: [10, 25, 50, 100],
    name: 'limit', label: 'Lignes', persist: true, storageKey: 'my-table:rows-limit' },
  selectable: {
    key: 'id', name: 'selectedUsers[]', selected: ['u-102'],
    disabledKey: 'locked', dataAttribute: 'user-id', sticky: true,
  },
  sticky: {
    breakpoints: {
      base: { header: true, topRows: 0, leftColumns: 0, rightColumns: 0, maxScrollHeight: '16rem' },
      md: { header: true, topRows: 0, leftColumns: 1, rightColumns: 0 },
      lg: { header: true, topRows: 1, leftColumns: 2, rightColumns: 1, maxScrollHeight: '18rem' },
    },
  },
  columns: [
    { key: 'name', label: 'Nom', sortable: true },
    { key: 'email', label: 'Email' },
    { key: 'score', label: 'Score', sortable: true, align: 'right', width: '80px' },
  ],
  rows: [
    { name: 'Alice', email: 'alice@…', score: '98',
      rowDataAttributes: { url: '/users/u-101', userId: 'u-101' },
      cellDataAttributes: { email: { url: 'mailto:alice@…' }, score: { value: 98 } } },
  ],
  empty: 'Aucune donnée.',
}) %>
// Usage HTML direct (classes utilitaires)
<div class="ui-table-wrapper">
  <table class="ui-table ui-table-striped">
    <thead><tr>
      <th class="ui-th">Nom</th>
      <th class="ui-th" data-align="right">Montant</th>
    </tr></thead>
    <tbody><tr>
      <td class="ui-td">Alice</td>
      <td class="ui-td" data-align="right">29,00 €</td>
    </tr></tbody>
  </table>
</div>
// Réagir au tri, au nombre de lignes et à la sélection (events bubbles)
document.addEventListener('table:sort', e => {
  const { key, dir } = e.detail;  // dir: 'asc' | 'desc' | null
  // recharger les données avec le nouveau tri
});
 
document.addEventListener('table:rows-limit-change', e => {
  const { value } = e.detail;  // 10 | 25 | 50 | 100
  // recharger les données avec limit=value
});
 
document.addEventListener('table:selection-change', e => {
  const { selected } = e.detail;  // ['u-101', 'u-102']
  // appeler une API ou synchroniser une action groupée
});
 
document.addEventListener('click', e => {
  if (e.target.closest('a, button, input, select, textarea')) return;
  const cell = e.target.closest('td[data-url]');
  if (cell) return window.location.assign(cell.dataset.url);
  const row = e.target.closest('tr[data-url]');
  if (!row) return;
  window.location.assign(row.dataset.url);
});
 
const values = document.querySelector('#my-table').__uiTableSelection.getSelected();

Pagination

Composant générique pour paginer une table, une liste, une galerie ou une grille. Il expose le choix de page via pagination:page-change. La récupération des données reste à la charge de l'application.

Résultats paginés

Faible volume (10 pages)

Grand volume

// Usage EJS
<%- include('ui/pagination', {
  id: 'users-pagination',  // id HTML optionnel
  class: 'mt-4',  // classes CSS additionnelles sur le nav
  page: 3,  // page courante, bornée entre 1 et totalPages
  totalItems: 284,  // total global d'items, utilisé pour le résumé et le calcul de totalPages
  itemsPerPage: 25,  // items par page, générique table/liste/galerie
  totalPages: 12,  // optionnel, prioritaire sur le calcul depuis totalItems/itemsPerPage
  siblings: 1,  // pages voisines autour de la page courante
  boundaries: 1,  // pages toujours visibles au début et à la fin
  showAllThreshold: 10,  // affiche tout sous ce seuil si la largeur disponible le permet
  name: 'page',  // nom métier dans pagination:page-change
  href: page => '/users?page=' + page + '&limit=25',  // optionnel, fallback SSR ou navigation native
  showSummary: true,  // affiche "x-y sur n" quand totalItems/itemsPerPage sont présents
  summaryLabel: 'sur',  // libellé du résumé entre la plage et le total
  ariaLabel: 'Pagination des utilisateurs',  // label accessible du nav
  previousLabel: 'Précédent',  // texte visible du contrôle précédent
  nextLabel: 'Suivant',  // texte visible du contrôle suivant
  previousAriaLabel: 'Page précédente',  // label accessible du contrôle précédent
  nextAriaLabel: 'Page suivante',  // label accessible du contrôle suivant
}) %>
// Coordination avec limit / rowsLimit
document.addEventListener('table:rows-limit-change', e => {
  form.elements.limit.value = e.detail.value;
  form.elements.page.value = 1;
  form.requestSubmit();
});
 
document.addEventListener('pagination:page-change', e => {
  e.preventDefault();  // optionnel si href est fourni
  form.elements.page.value = e.detail.page;
  form.requestSubmit();
});

Form controls

Input

We'll never share your email.

Textarea & Select

Checkbox & Radio

Checkboxes

Receive product updates and news.

Notification preference

Input

<%- include('../ui/input', {
  id: 'email',
  label: 'Email',
  type: 'email',                      <%# text | email | password | number… %>
  placeholder: 'you@example.com',
  helpText: 'We'll never share it.',  <%# ou errorText %>
  required: true,
  disabled: false,
}) %>

Textarea

<%- include('../ui/textarea', {
  id: 'message',
  label: 'Message',
  placeholder: 'Write something…',
  rows: 4,                               <%# default: 4 %>
  helpText: 'Max 500 characters.',  <%# ou errorText %>
  required: true,
}) %>

Select

<%- include('../ui/select', {
  id: 'country',
  label: 'Country',
  value: 'fr',                                  <%# pré-sélectionné %>
  options: [
    { value: 'fr', label: 'France' },
    { value: 'de', label: 'Germany', disabled: true },
    <%# ou groupes : %>
    { group: 'Europe', options: [{ value: 'es', label: 'Spain' }] },
  ],
}) %>

Checkbox

<%- include('../ui/checkbox', {
  id: 'terms',
  name: 'terms',
  label: 'Accept terms and conditions',
  value: '1',                                 <%# default: '1' %>
  checked: false,
  disabled: false,
  helpText: 'Read our terms before accepting.',
}) %>

Radio

<%- include('../ui/radio', {
  name: 'notif',
  label: 'Notification preference',  <%# legend du fieldset %>
  options: [
    { value: 'all', label: 'All notifications', checked: true },
    { value: 'important', label: 'Important only', helpText: 'Mentions and DMs only.' },
    { value: 'none', label: 'None', disabled: true },
  ],
}) %>
// Propriétés communes à tous les controls
helpText: 'Texte d'aide sous le champ'                       // classe ui-help-text
errorText: 'Message d'erreur (active la bordure rouge)'     // classe ui-error-text + role="alert"
required: true                                                  // ajoute * rouge après le label
disabled: true                                                  // opacité + pointer-events none
attrs: { 'data-lpignore': 'true', autocomplete: 'off' }  // attributs HTML arbitraires

Autocomplete

Champ texte avec suggestions récupérées depuis le backend. Supporte la navigation clavier, l'annulation des requêtes en vol et l'auto-sélection sur correspondance exacte unique.

Les suggestions arrivent après 1 caractère.

Affichage résolu via fetchByIdUrl au chargement.

// Usage
<%- include('ui/autocomplete', {
  id: 'city',
  name: 'city_id',                       // nom de l'input hidden soumis
  label: 'Ville',
  placeholder: 'Tapez pour rechercher…',
  fetchUrl: '/api/cities/search',          // GET ?search=... → { result: [...] }
  fetchByIdUrl: '/api/cities',                 // GET /{id} → { result: {...} } (optionnel)
  keyName: 'name',                         // propriété affichée (default: 'label')
  idKey: 'id',                           // propriété enregistrée (default: 'id')
  value: 'Paris',                       // texte initial (optionnel)
  selectedId: '75056',                     // ID initial dans le hidden (optionnel)
  minChars: 2,                             // caractères min avant fetch (default: 1)
  debounce: 300,                         // anti-rebond en ms (default: 300)
  required: true,
  helpText: 'Saisissez au moins 2 caractères.',
  errorText: 'Champ requis.',
}) %>
// Hooks JS (après init)
const ac = document.querySelector('[data-id="city"]').closest('[data-ui="autocomplete"]');
 
// Callback à la sélection
ac.__onItemSelected = item => console.log('Sélection :', item);
 
// Ou via événement DOM (bubbles)
document.addEventListener('autocomplete:select', e => console.log(e.detail.item));
 
// Filtre côté client après fetch
ac.__filterSuggestions = items => items.filter(i => !i.archived);

Upload

Zone de dépôt de fichier. Supporte trois modes d'interaction : clic (sélecteur natif), glisser-déposer et collage (Ctrl+V) depuis le presse-papier. La validation du type et de la taille s'applique dans les trois cas côté client. Supporte un ou plusieurs fichiers, les miniatures images et le mode édition (fichiers préchargés depuis le serveur).

Votre CV sera transmis aux recruteurs. Formats acceptés : PDF.

Mode édition — fichier existant

Le fichier préchargé est affiché dès le rendu côté serveur. Supprimer le fichier efface la valeur du champ caché. Sélectionner un nouveau fichier remplace automatiquement l'existant.

  • contrat-2024.pdf 181.9 Ko

Mode édition — plusieurs fichiers existants

Chaque fichier existant produit un <input type="hidden" name="photos_existing[]">. La valeur est vidée si l'utilisateur supprime l'entrée. Les nouveaux fichiers s'ajoutent à la liste.

  • photo-1.jpg photo-1.jpg 82.2 Ko
  • photo-2.jpg photo-2.jpg 100.0 Ko
Prop Type Défaut Description
idstringautoIdentifiant HTML unique. Lié au <input type="file"> et aux champs cachés.
namestringidAttribut name du champ. Détermine aussi le nom des champs cachés pour les fichiers existants (name_existing / name_existing[]).
labelstringLibellé affiché au-dessus de la zone. Omis si non renseigné.
acceptstringTypes acceptés. Appliqué au sélecteur natif et validé côté JS pour le drag & drop et le collage. Syntaxe : MIME (image/*, application/pdf) ou extensions (.pdf,.docx), combinables.
multiplebooleanfalseAutorise la sélection de plusieurs fichiers. En mode single, chaque nouvelle sélection remplace la précédente et efface les fichiers existants.
maxSizenumberTaille maximale par fichier en octets. Validation JS — les fichiers trop lourds sont refusés avec un message d'erreur. S'applique au sélecteur, au drag & drop et au collage.
placeholderstringautoTexte cliquable de la zone d'upload (partie soulignée). Par défaut : "Choisir un fichier" ou "Choisir des fichiers" selon multiple.
subtextstringautoTexte secondaire à droite du placeholder. Par défaut : "ou glisser-déposer", avec "ou survoler et coller l'image" ajouté automatiquement si accept inclut des images. Passer "" pour supprimer.
hintstringTexte de format affiché sous le placeholder (ex. "PNG, JPG — 5 Mo max").
previewbooleanfalseAffiche une miniature pour chaque fichier image sélectionné (nouveaux fichiers et fichiers existants si type est renseigné).
existingFilesArray[]Fichiers préchargés pour le mode édition. Chaque entrée : { name, url, size?, type? }. Produit un <input type="hidden"> dont la valeur est effacée à la suppression.
helpTextstringTexte d'aide affiché sous la zone. Masqué si errorText est renseigné.
errorTextstringMessage d'erreur SSR affiché sous la zone. Ajoute un contour rouge sur la zone. Prioritaire sur helpText.
requiredbooleanfalseAjoute un marqueur * sur le label et l'attribut required sur le champ natif.
disabledbooleanfalseDésactive toutes les interactions (clic, drag & drop, collage). Applique un style visuel atténué.
// Usage complet
<%- include('ui/upload', {
  id: 'my-upload',
  name: 'files',
  label: 'Documents',
  accept: 'image/*',                      // MIME ou extensions (.pdf,.docx) — valide aussi drag&drop et collage
  multiple: true,                          // plusieurs fichiers
  preview: true,                          // miniatures images
  maxSize: 5 * 1024 * 1024,               // 5 Mo max par fichier
  placeholder: 'Sélectionner une image',       // texte cliquable (défaut: 'Choisir un/des fichier(s)')
  subtext: 'ou glisser-déposer',           // texte secondaire (défaut: auto selon accept)
  hint: 'PNG, JPG — 5 Mo max',
  helpText: 'Texte d'aide sous la zone.',
  errorText: 'Champ requis.',                  // prioritaire sur helpText
  required: true,
  disabled: false,
  existingFiles: [                           // mode édition — fichiers préchargés
    { name: 'photo.jpg', size: 84200, url: '/uploads/photo.jpg', type: 'image/jpeg' },
    // type: optionnel — active la miniature si preview: true
  ],
}) %>
// Fichiers existants — champs produits par le composant
Champ unique (multiple: false) → name="contrat_existing"
<input type="hidden" name="contrat_existing" value="/uploads/contrat-2024.pdf">
Plusieurs fichiers (multiple: true) → name="photos_existing[]"
<input type="hidden" name="photos_existing[]" value="/uploads/photo-1.jpg">
<input type="hidden" name="photos_existing[]" value="/uploads/photo-2.jpg">
Suppression : la value du hidden est vidée (""). En mode single, sélectionner un nouveau fichier vide aussi le hidden.
// Logique backend (Express + multer — champ unique)
const newFile = req.file;                             // multer / busboy
const keepUrl = req.body.contrat_existing;            // "" si supprimé
 
if (newFile) {
  // Nouveau fichier uploadé — enregistrer, déplacer, etc.
} else if (keepUrl) {
  // Fichier existant conservé — rien à faire
} else {
  // Fichier explicitement supprimé par l'utilisateur
}
// Événement upload:change — déclenché à chaque modification de la sélection
document.addEventListener('upload:change', e => {
  const { files, existingFiles } = e.detail;
  // files : FileList — nouveaux fichiers (sélecteur, drag&drop, collage)
  // existingFiles : [{ name, url }] — fichiers préchargés encore présents
  [...files].forEach(f => console.log(f.name, f.size, f.type));
});
 
// Cibler un composant spécifique (e.target = le div[data-ui="upload"])
document.getElementById('my-upload-wrapper').addEventListener('upload:change', e => {
  // L'événement bubbles — on peut aussi l'écouter sur un ancêtre
});

Timeline

Composant de frise chronologique. Supporte trois layouts (vertical, alterné, horizontal), des variantes de statut sur les nœuds, et deux modes d'usage : données (items) ou markup libre (timeline-item imbriqués).

Vertical — statuts & icônes

Commande passée

Paiement confirmé par carte bancaire.

En préparation

Votre colis est en cours de conditionnement.

Expédié

Numéro de suivi : FR123456789.

Livraison

Créneau estimé : 9h–13h.

Vertical — dots simples

Fondation

Création de la société à Lyon.

Série A

Levée de fonds de 2 M€.

Expansion

Ouverture de bureaux à Paris et Bordeaux.

Scale

Croissance de l'équipe : 40 collaborateurs.

International

Entrée sur les marchés belge et suisse.

Alterné

Fondation

Création de la société.

Série A

Levée de fonds de 2 M€.

Expansion

Bureaux à Paris et Bordeaux.

Scale

40 collaborateurs.

International

Marchés belge et suisse.

Horizontal — étapes de processus

Panier

Livraison

Paiement

Confirmation

Markup libre — corps HTML personnalisé

Contrat signé

Contrat N° 2025-042 signé par les deux parties.

Accès créé

Identifiants envoyés par email à alice@example.com.

Onboarding

Session prévue le 22 mai 2025.

Étapes numérotées — auto ascendant

1

Création du compte

Renseignez vos informations.
2

Vérification e-mail

Cliquez sur le lien reçu.
3

Configuration du profil

Complétez votre fiche.
4

Invitation des membres

Ajoutez vos collaborateurs.
5

Première publication

Mettez votre projet en ligne.

Étapes numérotées — auto descendant

4

Lancement v3

Mise en production effectuée.
3

Tests de charge

Validation des performances.
2

Code freeze

Gel du code — correctifs critiques seulement.
1

Recette QA

Validation en cours.

Horizontal — nodeStep libre

1

Panier

2

Livraison

3

Paiement

4

Confirmation

Prop Composant Type Défaut Description
layouttimelinestring'vertical'vertical | alternating | horizontal
steptimelinestringNumérotation auto en mode données — 'asc' (1, 2, 3…) ou 'desc' (N…1). Ignoré si l'item définit nodeStep.
itemstimelineArrayMode données — tableau d'objets item. Si absent, utilise body.
bodytimelinestringMode markup libre — HTML pré-rendu (includes timeline-item).
datetimeline-itemstringLabel temporel affiché au-dessus du titre.
titletimeline-itemstringTitre de l'événement.
bodytimeline-itemstringContenu HTML du corps. Rendu via <%-.
nodeIcontimeline-itemstringNom d'icône Lucide affiché dans le nœud. Prioritaire sur nodeStep.
nodeSteptimeline-itemstring | numberChiffre ou texte court affiché dans le nœud. Remplace le dot. Peut être passé manuellement ou injecté via step sur le parent.
statustimeline-itemstring'default'default | success | warning | error | info | muted. Colore le nœud.
activetimeline-itembooleanfalseMarque l'étape courante — nœud en couleur primaire avec anneau de focus. Prioritaire sur status.
// Mode données
<%- include('ui/timeline', {
  layout: 'vertical',                                // vertical | alternating | horizontal
  step: 'asc',                                     // 'asc' | 'desc' — numérotation auto (optionnel)
  items: [
    { date: 'Jan 2024', title: 'Étape 1', body: 'Description.', nodeIcon: 'check', status: 'success' },
    { date: 'Mar 2024', title: 'Étape 2', nodeStep: 'A', active: true },         // nodeStep libre, étape courante
    { date: 'À venir', title: 'Étape 3', status: 'muted' },                     // futur / désactivé
  ],
}) %>
// Mode markup libre — corps HTML personnalisé par item
<%- include('ui/timeline', {
  body:
    include('ui/timeline-item', {
      date: '14 mai', title: 'Signé', nodeIcon: 'file-check', status: 'success',
      body: 'Contrat <strong>N° 042</strong> signé.',
    }) +
    include('ui/timeline-item', { date: 'À venir', title: 'Onboarding', status: 'muted' })
}) %>

Progress Bar

Barre de progression configurable. Supporte les couleurs du design system, plusieurs tailles, les positions de texte externes ou internes, les rayures et un mode indéterminé.

Couleurs

Primary 70%
Secondary 50%
Success 55%
Warning 40%
Danger 85%
Info 60%
Muted 35%

Tailles

Small 60%
Medium 60%
Large 60%

Position du texte — extérieur

top-left 65%
top-center 65%
top-right 65%
bottom-left 65%
bottom-center 65%
bottom-right 65%

Position du texte — intérieur (labels courts)

Envoi fichier · 70%
Traitement · 70%
Synchronisation · 70%

Rayures

Striped 60%
Striped + animated 75%

Mode indéterminé

Chargement...
Synchronisation en cours
Inclusion EJS
<%- include('ui/progress', { value: 65, color: 'primary', label: 'Envoi', showValue: true, textPosition: 'top-left' }) %>
<%- include('ui/progress', { value: 40, color: 'warning', label: 'Validation', showValue: true, textPosition: 'inside-center', size: 'lg' }) %>
<%- include('ui/progress', { indeterminate: true, color: 'info', label: 'Chargement...', textPosition: 'top-left' }) %>
<%- include('ui/progress', { value: 80, color: 'success', striped: true, animated: true, size: 'lg', textPosition: 'top-left' }) %>

Editor

Éditeur de texte riche basé sur Quill v2. Charge Quill en lazy (CSS + JS) au premier rendu. La valeur est synchronisée dans un <input type="hidden"> pour la soumission de formulaire.

Toolbar complète (défaut)

Toolbar minimale

Mise en forme de base uniquement.

Valeur initiale + état d'erreur

Désactivé (lecture seule)

// Usage de base
<%- include('ui/editor', {
  id: 'my-editor',
  name: 'body',                             // nom du champ hidden soumis avec le form
  label: 'Contenu',
  placeholder: 'Rédigez…',
  toolbar: 'full',                            // 'full' | 'minimal'
  height: 200,                             // hauteur du corps en px
  value: '<p>Valeur initiale</p>',      // HTML initial (optionnel)
  required: true,
  helpText: 'Texte d'aide',
  errorText: 'Champ requis.',                  // active la bordure rouge
  disabled: false,                            // lecture seule, pas de toolbar
}) %>
// Accès à l'instance Quill depuis le JS client
const wrapper = document.querySelector('[data-editor-id="my-editor"]');
const quill = wrapper.__quill;
quill.getContents();          // Delta Quill
quill.root.innerHTML;        // HTML brut