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ímbolo | Papel |
|---|---|
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.Node | classe ui-body para o <body> |
ui.Asset(nome) []byte | conteú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.Files | os 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ção | Renderiza |
|---|---|
Container, Stack, Row, Grid, Spacer | layout: 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, CardFooter | cartão |
Input, Textarea, Select, Checkbox, Radio, Switch, Label | controles (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 |
Separator | traç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() []Pattern | os 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ímbolo | Papel |
|---|---|
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ímbolo | O 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.
Navegação#
A navegação no cliente fica desligada até você pedir, em dois lugares:
| Símbolo | Papel |
|---|---|
ui.Navigate(id) h.Node | marca 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.Node | deixa 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.Node | para 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ímbolo | Papel |
|---|---|
ui.UploadTo(id) h.Node | no <form>: envia por XHR e troca o #id pelo que voltar |
ui.UploadBar(attrs…) h.Node | o <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)ui.Recorder(c, ui.RecorderOpts{
Action: "/api/voz", Target: "resposta", MaxSeconds: 60,
})
ui.RecorderScript(c)
ui.UploadScript(c)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 RecorderOpts | Papel |
|---|---|
Action | a rota que recebe a gravação, como o action de qualquer formulário |
Name | o campo que a rota lê com c.File; padrão "audio" |
MaxSeconds | para a gravação sozinho; zero deixa isso com a pessoa |
Mime | o que é pedido ao MediaRecorder quando o navegador suporta; padrão "audio/webm" |
Label | o texto do botão de gravar; o padrão vem do idioma do pedido |
Target | o 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 ComboboxOpts | Papel |
|---|---|
Name | nome do campo escondido; o visível é Name + "_q" |
Value / Label | o que foi escolhido e o que está escrito por ele (a ida e volta) |
Options []Option | lista curta: filtrada no navegador, sem requisição |
Source string | a URL que busca; a resposta é um ui.ComboboxOptions(...) |
With []string | outros campos do mesmo formulário para levar na query (?uf=SP&q=camp) |
MinChars, Debounce | quando buscar (padrão 1 caractere, 200 ms) |
Placeholder, Required, Attrs | como 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.NodeModelo 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 MarkdownOpts | O que faz |
|---|---|
HeadingBase | o nível em que # cai (padrão 3, para não competir com o <h1> da página); ###### nunca passa de <h6> |
Images |  vira <img>; desligado por padrão, e a URL é validada nos dois casos |
Class | uma 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) stringA 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 layoutCampo de ChatOpts | O que faz |
|---|---|
Action | a rota que responde — o único que precisa ser preenchido |
History | o que já foi dito, do mais antigo para o mais novo. É do app: o framework não guarda sessão |
Greeting | Markdown mostrado enquanto o histórico está vazio |
ID | o id do elemento e o prefixo dos ids de dentro (padrão chat) |
Placeholder, Submit, Label | as palavras na tela |
MaxLength | limita o campo (padrão 4000; negativo tira o limite) |
Steps | mostra a ferramenta que o agente chamou e o que voltou |
Markdown | o 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 ChatOpts | O que faz |
|---|---|
Context | o 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.NodeO 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 AssistantOpts | O que faz |
|---|---|
Action | a rota que responde, a do ai.Serve |
Page | a conversa como página: para onde o launcher aponta quando não há script |
Label, Title, Hint | a palavra no botão, o título do painel e a linha embaixo dele |
Icon | um ícone do kit no botão; vazio é só o rótulo |
ID | o prefixo de todos os ids de dentro (padrão assistant) — dois assistentes precisam de dois |
Chat | a 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.NodeO 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.
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)| O quê | Tipo | Prazo |
|---|---|---|
| Certificado SSL — api.example.com | certificado | |
| Contrato de fornecedor — Acme | contrato | |
| Contrato de fornecedor — Acme | contrato |
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) []stringO 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.
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)| Versão | Quem | Quando | Mudou | |
|---|---|---|---|---|
| v3 · publicada | Ana Souza | Título | ||
| v2 | Ana Souza | primeiro 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.ConnectionA 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.
ui.ConnectionsPanel(c, conexoes.Conexoes, ui.ConnectionsOpts{
Path: "/admin/conexoes", CSRF: trilha.CSRFInput(c), Editing: editando, Errors: errs,
})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,56As 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.
| Gancho | Em |
|---|---|
ui-assistant, ui-assistant-label | o widget Assistant e seu rótulo |
ui-audio | o player Audio |
ui-btn-primary | o Button padrão, ao lado das variantes que têm regra |
ui-connections, -actions, -buttons, -form, -group, -kind, -list | as partes do ConnectionsPanel |
ui-deadline-card | cada cartão de DeadlineCards |
ui-install-app | o botão InstallApp |
ui-list-form | o formulário em volta de um DataTable com seleção |
ui-md-img, ui-md-list | imagens e listas dentro de Markdown |
ui-quote | uma citação dentro de Markdown |
ui-shell-user | o canto do usuário do Shell |
ui-task | a caixa do TaskProgress |
ui-tree-branch, ui-tree-radio | os nós de uma Tree |
ui-webhooks, -events, -form, -title | as 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ão | Quando |
|---|---|
list-with-filter | uma listagem que alguém busca, filtra, ordena e pagina, tudo no endereço |
async-form | um formulário que posta sem sair da página, o 422 ao lado do campo errado |
approval-inbox | o que espera a decisão de uma pessoa, com o motivo no mesmo formulário |
dashboard-chart | os números em cima e os gráficos desenhados no servidor ao lado |
master-detail | uma lista e a linha escolhida, a escolha no endereço |
upload-progress | arquivos 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".