Ir para o conteúdo
Trilha
Capítulos

Referência

ui

Componentes do kit, variantes, assets e o contrato de tema.

import "github.com/emersonjoe/trilha/ui" — só stdlib. Os componentes devolvem h.Node com classes ui-* de public/ui.css; comportamentos em public/ui.js.

Assets#

SímboloPapel
ui.Head(c) h.Node<link> para ui.theme.css e ui.css, script inline (com nonce) que aplica o tema salvo, <script defer src=ui.js>; respeita c.Base()
ui.Body() h.Nodeclasse ui-body para o <body>
ui.Asset(nome) []byteconteúdo embutido de ui.css, ui.theme.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js ou ui.island.js
ui.Filesos seis nomes, na ordem em que trilha ui os grava

Variantes e tamanhos#

ui.Secondary(), ui.Outline(), ui.Ghost(), ui.Destructive(), ui.LinkStyle(), ui.Sm(), ui.Lg(), ui.IconSize(). São atributos de classe: valem em Button, Submit, ButtonLink, Badge e Alert (cada um traduz para a sua classe, ex. ui-btn-outline, ui-badge-outline).

Componentes#

FunçãoRenderiza
Container, Stack, Row, Grid, Spacerlayout: largura máxima, coluna, linha, grade responsiva — veja os blocos do layout
Cols(n...)quantas colunas um Grid tem, de 1 a 6: Cols(2, 4) é duas no celular e quatro a partir de 1024px; sem ele a grade cabe quantas colunas de 16rem a largura permitir
Header(children...), Brand(href, nome), Nav(...), NavLink(href, rótulo, atual), Sidebar(...)barra fixa no topo, marca, navegação (com aria-current), coluna lateral
H1, H2, H3, Lead, Muted, Code(s), Kbd(s)tipografia — veja uma tecla e um trecho
Button, Submit, ButtonLink(href, ...)<button type=button>, <button type=submit>, <a> com cara de botão
Card, CardHeader, CardTitle(s), CardDescription(s), CardContent, CardFootercartão
Input, Textarea, Select, Checkbox, Radio, Switch, Labelcontroles (Switch tem role=switch)
Field(id, rótulo, controle, opts ...FieldOpt)rótulo + controle + Help(s) + Error(s); With(nós...) põe atributos no grupo. As opções são valores ui.FieldOpt, então um formulário que monta os campos em laço as passa como dado
FormError(...)resumo de erro acessível para formulários assíncronos; o runtime também o cria quando foi omitido
CheckRow(controle, rótulo, id)checkbox/switch ao lado do rótulo
Invalid()aria-invalid="true" (anel vermelho)
Errors(errs, campo)opção de Field: mostra a mensagem de errs[campo] (um trilha.FieldErrors) se houver — ou a do menor campo[N], o nome que c.Files dá ao arquivo recusado
InvalidIf(errs, campo)Invalid() só quando há erro para o campo, ou para um dos seus campo[N]
SelectOptions([]Option{{Value, Label}}, selecionado)<option>s marcando o selecionado; Value: "" é placeholder (desabilitado) e fica selecionado quando nada casa
Checked(bool)checked condicional (ida e volta de checkbox/switch/radio)
ShowWhen(campo, valores...)data-ui-show-when: mostra o elemento só com o valor (ou qualquer valor não vazio); controles escondidos são desabilitados
Combobox(ComboboxOpts{...}, attrs...), ComboboxOptions(itens, de)campo de texto que busca numa lista — veja Combobox e demo
Dropzone(DropzoneOpts{...}, filhos...)área de arrastar-e-soltar em cima de um campo de arquivo — veja Upload com progresso
SchemaForm(esquema, values, errs, ...)formulário definido por dado: um campo por trilha.SchemaField — veja Validação
Badge, Alert(título, ...), AlertDescription(...)selo e aviso (role=alert)
Toaster(...), Toast(tipo, texto, fadeMs)pilha de avisos; tipo = "", success, error; fadeMs > 0 some sozinho
Flashes(c)o toaster com os avisos do c.Flash — ponha no layout; FlashInfo, FlashSuccess e FlashError são os tipos, e o FlashFadeMs é quanto tempo um deles fica antes de sumir — veja demo
Table(...), Cards(), Num(), Depth(n)tabela rolável; linha vira cartão abaixo de 640px — veja Listagens; célula numérica; indentação de linha (árvore)
Tabs(id, Tab{Label, Content}...), TabsWithOptions(id, TabsOpts{Selected}, ...)abas acessíveis; Tab.Href dá endereço real a cada visão e Selected escolhe o painel renderizado no servidor — veja demo
Dialog(id, título, ...), DialogDescription(s), DialogFooter(...), DialogTrigger(id, ...), DialogClose(...)<dialog> nativo com showModal; o DialogTrigger com h.Href é um link, e com Swap no corpo do diálogo carrega o corpo sob demanda
Confirm(título, descrição)atributos para um <form>: o ui.js pergunta num diálogo antes de enviar, inclusive em formulário de fragmento. O botão que confirma repete o rótulo do botão apertado; o outro diz Cancel, ou o que estiver em h.Data("ui-confirm-cancel", "…"). Sem JavaScript o formulário envia direto — veja demo
Menu(id, ...), MenuItem(...), MenuLink(href, ...), MenuTrigger(id, ...)menu com o atributo popover nativo — veja demo
Pagination(Pages{Page, Total, Href, Prev, Next, Label, Attrs})navegação de páginas em links; a página atual é um <span> com aria-current, as pontas somem em vez de virarem link desabilitado, e uma janela de sete casas guarda a primeira e a última página com … sobre cada buraco; uma página só não desenha nada
Tooltip(texto, ...)dica no que ele embrulha: title mais data-ui-tooltip, promovido pelo ui.js a uma bolha com role=tooltip e aria-describedby
Separatortraço entre seções — veja os blocos do layout
Skeleton, Progress(valor, máx)espaços reservados de carregamento — veja demo
Breadcrumb(Crumb{Label, Href}...), Avatar(iniciais, src)trilha de navegação e a foto (ou as iniciais) de quem está logado — veja demo
Collapsible(resumo, ...)um <details> com estilo — veja demo
ThemeToggle()botão que alterna claro/escuro (localStorage["ui-theme"])
CSVErrors(c, res, CSVErrorsOpts{...})o que o trilha.BindCSV recusou, por linha e coluna — veja Planilhas (CSV) e demo
DataTable(c, Columns[T], linhas, ListState)a listagem: formulário de filtro, cabeçalho ordenável, paginação e estado vazio, tudo na URL — veja Listagens e demo
Swap(id)data-trilha-target: o <a> ou <form> pede só o elemento #id e troca (fragmentos)
SecretOnce(c, segredo), APIKeysTable(c, linhas, opts)a chave mostrada uma vez, e a lista delas — veja Auth e demo
SettingsForm(c, seção, errs)a tela de administração de uma seção do trilha.Settings, desenhada da struct — veja App e demo
Tree(TreeOpts{...}), TreePicker(TreePickerOpts{...}), TreeItems, TreeScript(c)a hierarquia que abre nó a nó, e o campo que escolhe um — veja Árvores e demo
AuditTable(c, registros, AuditOpts{...})a trilha que o c.Audit escreve, com filtro, paginação e exportação CSV — veja Observabilidade e demo
Steps([]Step{Label, Href}, atual)o indicador de um formulário em várias telas — veja Formulário em passos e demo
Sheet(c, id, SheetOpts{Side, Width, Title, Modal, Open, Push}, filhos...), SheetBody(id, filhos...), SheetOpen(id), SheetClose(filhos...), SheetScript(c)painel lateral cujo corpo (#<id>-body) carrega do endereço do link como fragmento e fica aberto enquanto uma região Navigate ao lado navega; <aside role=complementary>, ou <dialog> quando modal — veja Um painel ao lado da página
Preview(c, src, PreviewOpts{...})o arquivo ao lado do que se sabe dele: barra, quadro, imagem ou "não dá para pré-visualizar" — veja Ctx, Uploads e demo
Audio(c, src, AudioOpts{Preload AudioPreload, ...})player nativo e acessível com duração, ações de abrir/baixar e fallback para formato sem reprodução
InstallApp(c, InstallAppOpts{...})convite progressivo para instalar a PWA; use com trilha add pwa
Outbox(c), OfflineScript(c)o que espera para ser enviado — a contagem, o último erro e o botão "Enviar agora" — e o script que põe na fila um formulário marcado com trilha.OfflineForm quando não há rede; use com trilha add pwa-offline
Defer(c, id, src, DeferOpts{...})serve a página agora e preenche esta parte um instante depois — veja Fragmentos vivos e demo
Poll(intervalo, src), Live(src), On(evento, src), LiveScript(c)fragmento que se atualiza pelo relógio ou por um evento do servidor — veja Fragmentos vivos e demo
NoPush()data-trilha-push="false": a troca não mexe no histórico
PushHistory()data-trilha-push="push": um formulário GET com Swap cria uma entrada por busca em vez de substituir a sua
Follow(), NoFollow()um gatilho segue no lugar o redirect da rota (o padrão numa página com região Navigate), ou não — veja Depois do POST
Markdown(texto, MarkdownOpts{...})texto de modelo ou de visitante como HTML, escapado por construção — veja Markdown
Chat(c, ChatOpts{...}), ChatScript(c), ChatHTML(texto)uma conversa com um agente — veja Chat
Icon(nome, attrs...), Icons()SVG inline do Lucide; nome desconhecido → pânico (erro de programação). NavItem.IconNode/EmptyOpts.IconNode desenham o próprio nó do app para um ícone fora do conjunto — veja Shell
Patterns() []Patternos padrões de tela do kit como dado — nome, resumo, componentes, um page.go completo de no máximo 60 linhas que compila, o contrato de dados e as notas de acessibilidade; veja Padrões
APIUsage(c, dados, opts)quanto uma chave foi usada, onde, e quando parou — veja Auth e demo
SearchBox(c, action, SearchBoxOpts{...}), SearchResults(c, res, SearchResultsOpts{...})a caixa da barra de cima (Submit acrescenta um botão com esse rótulo) e o resultado agrupado de um trilha.Search — veja Search e demo
DeadlineCards(c, resumo), DeadlineList(c, itens, opts), DeadlineBadge(c, vencidos)o que vence e quando, a partir de um resumo do trilha.Deadlines — veja DeadlineCards
ConnectionsPanel(c, conns, opts), ConnectionStatus(c, teste), ParseConnectionForm(c)os serviços externos e seus segredos, com o botão Testar — veja ConnectionsPanel
Shell(c, ShellOpts{...}, children...), PageHeader(título, ações...)a moldura de um app com seções: navegação lateral, barra de cima e o título da tela com seus botões — Back{} acima dele, Subtitle("…") abaixo — veja Shell e demo
Stat(rótulo, valor, ...), StatHint(texto, ...), Sparkline(valores, SparkOpts{...}), SparklineTitle(valores, SparkOpts{...}, ...), Bars([]Datum, ...), Donut([]Datum, ...), ChartTitle(nome)um número no painel e o desenho ao lado, em SVG escrito pelo servidor; ChartTitle é o que faz o desenho ser uma imagem com nome em vez de enfeite — veja Gráficos e demo
Inbox(c, []InboxRow, InboxOpts{...}), InboxBadge(n)o que espera decisão de quem está lendo, e a contagem ao lado do item de menu (zero não desenha nada) — veja Approval e demo
PolicyGrid(policy, PolicyGridOpts{...})a grade papel × módulo de um auth.Policy, como formulário; uma ScopedPolicy (que é o que o auth.Policy é, via ScopeNameOf e ScopeNames) ganha também um select de escopo por célula — organização, unidade, unidade e abaixo — renomeado pelo PolicyGridOpts.ScopeLabels — veja Auth e demo
TaskTable(c, tarefas, TaskTableOpts{...}), TaskProgress(c, tarefas, id)o trabalho em segundo plano: a lista com seus estados e o progresso de uma execução — veja Tarefas e demo
WebhooksPanel(c, hooks, entregas, WebhooksOpts{...})as assinaturas, o segredo e o registro de entregas de um webhook.Hooks — veja Webhook e demo
Empty(EmptyOpts{...}), EmptyError(c, título, err, ação)a tela sem nada para mostrar, e a que não conseguiu carregar: a mensagem é o que a pessoa lê, e o err aparece só em desenvolvimento — veja demo
Status(enum, valor)um valor de um trilha.Enum como selo, com seu rótulo e seu tom; valor que o enum não conhece mais sai discreto em vez de derrubar a tela — veja demo

Árvores#

Hierarquia com milhares de nós é o componente que as pessoas vão buscar no npm: expandir, buscar e teclado são cada um fácil e juntos são trezentas linhas. O ui.Tree é a versão disso no servidor — o servidor já conhece a árvore, então o navegador nunca precisa conhecer.

ui.Tree(ui.TreeOpts{
	Nodes:   raizes,                  // com o caminho até o Current já dentro
	Source:  "/classificacao/nos",    // GET ?parent=100.1 responde os filhos
	Current: doc.Codigo,
	Label:   "Plano de classificação",
})
SímboloPapel
Tree(TreeOpts{...})a hierarquia; cada nó é um <details>, então abre sem script nenhum
TreeNode{Value, Label, Leaf, Href, Children, Open, Path, Unpickable}um nó; os Children viajam junto quando conhecidos e Unpickable mantém um ramo navegável sem desenhar radio
TreeItems(nos, TreeOpts{...}) / TreeNodes(itens, of)o que uma rota-fonte responde: os filhos de um nó, em HTML
TreePicker(TreePickerOpts{...})a mesma árvore como campo de formulário: um radio por nó
TreeScript(c)carrega o ui.tree.js; página sem árvore não baixa nada disso

Um nó é <details>, e é essa a história inteira do sem-JavaScript. O que o script acrescenta é buscar os filhos na primeira vez que um ramo abre, em vez de pedir uma página inteira ao servidor. Nó cujos Children já estão em Nodes não pede nada — é assim que o caminho até o nó atual chega aberto e completo no primeiro desenho, inclusive depois de um 422 trazer o formulário de volta.

TreeOpts.Pending e TreeOpts.Fail trocam as mensagens padrão da carga preguiçosa (Carregando… e Não foi possível carregar.); TreePickerOpts repassa os mesmos campos. A frase pendente vem do servidor, então um ramo preguiçoso nunca abre numa caixa muda sem JavaScript.

Os papéis são os de verdade (tree, treeitem, group, aria-expanded), as setas andam pelo que está visível, Home e End vão às pontas, e * expande tudo. Só o primeiro nó entra na ordem de tabulação: a árvore é uma parada só, e as setas andam dentro dela.

O seletor#

ui.Field("codigo", "Classificação", ui.TreePicker(ui.TreePickerOpts{
	Name:   "codigo",
	Value:  form.Codigo,
	Nodes:  plano.Raizes(form.Codigo),
	Source: "/classificacao/nos",
	Search: "/classificacao/busca", // GET ?q= responde nós achatados, cada um com o seu Path
}))

O que posta é um radio, e é essa a razão inteira de isto funcionar sem script: a pessoa navega pelos mesmos <details> e marca o mesmo radio, e o formulário manda o mesmo campo. Não há hidden para manter em sincronia nem texto para resolver no servidor.

Com o script, digitar pergunta ao Search e põe os achados no lugar da árvore, cada um com a ancestralidade de onde veio — um código achado fora de contexto não diz onde mora. Apagar a busca traz a árvore de volta da memória, sem pedir de novo.

Uma árvore de radios se anuncia como grupo de escolhas, não como navegação: é campo de formulário, e isso é um papel e não dois.

ui.js#

Tudo por atributo, sem inicialização: [data-ui-tabs], [data-ui-dialog-open=id], [data-ui-dialog-close], [data-ui-fade=ms], [data-ui-show-when], [data-ui-toast=texto] (data-ui-toast-kind), [data-ui-theme-toggle], [data-ui-tooltip=texto], [popover].ui-menu. Também expõe window.ui.toast(texto, {kind, ms}), ui.formError(form, mensagem, opções), ui.clearFormErrors(form), ui.formPending(form), ui.fade(el), ui.evalShowWhen(root) e ui.applyTheme("dark"|"light"). Elementos inseridos depois (HTMX, fetch) precisam de ui.evalShowWhen(el)/ui.fade(el)/ui.initTooltips(el) se usarem esses atributos — ui.hydrate(el) faz os três de uma vez.

Formulários assíncronos#

Guarde event.currentTarget antes do primeiro await: depois que o listener devolve o controle ao navegador, event.currentTarget vira null. ui.formPending marca o formulário com aria-busy, desabilita os botões de envio e devolve a função que restaura o estado original. ui.formError mantém a falha dentro do formulário — inclusive quando ele está num diálogo — e pode marcar e focar o campo responsável.

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const form = event.currentTarget;
  ui.clearFormErrors(form);
  const settled = ui.formPending(form);
  try {
    await save(new FormData(form));
  } catch (error) {
    ui.formError(form, error.message, {
      field: form.elements.email,
      action: { href: "/ajuda", label: "Como corrigir" },
    });
  } finally {
    settled();
  }
});

