Componentes

sitelo-ui es una biblioteca de componentes para sitelo. Cada componente es una función que devuelve una cadena de HTML, así que encaja directamente en un árbol de javascript-to-html sin nada en medio: sin compilador, sin runtime, sin hidratación. Lo que construyes es lo que acaba en dist/.

Viene con sitelo, en el punto de entrada sitelo/ui.

Primeros pasos

npm install sitelo javascript-to-html

Pon styles() en el head y llama a los componentes en el body. Esa es toda la configuración:

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

export default () => html({ lang: 'es' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('Mi sitio'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, 'Hola'),
        text({ variant: 'lead' }, 'Una página construida con componentes.'),
        button({ href: '/docs' }, 'Leer la documentación'),
      ),
    ),
  ),
)

Los nombres de los componentes coinciden a propósito con lo que renderizan, lo que hace que unos cuantos — button, input, table, link, code, select, progress — choquen con las funciones de elemento de javascript-to-html. Importa la biblioteca como espacio de nombres cuando necesites ambas:

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: 'Rutas', subtitle: 'Basado en archivos' }),
  ui.cardBody(ui.text('src/about.ht.js pasa a ser /about.')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, 'Leer más')),
)

La convención de llamada

Cada componente recibe un objeto de props opcional seguido de sus hijos, exactamente como un elemento de javascript-to-html. Las props que el componente entiende se consumen por nombre; todo lo demás pasa al elemento renderizado como atributo, así que id, data-*, aria-* y los atributos de evento funcionan sin que la biblioteca tenga que enumerarlos:

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

Una prop cuyo valor el componente no reconoce — variant: 'nonsense' — vuelve al valor por defecto en lugar de lanzar un error. Una errata cosmética no debería romper una compilación.

Estilos

styles() devuelve un <link> a un solo archivo, que el navegador cachea entre páginas; el plugin de sitelo lo sirve en dev y lo escribe en la build, con un nombre que lleva un hash del contenido y puedes servir como immutable. Con { inline: true } devuelve en cambio un <style> con la hoja completa: unos 11 kB por la red, ni una petición extra y nada que pueda faltar en dist/:

import { styles } from 'sitelo/ui'

head(
  // la hoja entera en la página, sin ninguna petición
  styles({ inline: true }),
)

La hoja cubre todos los componentes de la biblioteca, y un sitio usa un puñado. pruneCss: true en sitelo.config.js escribe solo las reglas que la build puede llegar a aplicar: el plugin lee las clases su- de las páginas que acaba de escribir — y de los scripts junto a ellas, por las clases que un toast o un paso añaden después — y descarta toda regla cuyo selector nombre una que nada lleva. Se decide a partir de la salida, no de los imports, así que button({ variant: 'soft' }) conserva .su-btn--soft y un button() a secas no. Una hoja enlazada se recorta contra todo el sitio y recibe un hash nuevo, con cada <link> reescrito para coincidir; una inline se recorta a su propia página. Una docena de componentes se queda en 2–3 kB gzip. En dev se sirve la hoja completa; el marcado que la build nunca ve, como el de una isla de servidor, necesita sus clases en keep:

