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

O styles() devolve um <link> para um único ficheiro, que o browser guarda em cache entre páginas; o plugin do sitelo serve-o em dev e escreve-o na build, com um nome que leva um hash do conteúdo e podes servir como immutable. Com { inline: true } devolve antes um <style> com a folha completa: cerca de 11 kB na rede, sem pedido extra e sem nada que possa desaparecer de dist/:

import { styles } from 'sitelo/ui'

head(
  // a folha inteira na página, sem pedido nenhum
  styles({ inline: true }),
)

A folha cobre todos os componentes da biblioteca, e um site usa uma mão-cheia. pruneCss: true no sitelo.config.js escreve apenas as regras que a build pode de facto aplicar: o plugin lê as classes su- das páginas que acabou de escrever — e dos scripts ao lado, pelas classes que um toast ou um passo acrescentam depois — e descarta toda a regra cujo seletor nomeie uma que nada usa. Decide-se pelo resultado, não pelos imports, por isso button({ variant: 'soft' }) mantém .su-btn--soft e um button() simples não. Uma folha ligada é aparada contra o site inteiro e recebe um hash novo, com cada <link> reescrito a condizer; uma inline é aparada à sua própria página. Uma dúzia de componentes fica em 2–3 kB gzip. Em dev serve-se a folha completa; a marcação que a build nunca vê, como a de uma ilha de servidor, precisa das suas classes em keep:

export default {
  pruneCss: true,
  // ou nomeia classes que a build nunca vê; `*` para um prefixo
  // pruneCss: { keep: ['su-card', 'su-btn*'] },
}

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()

Ou muda o aspeto todo de uma vez: styles({ preset: 'neumorphism' }) liga a folha de um preset logo a seguir à principal, todos os componentes a seguem, e o theme() continua a funcionar por cima. Podes vê-lo ao vivo na página Neumorfismo.

JavaScript, e o pouco que é preciso

A maioria dos componentes não precisa de nenhum. O modal e a gaveta são elementos <dialog> que um botão abre com um command, por isso é o browser que trata do fundo, do foco, do Escape e do clique fora. O acordeão é um <details name>. Os menus são <details>. As dicas são CSS. Os separadores cujos painéis trocam no lugar são um grupo de rádios: cada separador é uma <label>, e o painel que se segue ao rádio marcado é o que o CSS mostra.

Três 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))">
  <svg class="su-icon" …></svg>
</button>

O sitelo serve esses módulos a partir de /su/ (ou /repo/su/ com --base /repo/) 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 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 <dialog> e o seu gatilho é qualquer botão cujo commandfor aponte para o seu 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'),
}, '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, com um grupo de rádios e 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'") },
] })

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, 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, stylesUrl, 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.