A mensagem entra por textContent, nunca por innerHTML. O link só aparece quando o chamador fornece explicitamente href e label. Coloque ui.FormError() antes dos campos para reservar o ponto do feedback; se ele não estiver presente, o runtime cria o elemento.

Fragmentos#

[data-trilha-target=id] em <a> ou <form> (veja ui.Swap) faz o kit pedir a mesma URL com o cabeçalho Trilha-Fragment e trocar o elemento #id pelo HTML que voltou. Detalhes: o alvo ganha aria-busy durante a espera; 204 com Trilha-Location vira navegação de verdade; 422 põe o foco no primeiro [aria-invalid=true] (um formulário recusado com a página inteira, sem swap, recebe o mesmo ao carregar), senão o foco (e o cursor) voltam para o campo em uso; o que entrou é hidratado (fade, show-when) e dispara trilha:swap (detail.target, detail.status). Em 5xx, erro de rede ou fragmento sem o id, o kit desiste e navega/envia normalmente. ui.swap(id, html, status) e ui.hydrate(el) fazem a troca à mão (o ui.swap devolve uma promessa: a substituição pode estar rodando dentro de uma transição de visualização). Veja Interatividade.

Espera#

SímboloO que faz
ui.Indicator(id)este elemento só aparece enquanto o alvo id espera além do limiar
ui.PendingAfter(ms)o limiar, no gatilho; padrão 120 ms, zero ou menos significa o padrão
ui.NoTransition()sem crossfade neste gatilho
ui.Spinner(attrs…)um anel girando do tamanho da fonte em que está, escondido da tecnologia assistiva

