Componentes

O sitelo-ui é uma biblioteca de componentes para o sitelo. Cada componente é uma função que devolve uma string de HTML, por isso encaixa diretamente numa árvore javascript-to-html sem nada pelo meio — sem compilador, sem runtime, sem hidratação. O que constróis é o que fica em dist/.

Vem com o sitelo, no ponto de entrada sitelo/ui.

Primeiros passos

npm install sitelo javascript-to-html

Põe styles() no head e chama os componentes no body. A configuração é toda esta:

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

export default () => html({ lang: 'pt' },
  head(
    meta({ charset: 'utf-8' }),
    meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }),
    title('O meu site'),
    styles(),
  ),
  body(
    container({ size: 'md' },
      stack({ gap: 'md' },
        heading({ level: 1 }, 'Olá'),
        text({ variant: 'lead' }, 'Uma página construída a partir de componentes.'),
        button({ href: '/docs' }, 'Ler a documentação'),
      ),
    ),
  ),
)

Os nomes dos componentes correspondem de propósito àquilo que renderizam, o que faz com que alguns deles — button, input, table, link, code, select, progress — colidam com as funções de elemento do javascript-to-html. Importa a biblioteca como espaço de nomes quando precisares das duas:

import * as ui from 'sitelo/ui'

ui.card(
  ui.cardHeader({ title: 'Rotas', subtitle: 'Baseado em ficheiros' }),
  ui.cardBody(ui.text('src/about.ht.js passa a ser /about.')),
  ui.cardFooter({ divided: true }, ui.button({ size: 'sm' }, 'Saber mais')),
)

A convenção de chamada

Cada componente recebe um objeto de props opcional seguido dos filhos, tal como um elemento do javascript-to-html. As props que o componente conhece são consumidas pelo nome; tudo o resto passa para o elemento renderizado como atributo, por isso id, data-*, aria-* e os atributos de evento funcionam sem que a biblioteca tenha de os enumerar:

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

Uma prop cujo valor o componente não reconhece — variant: 'nonsense' — recai no valor por omissão em vez de lançar um erro. Uma gralha cosmética não deve fazer falhar uma compilação.

Estilos

styles() devolve um elemento <style> com a folha completa, minificada. São cerca de 7 kB na rede e não pode desaparecer de dist/ — é por isso que é a opção por omissão. Se preferires ligá-la uma só vez e deixar o browser guardá-la em cache entre páginas, importa o CSS a partir de um ficheiro de entrada empacotado e o Vite emite-o:

// src/main.js — empacotado pelo Vite, em cache entre páginas
import 'sitelo/ui/styles.css'

Usa uma ou outra, não as duas.

Temas

Tudo é controlado por propriedades personalizadas de CSS em :root: cinco paletas, uma escala de espaçamento, raios, tipografia e sombras. theme() escreve as substituições e aceita nomes em camelCase (radiusMd--su-radius-md), objetos de paleta ou propriedades personalizadas literais:

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

head(
  styles(),
  // Depois de styles(), para que estes prevaleçam.
  theme({
    primary: { base: '#5b5bd6', hover: '#4a4ac4', fg: '#ffffff' },
    radiusMd: '2px',
    fontSans: '"Inter", system-ui, sans-serif',
  }, {
    dark: { primary: { base: '#8f8ff0' } },
  }),
)

O modo escuro resolve-se sozinho a partir de prefers-color-scheme. Definir data-theme ou data-su-theme como light ou dark em qualquer ascendente sobrepõe-se a isso — e é exatamente o que themeToggle() faz:

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

head(
  themeScript(), // aplica a escolha guardada antes da primeira pintura
  styles(),
)

// …em qualquer sítio do body
themeToggle()

JavaScript, e o pouco que é preciso

A maioria dos componentes não precisa de nenhum. O modal e a gaveta são elementos popover, por isso é o browser que trata da abertura, do fundo, do clique fora e do Escape. O acordeão é um <details name>. Os menus são <details>. As dicas são CSS.

Quatro coisas querem mesmo um script, e cada uma vai buscá-lo sozinha:

Não há nada a acrescentar ao teu ficheiro de entrada — o import é o atributo do evento:

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

O sitelo serve esses módulos a partir de /su/ enquanto desenvolves e copia para o build apenas aqueles que as tuas páginas referenciam de facto. Cada um tem bem menos de um kilobyte, nenhum é descarregado antes da primeira interação e, até lá, todos os componentes renderizam corretamente: os separadores com painel mostram aquele que o servidor marcou como ativo, os menus abrem e fecham sozinhos e o botão de dispensar não faz nada.

A exceção é toast(), porque nada na página o dispara por ti:

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

Exemplos

Os formulários ligam sozinhos as suas etiquetas, ids, textos de ajuda e mensagens de erro:

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: 'Nunca é partilhado.' }),
      textField({ label: 'Site', name: 'site', startAdornment: 'https://', error: 'Não é um URL.' }),
      selectField({ label: 'Plano', name: 'plan', options: ['Grátis', 'Pro'], value: 'Pro' }),
    ),
  ),
  cardFooter({ divided: true }, button({ type: 'submit' }, 'Guardar')),
)

Um modal é um popover e o seu gatilho é qualquer botão que aponte para o seu id:

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

button({ popovertarget: 'confirm' }, 'Eliminar…')

modal({
  id: 'confirm',
  title: 'Eliminar esta página?',
  footer: button({ color: 'danger' }, 'Eliminar'),
}, 'Isto não pode ser desfeito.')

Os separadores existem em duas formas — ligações ou painéis:

// Separadores de ligação: uma página por separador, sem qualquer script.
tabs({ items: [
  { label: 'Docs', href: '/docs', active: true },
  { label: 'API', href: '/api' },
] })

// Separadores com painel: trocam no lugar e vão buscar sozinhos o código.
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'") },
] })

As tabelas recebem columns e rows, com uma função render onde uma célula precisar de mais do que um valor:

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

A pasta examples/ui do repositório renderiza todos os componentes numa única página — é a forma mais rápida de ver o conjunto completo.

Referência de componentes

Todas as exportações, por grupo. As props têm tipos: o sitelo/ui traz ficheiros .d.ts, por isso o editor completa variant, color e size tanto em JavaScript como em TypeScript.

GrupoComponentes
Disposiçãocontainer, stack, grid, divider, card, cardHeader, cardTitle, cardSubtitle, cardMedia, cardBody, cardFooter, aspectRatio
Tipografiatext, 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
Apresentação de dadosavatar, avatarGroup, badge, chip, tooltip, table, list, listItem, figure
Feedbackalert, progress, spinner, skeleton, toasts, empty
Navegaçãobreadcrumbs, pagination, tabs, appBar, appBarNav, appBarSpacer, appBarActions, navLink, themeToggle
Sobreposiçõesmodal, drawer, closeButton, menu, menuItem, menuSeparator, accordion, accordionItem, collapsible
Secçõeshero, footer, siteFooter, footerColumn, footerBottom, stat, statGroup, steps, timeline, timelineItem, mockup
Estilosstyles, stylesheet, theme, themeScript

Dois nomes fogem ao esperado: o interruptor é toggle, porque switch é uma palavra reservada e não pode ser uma ligação de importação; e a ligação com estilo é exportada como link e como textLink, para poder conviver com o link do javascript-to-html. table, input, select e progress têm a mesma saída de emergência: dataTable, textInput, selectField, progressBar.