Composants

sitelo-ui est une bibliothèque de composants pour sitelo. Chaque composant est une fonction qui renvoie une chaîne de HTML : il s’imbrique donc directement dans un arbre javascript-to-html, sans rien entre les deux — pas de compilateur, pas de runtime, pas d’hydratation. Ce que vous écrivez est exactement ce qui atterrit dans dist/.

Elle est livrée avec sitelo, sous le point d’entrée sitelo/ui.

Démarrage

npm install sitelo javascript-to-html

Placez styles() dans le head et appelez les composants dans le body. C’est toute la configuration :

import { body, head, html, meta, title } from 'javascript-to-html'
import { styles, container, stack, heading, text, button } from 'sitelo/ui'

export default () => html({ lang: 'fr' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('Mon site'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, 'Bonjour'),
        text({ variant: 'lead' }, 'Une page construite à partir de composants.'),
        button({ href: '/docs' }, 'Lire la documentation'),
      ),
    ),
  ),
)

Les noms des composants correspondent volontairement à ce qu’ils rendent, ce qui fait que quelques-uns — button, input, table, link, code, select, progress — entrent en collision avec les fonctions d’élément de javascript-to-html. Importez la bibliothèque comme espace de noms quand vous avez besoin des deux :

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: 'Routage', subtitle: 'Basé sur les fichiers' }),
  ui.cardBody(ui.text('src/about.ht.js devient /about.')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, 'En savoir plus')),
)

La convention d’appel

Chaque composant prend un objet de props optionnel suivi de ses enfants, exactement comme un élément javascript-to-html. Les props que le composant comprend sont consommées par leur nom ; tout le reste est transmis à l’élément rendu sous forme d’attribut, si bien que id, data-*, aria-* et les attributs d’événement fonctionnent sans que la bibliothèque ait à les énumérer :

button({ id: 'save', 'data-analytics': 'save-click', onclick: 'save()' }, 'Enregistrer')
// <button type="button" id="save" data-analytics="save-click" onclick="save()" class="su-btn …">

Une prop dont le composant ne reconnaît pas la valeur — variant: 'nonsense' — retombe sur la valeur par défaut au lieu de lever une erreur. Une faute de frappe cosmétique ne devrait pas faire échouer un build.

Styles

styles() renvoie un <link> vers un seul fichier, que le navigateur met en cache d’une page à l’autre ; le plugin de sitelo le sert en dev et l’écrit dans le build, sous un nom porteur d’une empreinte du contenu que vous pouvez servir en immutable. Avec { inline: true }, il renvoie plutôt un <style> contenant toute la feuille : environ 11 ko sur le réseau, aucune requête supplémentaire, et rien qui puisse disparaître de dist/ :

import { styles } from 'sitelo/ui'

head(
  // la feuille entière dans la page, sans aucune requête
  styles({ inline: true }),
)

La feuille couvre chaque composant de la bibliothèque, et un site en utilise une poignée. pruneCss: true dans sitelo.config.js n’écrit que les règles que le build peut effectivement appliquer : le plugin lit les classes su- des pages qu’il vient d’écrire — et des scripts à côté, pour les classes qu’un toast ou une étape ajoutent plus tard — et supprime toute règle dont le sélecteur nomme une classe que rien ne porte. La décision se prend sur la sortie, pas sur les imports : button({ variant: 'soft' }) garde .su-btn--soft, un simple button() non. Une feuille liée est réduite à l’échelle du site et reçoit un nouveau hash, chaque <link> étant réécrit en conséquence ; une feuille inline est réduite à sa propre page. Une douzaine de composants tiennent en 2–3 ko gzip. Le serveur de dev sert toujours la feuille entière ; le balisage que le build ne voit jamais — celui d’un îlot serveur, par exemple — doit nommer ses classes dans keep :