Veja funcionando: demo.

Enquanto um alvo espera, o data-trilha-pending está no alvo, no gatilho e em todo indicador daquele alvo, e o aria-busy está no alvo; trilha:pending e trilha:settled disparam no document com detail.target e detail.id. Um segundo gatilho para um alvo que já está no ar é ignorado. A substituição roda dentro do document.startViewTransition onde ele existe e onde o sistema não pede menos movimento.

A navegação no cliente fica desligada até você pedir, em dois lugares:

SímboloPapel
ui.Navigate(id) h.Nodemarca uma região: um clique em link da mesma origem dentro dela troca o elemento #id pelo mesmo elemento da próxima página. id vazio significa o próprio elemento marcado
ui.NoNavigate() h.Nodedeixa um link de fora (um download, outro app, uma rota que precisa recarregar)
ui.Prefetch(), ui.NoPrefetch(), ui.PrefetchTTL(ms)prefetch por intenção (ponteiro parado 80 ms, foco do teclado, toque) dentro de uma região Navigate, um link de fora, e quanto tempo uma resposta espera o clique (padrão 10 s) — veja Pedir antes do clique
ui.NavigateFocus(modo) h.Nodepara onde vai o foco depois de uma navegação no cliente: "h1" (padrão, o primeiro título da região), "region" ou "none"; o título novo é anunciado de qualquer jeito
ui.NavigateScript(c) h.Node<script defer src=ui.nav.js>; ponha uma vez, no layout da área que usa