export default {
  pruneCss: true,
  // o nombra clases que la build nunca ve; `*` para un prefijo
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

Temas

Todo se controla con propiedades personalizadas de CSS en :root: cinco paletas, una escala de espaciado, radios, tipografía y sombras. theme() escribe sus sobrescrituras y acepta nombres en camelCase (radiusMd → --su-radius-md), objetos de paleta o propiedades personalizadas literales:

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

head(
  styles(),
  // Después de styles(), para que estos ganen.
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

El modo oscuro se resuelve solo a partir de prefers-color-scheme. Poner data-theme o data-su-theme en light o dark en cualquier ancestro lo anula, que es justo lo que hace themeToggle():

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

head(
  themeScript(), // aplica la elección guardada antes del primer pintado
  styles(),
)

// …en cualquier parte del body
themeToggle()

O cambia todo el aspecto de una vez: styles({ preset: 'neumorphism' }) enlaza la hoja de un preset justo después de la principal, cada componente la sigue y theme() sigue funcionando por encima. Puedes verlo en directo en la página Neumorfismo.

JavaScript, y lo poco que hay

La mayoría de los componentes no necesita ninguno. El modal y el panel lateral son elementos <dialog> que un botón abre con un command, así que el navegador se encarga del fondo, del foco, de Escape y del clic fuera. El acordeón es <details name>. Los menús son <details>. Los tooltips son CSS. Las pestañas cuyos paneles cambian en el sitio son un grupo de radios: cada pestaña es una <label>, y el panel que sigue al radio marcado es el que muestra el CSS.

Tres cosas sí quieren un script, y cada una va a buscarlo por su cuenta:

No hay nada que añadir a tu archivo de entrada: el import es el atributo del evento:

<!-- 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 sirve esos módulos desde /su/ (o /repo/su/ con --base /repo/) mientras desarrollas y copia al build solo los que tus páginas referencian de verdad. Cada uno pesa bastante menos de un kilobyte, ninguno se descarga antes de la primera interacción y hasta entonces todos los componentes se renderizan bien: los menús se abren y se cierran solos y el botón de descartar no hace nada.

La excepción es toast(), porque nada en la página lo dispara por ti:

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

Ejemplos

Los formularios conectan solos sus etiquetas, ids, textos de ayuda y mensajes de error:

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

card(
  cardBody(
    stack({ gap: 'md' },
      textField({ label: 'Correo', name: 'email', type: 'email', help: 'Nunca se comparte.' }),
      textField({ label: 'Sitio', name: 'site', startAdornment: 'https://', error: 'No es una URL.' }),
      selectField({ label: 'Plan', name: 'plan', options: ['Gratis', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, 'Guardar')),
)

Un modal es un <dialog> y su disparador es cualquier botón cuyo commandfor apunte a su id:

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

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

modal({
  id: 'confirm',
  title: '¿Eliminar esta página?',
  footer: button({ color: 'danger' }, 'Eliminar'),
}, 'Esto no se puede deshacer.')

Las pestañas vienen en dos formas: enlaces o paneles.

// Pestañas de enlace: una página por pestaña, sin ningún script.
tabs({ items: [
  { label: 'Docs', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// Pestañas con panel: cambian en el sitio, con un grupo de radios y CSS.
tabs({ value: 'use', items: [
  { id: 'install', label: 'Instalar', panel: code('npm install sitelo') },
  { id: 'use', label: 'Usar', panel: code("import * as ui from 'sitelo/ui'") },
] })

Las tablas reciben columns y rows, con una función render allí donde una celda necesita más que un valor:

table({
  striped: true,
  columns: [
    { key: 'page', header: 'Página' },
    { key: 'size', header: 'Tamaño', align: 'end' },
    { header: 'Estado', render: (row) => chip({ color: row.ok ? 'success' : 'danger' }, row.ok ? 'ok' : 'falló') },
  ],
  rows: pages,
})

El directorio examples/ui del repositorio renderiza todos los componentes en una sola página: es la forma más rápida de ver el conjunto completo.

Referencia de componentes

Todas las exportaciones, por grupo. Las props están tipadas: sitelo/ui incluye archivos .d.ts, así que el editor completa variant, color y size tanto en JavaScript como en TypeScript.

GrupoComponentes
Maquetacióncontainer, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
Tipografíatext, heading, link, code, inlineCode, kbd, visuallyHidden, prose
Entradasbutton, iconButton, buttonGroup, field, input, textarea, select, textField, textareaField, selectField, checkbox, radio, toggle, choiceGroup, slider, sliderField, toggleButton, toggleGroup
Visualización de datosavatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
Feedbackalert, progress, skeleton, toasts, empty
Navegaciónbreadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
Superposicionesmodal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
Seccioneshero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
Estilosstyles, stylesheet, stylesUrl, theme, themeScript

Dos nombres se apartan de lo esperable: el interruptor es toggle, porque switch es una palabra reservada y no puede ser un enlace de importación; y el enlace con estilo se exporta como link y como textLink, para poder convivir con el link de javascript-to-html. table, input, select y progress tienen la misma salida de emergencia: dataTable, textInput, selectField, progressBar.