export default {
  pruneCss: true,
  // ou nommer des classes que le build ne voit jamais ; `*` pour un préfixe
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

Thèmes

Tout repose sur des propriétés personnalisées CSS déclarées sur :root : cinq palettes, une échelle d’espacement, des rayons, la typographie et les ombres. theme() écrit leurs surcharges et accepte des noms en camelCase (radiusMd → --su-radius-md), des objets de palette ou des propriétés personnalisées littérales :

import { styles, theme } from 'sitelo/ui'

head(
  styles(),
  // Après styles(), pour que ces valeurs l’emportent.
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

Le mode sombre se résout tout seul à partir de prefers-color-scheme. Donner à data-theme ou data-su-theme la valeur light ou dark sur n’importe quel ancêtre l’emporte — c’est exactement ce que fait themeToggle() :

import { styles, themeScript, themeToggle } from 'sitelo/ui'

head(
  themeScript(), // applique le choix enregistré avant le premier rendu
  styles(),
)

// …n’importe où dans le body
themeToggle()

Ou changez tout le rendu d’un coup : styles({ preset: 'neumorphism' }) lie la feuille d’un préréglage juste après la feuille principale, chaque composant la suit, et theme() fonctionne toujours par-dessus. Vous le verrez en direct sur la page Neumorphisme.

JavaScript, et le peu qu’il en faut

La plupart des composants n’en ont pas besoin. La modale et le tiroir sont des éléments <dialog> qu’un bouton ouvre avec un command : le navigateur gère l’arrière-plan, le focus, Échap et le clic à l’extérieur. L’accordéon est un <details name>. Les menus sont des <details>. Les infobulles sont en CSS. Les onglets dont les panneaux changent sur place sont un groupe de boutons radio : chaque onglet est un <label>, et le panneau qui suit le radio coché est celui que le CSS affiche.

Trois choses réclament vraiment un script, et chacune va le chercher elle-même :

Rien à ajouter à votre fichier d’entrée : l’import est l’attribut d’événement :

<!-- rendered by alert({ dismissible: true }) -->
<button class="su-alert-dismiss"
        onclick="import('/su/alert.js').then(m=>m.dismiss(this))">
  <svg class="su-icon" …></svg>
</button>

sitelo sert ces modules depuis /su/ (ou /repo/su/ avec --base /repo/) pendant le développement et copie dans la build uniquement ceux que vos pages référencent réellement. Chacun pèse bien moins d’un kilo-octet, aucun n’est chargé avant la première interaction, et jusque-là chaque composant s’affiche correctement : les menus s’ouvrent et se ferment seuls, et le bouton de fermeture ne fait rien.

L’exception est toast(), car rien sur la page ne le déclenche pour vous :

// src/main.js
import { toast } from 'sitelo/ui/client'

Exemples

Les formulaires relient eux-mêmes leurs libellés, leurs ids, leurs textes d’aide et leurs messages d’erreur :

import { card, cardBody, cardFooter, button, stack, textField, selectField } from 'sitelo/ui'

card(
  cardBody(
    stack({ gap: 'md' },
      textField({ label: 'E-mail', name: 'email', type: 'email', help: 'Jamais partagé.' }),
      textField({ label: 'Site', name: 'site', startAdornment: 'https://', error: 'Ce n’est pas une URL.' }),
      selectField({ label: 'Formule', name: 'plan', options: ['Gratuit', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, 'Enregistrer')),
)

Une modale est un <dialog>, et son déclencheur est n’importe quel bouton dont le commandfor pointe vers son id :

import { button, modal } from 'sitelo/ui'

button({ commandfor: 'confirm', command: 'show-modal' }, 'Supprimer…')

modal({
  id: 'confirm',
  title: 'Supprimer cette page ?',
  footer: button({ color: 'danger' }, 'Supprimer'),
}, 'Cette action est irréversible.')

Les onglets existent sous deux formes : des liens, ou des panneaux.

// Onglets liens : une page par onglet, aucun script.
tabs({ items: [
  { label: 'Docs', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// Onglets à panneaux : échange sur place, avec un groupe de radios et du CSS.
tabs({ value: 'use', items: [
  { id: 'install', label: 'Installer', panel: code('npm install sitelo') },
  { id: 'use', label: 'Utiliser', panel: code("import * as ui from 'sitelo/ui'") },
] })

Les tableaux prennent columns et rows, avec une fonction render partout où une cellule demande plus qu’une valeur :

table({
  striped: true,
  columns: [
    { key: 'page', header: 'Page' },
    { key: 'size', header: 'Taille', align: 'end' },
    { header: 'Statut', render: (row) => chip({ color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : 'échec') },
  ],
  rows: pages,
})

Le dossier examples/ui du dépôt affiche tous les composants sur une seule page — c’est le moyen le plus rapide de voir l’ensemble.

Référence des composants

Tous les exports, par groupe. Les props sont typées : sitelo/ui livre des fichiers .d.ts, de sorte qu’un éditeur complète variant, color et size aussi bien en JavaScript qu’en TypeScript.

GroupeComposants
Mise en pagecontainer, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
Typographietext, heading, link, code, inlineCode, kbd, visuallyHidden, prose
Champsbutton, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup
Affichage de donnéesavatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
Retour d’informationalert, progress, skeleton, toasts, empty
Navigationbreadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
Superpositionsmodal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
Sectionshero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
Stylesstyles, stylesheet, stylesUrl, theme, themeScript

Deux noms s’écartent de ce à quoi on s’attend : l’interrupteur s’appelle toggle, parce que switch est un mot réservé et ne peut pas servir de liaison d’import ; et le lien stylé est exporté à la fois comme link et comme textLink, pour cohabiter avec le link de javascript-to-html. table, input, select et progress disposent de la même échappatoire : dataTable, textInput, selectField, progressBar.