O que o navegador continua fazendo: o endereço na barra é o mesmo de uma navegação normal, Voltar e Avançar funcionam (e restauram a rolagem da entrada para onde voltam), Cmd/Ctrl-clique e clique do meio abrem aba, e target, download e links para outra origem passam intactos. O que o kit acrescenta: aria-busy na região durante a espera, foco no que entrou, ui.hydrate e o evento trilha:swap, e uma requisição por vez — um segundo clique cancela a primeira. Em 5xx, erro de rede, redirecionamento ou página sem o id, ele desiste e navega de verdade.

O comportamento é um arquivo separado para que um app que não use não o baixe, e o ui.Head não o carrega. Link marcado com ui.Swap continua sendo fragmento: ele pede um pedaço da página, não a próxima página.

Upload com progresso#

Um formulário que manda arquivo é um formulário: method="post", enctype="multipart/form-data", o campo de CSRF. Três símbolos põem a barra de progresso em cima disso, e ela fica desligada até você pedir:

SímboloPapel
ui.UploadTo(id) h.Nodeno <form>: envia por XHR e troca o #id pelo que voltar
ui.UploadBar(attrs…) h.Nodeo <progress> que o kit preenche; escondido até o envio começar
ui.UploadScript(c) h.Node<script defer src=ui.upload.js>, uma vez por página que envia

A requisição leva Trilha-Fragment: id, então o handler responde o pedaço com o mesmo c.Fragment() de sempre. Enquanto sobe, a barra recebe value/max do evento de progresso do próprio navegador (e perde o value — barra indeterminada — quando o total é desconhecido), e um evento trilha:upload sobe com detail: {loaded, total, form}. Em 5xx, erro de rede ou pedaço sem o id, o formulário envia de verdade: o usuário vê a página recarregar, não um botão que não fez nada.

O atributo é data-trilha-upload, e não data-trilha-target, para o tratador de fragmento do ui.js não enviar o mesmo formulário uma segunda vez. O limite de corpo é assunto do servidor — veja AllowBody.

O ui.Dropzone(ui.DropzoneOpts{Name, Accept, MaxSize, Single, Attrs}) põe uma área de soltar em cima do campo de arquivo: um <label> que recebe o arrasto e um <ul class="ui-queue"> com uma linha por arquivo. Com ui.UploadTo no formulário, a fila manda um arquivo por requisição, então cada linha tem o próprio progresso e a própria resposta — uma mensagem que diz arquivos[2] não tem onde pousar quando três arquivos viajam num corpo só. O elemento trocado precisa estar fora do dropzone, ou a fila é destruída no meio do caminho.

O Accept e o MaxSize das opções só poupam uma ida ao servidor: dá para dizer qualquer coisa ao navegador. Quem decide é o c.Files com as FileRules — as mesmas regras, aplicadas a bytes que já chegaram. Sem JavaScript o campo é um multiple comum e o formulário posta todos os arquivos de uma vez, no mesmo handler.

Recorder#

O ui.Recorder(c, ui.RecorderOpts{Action, Name, MaxSeconds, Mime, Label, Target, Attrs}) é a metade de microfone do Audio: um formulário que grava no balcão e posta a gravação para uma rota, com a barra de progresso do upload acima.

ui.Recorder(c, ui.RecorderOpts{Action: "/api/voz", Target: "resposta", MaxSeconds: 60})
ui.RecorderScript(c)
ui.UploadScript(c)
app/…/page.go
ui.Recorder(c, ui.RecorderOpts{
	Action: "/api/voz", Target: "resposta", MaxSeconds: 60,
})
ui.RecorderScript(c)
ui.UploadScript(c)
resultado
0:00
Usar este padrão — upload-progress

Arquivos enviados com barra de progresso e conferidos pelo conteúdo no servidor, funcionando também sem JavaScript.

Componentes

ui.UploadTo ui.UploadBar ui.UploadScript ui.Field ui.Input ui.InvalidIf ui.Errors ui.Submit ui.PageHeader ui.Stack

Dados de que precisa

trilha.FileRules (size, count, accepted types, checked against the bytes); Save(c, up) keeps one *trilha.Upload in your blob store.

Acessibilidade

  • A barra de progresso tem aria-label e é um <progress> de verdade, lido como porcentagem.
  • O campo de arquivo tem rótulo visível que diz os limites antes de alguém tentar.
  • Um arquivo recusado volta como 422 com a mensagem no campo e o foco nele.

O page.go inteiro (compila, no máximo 60 linhas)

// Package uploadprogress is the upload-progress pattern: files sent with a
// progress bar, checked by content on the server, and the same form working
// with JavaScript off.
package uploadprogress

import (
	"errors"
	"net/http"

	"github.com/emersonjoe/trilha"
	"github.com/emersonjoe/trilha/h"
	"github.com/emersonjoe/trilha/ui"
)

// rules are checked against the bytes, not against what the browser claims.
var rules = trilha.FileRules{MaxSize: 10 << 20, MaxFiles: 5, Accept: []string{"image/*", "application/pdf"}}

// Save keeps one file — your blob store. The upload is closed after it.
var Save = func(c *trilha.Ctx, up *trilha.Upload) error { return nil }

// Page draws the form.
func Page(c *trilha.Ctx) (h.Node, error) { return screen(c, nil), nil }

// POST receives the files. A refusal answers the fragment the kit swaps in.
func POST(c *trilha.Ctx) error {
	ups, err := c.Files("files", rules)
	if err != nil {
		var fe trilha.FieldErrors
		if !errors.As(err, &fe) {
			return err
		}
		if c.Fragment() == "upload" {
			return c.Render(http.StatusUnprocessableEntity, panel(c, fe))
		}
		return c.Render(http.StatusUnprocessableEntity, screen(c, fe))
	}
	for _, up := range ups {
		err := Save(c, up)
		up.Close()
		if err != nil {
			return err
		}
	}
	c.Flash(ui.FlashSuccess, "Sent.")
	return c.Redirect(c.Request().URL.Path)
}

func screen(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
	return ui.Stack(ui.PageHeader("Files"), panel(c, errs), ui.UploadScript(c))
}

func panel(c *trilha.Ctx, errs trilha.FieldErrors) h.Node {
	return h.Form(h.ID("upload"), h.Method("post"), h.Enctype("multipart/form-data"), ui.UploadTo("upload"),
		h.Class("ui-stack"), trilha.CSRFInput(c),
		ui.Field("files", "Files (images or PDF, 10 MB each)", ui.Input(h.ID("files"), h.Name("files"),
			h.Type("file"), h.Attr("multiple", ""), ui.InvalidIf(errs, "files")), ui.Errors(errs, "files")),
		ui.UploadBar(h.Aria("label", "Upload progress")), ui.Submit(h.Text("Send")))
}

trilha ui patterns upload-progress · get_pattern {"name":"upload-progress"}

Campo de RecorderOptsPapel
Actiona rota que recebe a gravação, como o action de qualquer formulário
Nameo campo que a rota lê com c.File; padrão "audio"
MaxSecondspara a gravação sozinho; zero deixa isso com a pessoa
Mimeo que é pedido ao MediaRecorder quando o navegador suporta; padrão "audio/webm"
Labelo texto do botão de gravar; o padrão vem do idioma do pedido
Targeto id que a resposta troca, como no ui.UploadTo; vazio deixa um formulário comum

O ui.RecorderScript(c) carrega o ui.recorder.js, que mostra o botão de gravar quando o navigator.mediaDevices e o MediaRecorder estão lá, conta os segundos, põe a gravação no campo de arquivo e envia o formulário — daí para a frente quem envia é o ui.UploadScript, com o progresso e a troca. O que sobra quando o script está desligado, o navegador não tem MediaRecorder ou a pessoa nega o microfone é o <input type="file" accept="audio/*" capture> que a marcação sempre carrega: no celular ele abre o gravador do sistema.

O navegador só entrega o microfone por HTTPS e sob uma política de permissões que o libere, e o padrão do framework nega. Um app que grava define isso uma vez:

a.Config().Security.PermissionsPolicy = "camera=(), microphone=(self), geolocation=(), payment=(), usb=()"

A CSP não muda: o gravador é um arquivo do kit, carregado como qualquer outro script. A rota lê um arquivo com o c.File e as FileRules, e o que ela faz com ele — ai.Transcribe, uma resposta, ai.Speak — é a seção de voz do capítulo de IA.

Combobox#

Um campo de texto que busca numa lista são dois campos: o que a pessoa digita e o que o formulário manda. O ui.Combobox renderiza os dois — um <input role=combobox name="<nome>_q"> visível e um <input type=hidden name="<nome>"> com o valor escolhido — mais o <ul role=listbox> das opções.

Campo do ComboboxOptsPapel
Namenome do campo escondido; o visível é Name + "_q"
Value / Labelo que foi escolhido e o que está escrito por ele (a ida e volta)
Options []Optionlista curta: filtrada no navegador, sem requisição
Source stringa URL que busca; a resposta é um ui.ComboboxOptions(...)
With []stringoutros campos do mesmo formulário para levar na query (?uf=SP&q=camp)
MinChars, Debouncequando buscar (padrão 1 caractere, 200 ms)
Placeholder, Required, Attrscomo no Input

O ComboboxOptions(itens []T, de func(T) (valor, rótulo string)) é a resposta da rota de busca: só os <li>, sem envelope. A requisição leva Trilha-Fragment, então a rota responde com c.HTML e o layout da página fica de fora.

func GET(c *trilha.Ctx) error {
	return c.HTML(200, ui.ComboboxOptions(
		Buscar(c.Query("uf"), c.Query("q")),
		func(cidade string) (string, string) { return cidade, cidade },
	))
}

Sem JavaScript nada quebra: o campo visível é um texto comum, então o formulário chega com cidade_q preenchido e cidade vazio. Resolver o texto digitado contra a lista é trabalho do servidor — a mesma lista que a rota de busca lê.

O comportamento é o ui.combo.js, escrito em public/ com o resto do kit. O ui.js o carrega na primeira vez que uma página — ou um fragmento trocado nela — tem um combobox, pela URL versionada que o ui.Head põe na própria tag, então página sem combobox não o baixa.

Markdown#

func Markdown(src string, opt MarkdownOpts) h.Node

Modelo escreve Markdown, e quem digita num formulário também. O Markdown transforma isso em nós: parágrafos, ênfase, títulos, listas, citação, código inline e cercado, tabelas GFM, links e quebras.

h.Div(ui.Markdown(doc.Resumo, ui.MarkdownOpts{}))

Não existe HTML cru, e não há como ligar. Um <script> no texto é um <script> na tela, como texto — o retorno é uma árvore, não uma string, então o escape não é uma regra que alguém precise lembrar. É por isso que isto existe em vez de um h.Raw em volta de um conversor.

Campo de MarkdownOptsO que faz
HeadingBaseo nível em que # cai (padrão 3, para não competir com o <h1> da página); ###### nunca passa de <h6>
Images![alt](url) vira <img>; desligado por padrão, e a URL é validada nos dois casos
Classuma classe a mais no envelope, que é sempre ui-md

Link só continua link quando o endereço é http, https, mailto ou relativo (/, #, ./); o resto — javascript:, data: — fica como o texto que era. Link externo leva rel="noopener nofollow ugc".

As páginas do próprio site usam outro conversor (site/internal/md): outro dialeto, sobre texto deste repositório. O ui.Markdown é para texto que ninguém daqui escreveu.

Chat#

func Chat(c *trilha.Ctx, o ChatOpts) h.Node
func ChatScript(c *trilha.Ctx) h.Node
func ChatHTML(texto string) string

A conversa com um agente: as bolhas, o campo e o botão. A rota do outro lado é o ai.Serve.

ui.Chat(c, ui.ChatOpts{Action: "/api/chat", History: msgs, Greeting: "Pergunte o que quiser."})
ui.ChatScript(c)   // uma vez, no layout
Campo de ChatOptsO que faz
Actiona rota que responde — o único que precisa ser preenchido
Historyo que já foi dito, do mais antigo para o mais novo. É do app: o framework não guarda sessão
GreetingMarkdown mostrado enquanto o histórico está vazio
IDo id do elemento e o prefixo dos ids de dentro (padrão chat)
Placeholder, Submit, Labelas palavras na tela
MaxLengthlimita o campo (padrão 4000; negativo tira o limite)
Stepsmostra a ferramenta que o agente chamou e o que voltou
Markdowno MarkdownOpts das respostas

ChatMessage{Role, Text, Sources} é um turno. Role: "assistant" sai como Markdown; Role: "user" sai como texto — o que alguém digitou nunca é marcação.

ChatSource{Title, URL, Snippet} anexa as páginas ou documentos que sustentam uma resposta do assistente. Título e trecho são escapados, a URL passa pela regra de link seguro do Markdown, e o streaming aceita sources num trecho ou no evento final done. As fontes fazem parte da resposta tanto quando ela chega inteira quanto quando chega token a token.

Com o ChatScript a resposta chega palavra por palavra e o Markdown é renderizado no fim da mensagem: passe ui.ChatHTML para o ai.ServeOpts.HTML e a bolha pronta fica igual à de uma página recarregada. Sem o script o formulário submete do mesmo jeito e a rota responde tudo de uma vez, então nada na tela depende do script rodar.

Campo de ChatOptsO que faz
Contexto que a página sabe e o modelo não: {"folha": id} vira campos escondidos ctx.*, mandados junto com a mensagem e lidos pelo ai.ServeOpts.Context

Assistant#

func Assistant(c *trilha.Ctx, o AssistantOpts) h.Node

O outro formato de um chat: um botão fixo no canto que abre um painel sobre a tela, em vez de trocá-la. Monte uma vez, no layout da área que o tem.

ui.Assistant(c, ui.AssistantOpts{
	Action: "/api/assistente",
	Page:   "/painel/assistente",          // a mesma conversa, como página
	Hint:   "Perguntando sobre a folha " + id, // o que ele sabe agora
	Chat:   ui.ChatOpts{Context: map[string]string{"folha": id}},
})
ui.ChatScript(c)
Campo de AssistantOptsO que faz
Actiona rota que responde, a do ai.Serve
Pagea conversa como página: para onde o launcher aponta quando não há script
Label, Title, Hinta palavra no botão, o título do painel e a linha embaixo dele
Iconum ícone do kit no botão; vazio é só o rótulo
IDo prefixo de todos os ids de dentro (padrão assistant) — dois assistentes precisam de dois
Chata conversa em si: histórico, saudação, contexto

O launcher é um link antes de ser um botão. Sem JavaScript ele vai para o Page, que é a mesma conversa como página inteira; com JavaScript o script do kit abre o <dialog> no lugar, e o foco preso e o Escape vêm do navegador. O aria-expanded acompanha o painel, o aria-controls o nomeia, e tudo é HTML do servidor — uma página com assistente não vira ilha.

É composição, não um segundo chat: o painel tem um Chat dentro, então o streaming, o Markdown e os erros são os que já existem.

Veja funcionando na demo do assistente: o componente de verdade sobre uma tela pequena, respondido no navegador no contrato do ai.Serve — o launcher, o painel, o streaming e o contexto são os que uma aplicação recebe.

DeadlineCards#

func DeadlineCards(c *trilha.Ctx, s trilha.DeadlineSummary) h.Node
func DeadlineList(c *trilha.Ctx, items []trilha.Deadline, o DeadlineListOpts) h.Node
func DeadlineBadge(c *trilha.Ctx, items []trilha.Deadline) h.Node

O painel de um resumo de trilha.Deadlines: os cartões em cima, a lista embaixo, o número ao lado do item de menu.

resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})

ui.DeadlineCards(c, resumo)
ui.DeadlineList(c, itens, ui.DeadlineListOpts{Limit: 10, More: "/prazos"})
ui.DeadlineBadge(c, resumo.Overdue)

Os cartões seguem a ordem das faixas, então não trocam de lugar entre recargas, e o de vencidos só fica vermelho quando há do que reclamar — um zero vermelho ensina a ignorar a cor, e aí o três também é ignorado. O último cartão é o Next: uma contagem sem um exemplo ao lado é um número que alguém precisa clicar para entender.

O DeadlineListOpts recebe Limit (com More para o link do "e mais 12"), Owner para a coluna de responsável, RowAction func(trilha.Deadline) h.Node para uma ação da aplicação ao fim de cada linha, Empty para a frase de quando não há nada, e Now — o relógio contra o qual o atraso é medido, para um teste não quebrar sozinho na manhã seguinte. As datas são escritas pelo ui.Date com Relative, e a linha cujo dia acabou leva ui-late, a mesma classe da caixa de aprovações: atraso é igual em toda a aplicação, ou parece defeito.

O DeadlineBadge não desenha nada com a lista vazia, e diz no aria-label o que a cor diz. Nenhuma receita escreve esta tela — trilha.Deadlines é um primitivo do framework, usado direto na página da aplicação.

app/…/page.go
resumo := trilha.Deadlines(itens, trilha.DeadlineOpts{Now: time.Now().In(c.Location())})

ui.DeadlineCards(c, resumo)
ui.DeadlineList(c, itens, ui.DeadlineListOpts{Limit: 10, More: "/prazos"})
ui.DeadlineBadge(c, resumo.Overdue)
resultado
Vencidos1
Certificado SSL — api.example.com
Em 7 dias1
Contrato de fornecedor — Acme
Em 30 dias2
Contrato de fornecedor — Acme
Em 90 dias2
Contrato de fornecedor — Acme
O quêTipoPrazo
Certificado SSL — api.example.comcertificado
Contrato de fornecedor — Acmecontrato
Contrato de fornecedor — Acmecontrato
1

VersionList#

func VersionList(c *trilha.Ctx, rows []VersionRow, o VersionOpts) h.Node
func VersionBadge(n int, published bool, words ...map[string]string) h.Node
func Changed(before, after map[string]string) []string

O histórico de um trilha.Versioned[T]: quem, quando, o que mudou, e os botões de publicar ou voltar. Do mais novo para o mais velho, porque um histórico se lê de agora para trás.

Sem JavaScript e sem biblioteca de diff: o que mudou é uma lista de nomes de campo, e o ui.Changed a monta a partir de dois mapas de strings — a metade honesta de um diff, e a metade que alguém lê antes de abrir a versão. A linha publicada não traz botões: ela já é a que todo mundo lê. Nenhuma receita escreve esta tela também — trilha.Versioned[T] é um primitivo do framework.

app/…/page.go
n, _ := Modelos.Save(c, id, m, "ajuste")
_ = Modelos.Publish(c, id, n)

ui.VersionList(c, linhas, ui.VersionOpts{Restore: "/admin/modelos/1/restaurar", CSRF: trilha.CSRFInput(c)})
// linha.Changed = ui.Changed(antes, depois)
resultado
VersãoQuemQuandoMudou
v3 · publicadaAna Souza
Título
v2Ana Souzaprimeiro rascunho

ConnectionsPanel#

func ConnectionsPanel(c *trilha.Ctx, x *trilha.Connections, o ConnectionsOpts) h.Node
func ConnectionStatus(c *trilha.Ctx, t *trilha.ConnectionTest) h.Node
func ParseConnectionForm(c *trilha.Ctx) trilha.Connection

A tela de um trilha.Connections: a lista agrupada por tipo — nome, URL, autenticação, o badge do último teste — e o formulário de uma conexão, nova ou em edição (Editing, com Errors de um save recusado). Cada botão é um formulário que posta para Path com um _action oculto (save, test, delete) e o id, mais o nó CSRF que você passa; nada precisa de script. Os campos de usuário e nome do cabeçalho aparecem com ShowWhen para a autenticação que os usa.

O segredo é a única coisa que a tela nunca contém: o campo é o SecretField, que renderiza vazio e diz "deixe em branco para manter", e ParseConnectionForm o lê de volta como Secret, de modo que um vazio mantém o valor anterior no Save. Quando o store guarda a credencial em outro sistema, marque Connection.HasSecret e renderize SecretFieldWithPresence: o update vazio ainda significa manter, sem trazer a credencial de volta para este processo.

O trilha add connections escreve o pacote, esta tela e o teste.

app/…/page.go
ui.ConnectionsPanel(c, conexoes.Conexoes, ui.ConnectionsOpts{
	Path: "/admin/conexoes", CSRF: trilha.CSRFInput(c), Editing: editando, Errors: errs,
})
resultado

API

NomeURLAutenticaçãoÚltimo teste
API de faturamentohttps://billing.example.comBearer tokenokEditar
data-ui-confirm="Apagar esta conexão?" data-ui-confirm-description="O que estiver usando ela para de funcionar na hora."

Servidor MCP

NomeURLAutenticaçãoÚltimo teste
MCP de documentoshttps://docs.example.com/mcpNonefalhouEditar
data-ui-confirm="Apagar esta conexão?" data-ui-confirm-description="O que estiver usando ela para de funcionar na hora."

Nova conexão

O endereço base. Os caminhos quem chama acrescenta.

Nada guardado ainda. Deixe em branco para manter o atual.

Um por linha, Nome: valor. Não é lugar de segredo.

Audio#

ui.Audio(c, "/gravacoes/42", ui.AudioOpts{
	Title:    "Entrevista",
	Type:     "audio/mpeg",
	Duration: 3*time.Minute + 12*time.Second,
	Download: "/gravacoes/42?download=1",
	Preload:  ui.PreloadMetadata,
})

Audio usa os controles nativos do navegador e a mesma barra de título e rotas de saída do Preview. PreloadNone é o padrão para listas; PreloadMetadata busca os metadados da duração; PreloadAuto deixa a escolha para o navegador. Um MIME que não é de áudio renderiza um estado honesto de "não pode reproduzir" e mantém a ação de download em vez de deixar um player quebrado.

InstallApp#

InstallApp é o convite progressivo usado pela receita pwa. Ele mantém uma instrução útil sobre o menu sem JavaScript, expõe o prompt nativo do Chromium quando disponível, explica os passos do Safari no iOS e desaparece quando c.Standalone() diz que o app já está instalado. InstallAppOpts permite trocar script, manifesto, endereço de ajuda e todo texto visível.

Formatação#

Data, tamanho, duração e contagem não são domínio: são iguais em toda aplicação, e toda aplicação escreve de novo — normalmente quatro vezes, um pouco diferente em cada uma, e quase sempre ignorando o fuso.

// app/setup.go
func Config(cfg *trilha.Config) {
	cfg.Locale = "pt-BR"                  // "en" é o valor zero
	cfg.TimeZone = "America/Sao_Paulo"    // vazio significa UTC
}
ui.Date(c, doc.CriadoEm)                  // <time datetime="…">08/09/2026 12:04</time>
ui.Date(c, doc.CriadoEm, ui.Relative())   // há 3 min, com o absoluto no title
ui.Date(c, doc.CriadoEm, ui.DateOnly())   // 08/09/2026
ui.Date(c, doc.CriadoEm, ui.TimeOnly())   // 12:04       (o dia já está no cabeçalho)
ui.Bytes(c, doc.Tamanho)                  // 1,4 MB      (en: 1.4 MB)
ui.Duration(c, tarefa.Levou)              // 2 min 13 s
ui.Number(c, total)                       // 12.345      (en: 12,345)
ui.Number(c, preco, ui.Decimals(2))       // 1.234,56

As opções são valores ui.FormatOpt — Relative, DateOnly, TimeOnly, Decimals — que é o que permite a uma coluna de um ui.DataTable levar o formato que quer junto do campo que lê.

Veja funcionando: demo.

As regras que valem saber#

Valor ausente é um travessão. time.Time zero, *time.Time nil, tamanho zero: todos renderizam — em texto apagado. Data zero saindo como 01/01/0001 é o bug que isto remove.

O datetime é sempre o instante. O texto é local e traduzido; o atributo que a máquina lê é RFC 3339 em UTC — então um copiar-colar, uma ordenação ou um leitor de tela recebe o fato, e não a apresentação.

O Relative não anda. Ele escreve "há 3 min" e guarda o absoluto no title. Nada o atualiza: o kit não tem relógio e não quer um. Tela que precisa do número se mexendo põe o pedaço num ui.Poll — uma decisão que a página toma e paga, uma vez.

Bytes é base 10. kB, MB, GB: o que o gerenciador de arquivos de quem está lendo já mostra. A contagem exata fica no title.

Metades decimais arredondam para longe do zero. Number, Bytes e Duration usam a mesma regra half-expand do JavaScript e do ICU para o decimal curto que exibem; valores como 1,25 com uma casa viram 1,3, não o arredondamento bancário 1,2 do strconv.

Fuso desconhecido cai para UTC e avisa no log. Cair em silêncio deslocaria todo horário da tela sem nada parecer quebrado.

Eles recebem um Ctx, e isso é de propósito#

ui.Date(c, t) e não ui.Date(t). Um idioma de pacote seria compartilhado por duas aplicações rodando num processo — que é exatamente o que o trilha.Provide e o app embutido existem para permitir —, e a segunda a subir mudaria a primeira em silêncio. O ui.Head, o ui.Flashes e o ui.DataTable recebem um Ctx pelo mesmo tipo de razão.

Dinheiro não está aqui#

A moeda, onde fica o símbolo, como um negativo se lê: são decisões da aplicação, e um framework que chutasse estaria errado no país de alguém. ui.Number(c, v, ui.Decimals(2)) com o símbolo escrito ao lado é a receita inteira.

O trilha audit avisa sobre time.Format("02/01/2006") dentro de app/: layout na página ignora o Config.TimeZone, que é como uma data mostrada a alguém de outro país acaba simplesmente errada.

Tema#

ui.theme.css define, em :root e .dark, as variáveis do shadcn/ui v4: --background/--foreground, --card/--card-foreground, --popover/…, --primary/…, --secondary/…, --muted/…, --accent/…, --destructive, --border, --input, --ring, --chart-1…5, --sidebar…, --radius. O Trilha amplia esse contrato com tokens semânticos de intenção (--success-*, --warning-*, --info-*, --destructive-*) e sombras (--ui-shadow-sm/md/lg, além do alias --shadow). Use esses tokens em estados operacionais em vez de cores primitivas. ui.css deriva --radius-sm/md/lg/xl. O modo escuro é a classe dark no <html> (o script de ui.Head aplica a preferência salva ou a do sistema antes da primeira pintura).

Ganchos#

Toda classe que o kit escreve tem uma regra no ui.css, com uma lista curta de exceções: classes escritas para a folha de estilo do app ter onde se pendurar, sem regra própria porque o kit não tem nada a dizer ali. Um teste em ui/ mantém a lista curta assim — uma classe fora dela sem regra é bug, como o ui-inline-form que o painel de conexões escreveu errado por duas versões.

GanchoEm
ui-assistant, ui-assistant-labelo widget Assistant e seu rótulo
ui-audioo player Audio
ui-btn-primaryo Button padrão, ao lado das variantes que têm regra
ui-connections, -actions, -buttons, -form, -group, -kind, -listas partes do ConnectionsPanel
ui-deadline-cardcada cartão de DeadlineCards
ui-install-appo botão InstallApp
ui-list-formo formulário em volta de um DataTable com seleção
ui-md-img, ui-md-listimagens e listas dentro de Markdown
ui-quoteuma citação dentro de Markdown
ui-shell-usero canto do usuário do Shell
ui-taska caixa do TaskProgress
ui-tree-branch, ui-tree-radioos nós de uma Tree
ui-webhooks, -events, -form, -titleas partes do WebhooksPanel

CLI#

trilha ui [--force] [--css-only|--js-only] grava os arquivos em public/: ui.theme.css só é criado (nunca sobrescrito); ui.css, ui.js, ui.nav.js, ui.upload.js, ui.recorder.js, ui.live.js, ui.chat.js e ui.island.js são atualizados quando iguais a uma versão anterior e, se você os editou, só com --force.

Padrões#

Um componente responde "como eu desenho uma tabela"; um padrão responde "como eu monto uma listagem aqui". O ui.Patterns() devolve seis, cada um um page.go completo de examples/patterns/ — o repositório compila todos, e nenhum passa de 60 linhas:

PadrãoQuando
list-with-filteruma listagem que alguém busca, filtra, ordena e pagina, tudo no endereço
async-formum formulário que posta sem sair da página, o 422 ao lado do campo errado
approval-inboxo que espera a decisão de uma pessoa, com o motivo no mesmo formulário
dashboard-chartos números em cima e os gráficos desenhados no servidor ao lado
master-detailuma lista e a linha escolhida, a escolha no endereço
upload-progressarquivos com barra de progresso, conferidos pelo conteúdo, funcionando sem JavaScript

O trilha ui patterns lista os padrões (--json para máquina, um nome para um deles), a ferramenta MCP get_pattern responde um, o trilha ui describe <Componente> diz de quais padrões um componente participa, e a demo de cada padrão neste site termina com a seção "Usar este padrão".