Ir para o conteúdo
Trilha
Capítulos

Aprender

Interface com ui

O kit de componentes padrão do Trilha, compatível com temas do shadcn/ui, e como ele fica seu para customizar.

Todo projeto criado com trilha new já vem com o kit ui: componentes tipados em Go (ui.Button, ui.Card, ui.Field...) que renderizam classes de um CSS pequeno e prefixado (ui-*), mais um JavaScript de 200 linhas para o que o HTML não faz sozinho (abas, avisos que somem, campos condicionais, tema claro/escuro). Nenhuma dependência: os três arquivos ficam em public/ e são seus.

public/ui.theme.css   ← as cores e o raio: edite ou cole um tema pronto
public/ui.css         ← os componentes; `trilha ui` atualiza
public/ui.js          ← comportamentos; `trilha ui` atualiza

O contrato de tema é o do shadcn/ui (MIT): as mesmas variáveis, --background, --primary, --radius, em oklch. Gere um tema em ui.shadcn.com/themes ou tweakcn.com, cole o bloco :root { … } .dark { … } em ui.theme.css e pronto: nada em Go muda. O Trilha não usa React nem Tailwind; a compatibilidade é só do tema.

Ligando o kit#

O layout gerado já faz isso; num projeto existente, rode trilha ui e adicione:

h.Head(…, ui.Head(c)),          // ui.theme.css, ui.css, tema salvo, ui.js
h.Body(ui.Body(),               // fonte e cores do tema
	ui.Header(ui.Brand("/", "Meu app"), ui.Nav(ui.NavLink("/", "Início", true)), ui.Spacer(), ui.ThemeToggle()),
	h.Main(ui.Container(children)),
	ui.Flashes(c),              // onde os avisos aparecem, o c.Flash junto
)

Variantes são atributos#

Um componente é uma função que devolve h.Node; variantes e tamanhos são atributos de classe que você mistura com qualquer atributo do h, na ordem que quiser. O h funde os class repetidos em um só.

app/…/page.go
ui.Row(
	ui.Button(h.Text("Salvar")),
	ui.Button(ui.Secondary(), h.Text("Rascunho")),
	ui.Button(ui.Outline(), h.Text("Cancelar")),
	ui.Button(ui.Ghost(), ui.Icon("settings")),
	ui.Button(ui.Destructive(), ui.Sm(), ui.Icon("trash"), h.Text("Apagar")),
	ui.Badge(h.Text("novo")),
	ui.Badge(ui.Outline(), h.Text("beta")),
)
resultado
novobeta

Formulários#

ui.Field junta rótulo, controle, ajuda e erro com os id/for e o aria-* certos. ui.ShowWhen("campo", "valor") mostra o grupo só enquanto o campo tem aquele valor e desabilita os controles escondidos, para eles não irem no POST. Sem JavaScript, os campos simplesmente aparecem todos.

app/…/page.go
h.Form(h.Class("ui-stack"),
	ui.Field("tipo", "Tipo de pessoa",
		ui.Select(h.ID("tipo"), h.Name("tipo"),
			h.Option(h.Value("pf"), h.Text("Física")),
			h.Option(h.Value("pj"), h.Text("Jurídica")))),
	ui.Field("cpf", "CPF", ui.Input(h.ID("cpf"), h.Name("cpf")),
		ui.With(ui.ShowWhen("tipo", "pf"))),
	ui.Field("cnpj", "CNPJ", ui.Input(h.ID("cnpj"), h.Name("cnpj")),
		ui.Help("14 dígitos"), ui.With(ui.ShowWhen("tipo", "pj"))),
	ui.CheckRow(ui.Switch(h.ID("nf"), h.Name("nf")), "Emitir nota fiscal", "nf"),
	ui.Field("email", "E-mail para a nota", ui.Input(h.ID("email"), h.Type("email")),
		ui.With(ui.ShowWhen("nf"))),
)
resultado

14 dígitos

Depois de um POST, renderize o erro no próprio campo (ui.Error("Título obrigatório") + ui.Invalid() no controle) e um aviso que some sozinho: ui.Toast("success", "Salvo!", 4000) dentro do toaster do layout. O exemplo examples/blog faz as duas coisas em app/blog/novo/page.go.

Para um formulário enviado com fetch, preserve event.currentTarget antes do primeiro await. Use ui.formPending(form) durante a requisição e ui.formError(form, mensagem, {field}) para que a falha continue visível dentro do diálogo, com foco e aria-invalid no campo correto.

app/…/page.go
ui.DialogTrigger("perfil-assincrono", ui.Outline(), h.Text("Editar perfil"))
ui.Dialog("perfil-assincrono", "Editar perfil",
	h.Form(h.Method("post"), h.Class("ui-stack"),
		ui.FormError(),
		ui.Field("email-assincrono", "E-mail",
			ui.Input(h.ID("email-assincrono"), h.Name("email"), h.Type("email"))),
		ui.DialogFooter(ui.DialogClose(ui.Ghost(), h.Text("Cancelar")), ui.Submit(h.Text("Salvar"))),
	),
)
resultado

Editar perfil

A demonstração simula uma resposta remota inválida.

Usar este padrão — async-form

Um formulário que posta sem sair da página e responde um 422 ao lado do campo errado.

Componentes

ui.Swap ui.FormError ui.Field ui.Input ui.InvalidIf ui.Errors ui.Submit

Dados de que precisa

type Profile struct with form and validate tags, bound by c.Bind; Save(c, p) error stores a valid one. A trilha.FieldErrors from Bind is the 422.

Acessibilidade

  • Num 422 o kit troca o formulário e leva o foco ao primeiro campo com aria-invalid.
  • O ui.Field liga a mensagem ao campo com aria-describedby.
  • O ui.FormError é o resumo que o leitor de tela ouve quando a resposta chega.

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

// Package asyncform is the async-form pattern: a form that posts without
// leaving the page, and a 422 that comes back with the message beside the
// field and the focus on it.
package asyncform

import (
	"errors"
	"net/http"

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

// Profile is what the form edits; the tags are its rules.
type Profile struct {
	Name  string `form:"name" validate:"required,max=80" label:"Name"`
	Email string `form:"email" validate:"required,email" label:"E-mail"`
}

// Save is where a valid profile goes — your store.
var Save = func(c *trilha.Ctx, p Profile) error { return nil }

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

// POST validates by the tags. The 422 answers the fragment: the kit swaps it
// in place and focuses the first field marked invalid.
func POST(c *trilha.Ctx) error {
	var p Profile
	if err := c.Bind(&p); err != nil {
		var fe trilha.FieldErrors
		if errors.As(err, &fe) {
			return c.Render(http.StatusUnprocessableEntity, form(c, p, fe))
		}
		return err
	}
	if err := Save(c, p); err != nil {
		return err
	}
	c.Flash(ui.FlashSuccess, "Saved.")
	return c.Redirect(c.Request().URL.Path)
}

func form(c *trilha.Ctx, p Profile, errs trilha.FieldErrors) h.Node {
	return h.Form(h.ID("profile"), h.Method("post"), ui.Swap("profile"), h.Class("ui-stack"),
		trilha.CSRFInput(c), ui.FormError(),
		ui.Field("name", "Name", ui.Input(h.ID("name"), h.Name("name"), h.Value(p.Name),
			ui.InvalidIf(errs, "name")), ui.Errors(errs, "name")),
		ui.Field("email", "E-mail", ui.Input(h.ID("email"), h.Name("email"), h.Type("email"),
			h.Value(p.Email), ui.InvalidIf(errs, "email")), ui.Errors(errs, "email")),
		ui.Submit(h.Text("Save")))
}

trilha ui patterns async-form · get_pattern {"name":"async-form"}

Contar o que aconteceu, e perguntar antes de destruir#

Um POST que deu certo termina em redirect, e o redirect come a notícia. O c.Flash escreve num cookie assinado, e o ui.Flashes(c) do layout mostra na página seguinte:

c.Flash(ui.FlashSuccess, "Post apagado")
return c.Redirect("/blog")

Os tipos são ui.FlashInfo, ui.FlashSuccess e ui.FlashError. Numa resposta de fragmento não há redirect para sobreviver: os avisos vão num cabeçalho e quem mostra é o ui.js — a chamada no handler é a mesma. Sem TRILHA_SECRET nada é escrito, e o app avisa uma vez no log.

Antes de algo irreversível, o ui.Confirm põe a pergunta no próprio formulário:

h.Form(h.Method("post"), h.Action("/blog/"+p.Slug), trilha.CSRFInput(c),
	ui.Confirm("Apagar este post?", "Não dá para desfazer."),
	h.Data("ui-confirm-cancel", "Cancelar"),
	ui.Submit(ui.Destructive(), h.Text("Apagar")))

O ui.js segura o envio, abre o diálogo do kit e só então deixa passar. Sem JavaScript o formulário envia direto; quando isso não serve, pergunte numa página própria (GET /blog/{slug}/apagar renderizando o mesmo formulário), que funciona dos dois jeitos.

app/…/page.go
h.Form(h.Method("post"), h.Action("/blog/"+p.Slug), trilha.CSRFInput(c),
	ui.Confirm("Apagar este post?", "Não dá para desfazer."),
	h.Data("ui-confirm-cancel", "Cancelar"),
	ui.Submit(ui.Destructive(), h.Text("Apagar")),
)
resultado

Cards, abas, progresso#

app/…/page.go
ui.Card(
	ui.CardHeader(ui.CardTitle("Meta do mês"), ui.CardDescription("7 de 10 posts")),
	ui.CardContent(
		ui.Progress(7, 10),
		ui.Tabs("meta",
			ui.Tab{Label: "Resumo", Content: h.P(h.Text("Faltam 3."))},
			ui.Tab{Label: "Detalhes", Content: h.P(h.Text("Abas com teclado e ARIA."))},
		),
	),
	ui.CardFooter(ui.Button(ui.Sm(), h.Text("Publicar"))),
)
resultado

Meta do mês

7 de 10 posts

Faltam 3.

Diálogo e avisos#

ui.Dialog é um <dialog> nativo: fecha com Esc, clique fora ou ui.DialogClose; o formulário dentro dele faz POST normalmente.

app/…/page.go
ui.Row(
	ui.DialogTrigger("confirma", ui.Outline(), h.Text("Abrir diálogo")),
	ui.Dialog("confirma", "Publicar agora?",
		ui.DialogDescription("O post fica visível para todos."),
		ui.DialogFooter(ui.DialogClose(ui.Ghost(), h.Text("Depois")), ui.DialogClose(h.Text("Publicar")))),
	ui.Button(ui.Secondary(), h.Data("ui-toast", "Salvo!"), h.Text("Mostrar aviso")),
)
// data-ui-toast mostra um aviso ao clicar; do servidor, após um POST,
// renderize ui.Toast("success", "Salvo!", 4000) dentro de ui.Toaster().
resultado

Publicar agora?

O post fica visível para todos.

Tabelas com hierarquia#

ui.Depth(n) indenta a primeira célula: serve para plano de contas, árvore de categorias e qualquer drill-down renderizado no servidor. ui.Num() alinha números à direita.

app/…/page.go
ui.Table(
	h.Thead(h.Tr(h.Th(h.Text("Conta")), h.Th(ui.Num(), h.Text("Orçado")), h.Th(ui.Num(), h.Text("Realizado")))),
	h.Tbody(
		h.Tr(ui.Depth(0), h.Td(h.Strong(h.Text("Despesas"))), h.Td(ui.Num(), h.Text("12.000")), h.Td(ui.Num(), h.Text("11.240"))),
		h.Tr(ui.Depth(1), h.Td(h.Text("Pessoal")), h.Td(ui.Num(), h.Text("8.000")), h.Td(ui.Num(), h.Text("8.000"))),
		h.Tr(ui.Depth(1), h.Td(h.Text("Marketing")), h.Td(ui.Num(), h.Text("4.000")), h.Td(ui.Num(), ui.Badge(ui.Destructive(), h.Text("3.240")))),
	),
)
resultado
ContaOrçadoRealizado
Despesas12.00011.240
Pessoal8.0008.000
Marketing4.0003.240
Usar este padrão — master-detail

Uma lista de um lado e a linha escolhida do outro, a escolha no endereço.

Componentes

ui.DataTable ui.Columns ui.ListState ui.Empty ui.EmptyOpts ui.Card ui.CardHeader ui.CardTitle ui.CardDescription ui.CardContent ui.Badge ui.Grid ui.Cols

Dados de que precisa

type Query struct{ trilha.ListParams; ID string `form:"id"` }; Customers(c, q) answers one page and the total, Find(c, id) one row or trilha.ErrNotFound.

Acessibilidade

  • Cada linha é um link (ListState.RowHref), então o teclado chega ao detalhe.
  • O detalhe é uma seção com aria-label, um ponto de referência para onde pular.
  • A escolha mora no endereço: voltar e recarregar a mantêm.

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

// Package masterdetail is the master-detail pattern: the list on one side and
// the chosen row on the other, the choice in the address so it can be shared
// and survives a reload.
package masterdetail

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

// Customer is one row. Replace it with your type.
type Customer struct{ ID, Name, Email, Plan string }

// Query is the listing plus the chosen row.
type Query struct {
	trilha.ListParams
	ID string `form:"id"`
}

// Customers answers one page and the total; Find answers one row or
// trilha.ErrNotFound — both from your store.
var (
	Customers = func(c *trilha.Ctx, q trilha.ListParams) ([]Customer, int, error) { return nil, 0, nil }
	Find      = func(c *trilha.Ctx, id string) (Customer, error) { return Customer{}, trilha.ErrNotFound }
)

var columns = ui.Columns[Customer]{
	{Key: "name", Label: "Name", Sort: true, Cell: func(x Customer) h.Node { return h.Text(x.Name) }},
	{Key: "plan", Label: "Plan", Cell: func(x Customer) h.Node { return ui.Badge(ui.Outline(), h.Text(x.Plan)) }},
}

// Page renders the list and, when the address names one, its detail.
func Page(c *trilha.Ctx) (h.Node, error) {
	var q Query
	if err := c.Bind(&q); err != nil {
		return nil, err
	}
	rows, total, err := Customers(c, q.ListParams)
	if err != nil {
		return nil, err
	}
	list := ui.DataTable(c, columns, rows, ui.ListState{Params: q.ListParams, Total: total, Caption: "Customers",
		RowHref: func(i int) string { return q.Href("id", rows[i].ID) }})
	var detail h.Node = ui.Empty(ui.EmptyOpts{Title: "Pick a customer to see the details"})
	if q.ID != "" {
		x, err := Find(c, q.ID)
		if err != nil {
			return nil, err
		}
		detail = ui.Card(ui.CardHeader(ui.CardTitle(x.Name), ui.CardDescription(x.Email)),
			ui.CardContent(ui.Badge(h.Text(x.Plan))))
	}
	return ui.Stack(ui.PageHeader("Customers"),
		ui.Grid(ui.Cols(1, 2), list, h.Section(h.Aria("label", "Details"), detail))), nil
}

trilha ui patterns master-detail · get_pattern {"name":"master-detail"}

Paginação e dicas#

ui.Pagination desenha a navegação de páginas com links de verdade, então uma página pode ser compartilhada, recarregada e indexada. A página atual é um <span> com aria-current — link para onde você já está é link para lugar nenhum — e a primeira página não tem anterior, então nada é desenhado no lugar. A janela guarda a primeira página, a última e as vizinhas da atual, com reticências sobre cada buraco, para o rodapé não crescer junto com a tabela.

ui.Tooltip escreve a dica no title, que é o tooltip do próprio navegador e funciona com o ui.js desligado. Com o script na página o title some — dois tooltips é pior que nenhum —, uma bolha com role="tooltip" toma o lugar dele, o alvo ganha aria-describedby e a dica responde ao mouse, ao foco do teclado e ao toque, fechando com Escape.

app/…/page.go
ui.Row(
	ui.Tooltip("Só quem escreveu vê os rascunhos",
		ui.Button(ui.Outline(), h.Text("Rascunhos"))),
	ui.Pagination(ui.Pages{
		Page: 4, Total: 12,
		Href: func(n int) string { return "?pagina=" + strconv.Itoa(n) },
		Prev: "Anterior", Next: "Próxima", Label: "Paginação",
	}),
)
resultado

A moldura de um app interno#

O ui.Shell é a barra lateral, o topo e o menu de quem está logado, escritos uma vez só. O item cujo Href é o prefixo mais longo de Current ganha aria-current="page" — um match exato sempre vence, então /items/42/edit acende /items, não /. O ui.PageHeader é o título da tela dentro dele, com a volta e as ações da tela. Veja Shell para Hide, a barra que colapsa e o IconNode.

app/…/page.go
ui.Shell(c, ui.ShellOpts{
	Brand:   h.Text("Acervo"),
	Current: "/documentos",
	Nav: []ui.NavGroup{
		{Label: "Trabalho", Items: []ui.NavItem{
			{Href: "/painel", Label: "Painel", Icon: "house"},
			{Href: "/documentos", Label: "Documentos", Badge: "12"},
		}},
	},
	User: ui.UserMenu{Name: "Ana Paula", Detail: "ana@acervo.com", Items: []h.Node{
		ui.MenuLink("/perfil", h.Text("Perfil")),
	}},
},
	ui.PageHeader("Documentos",
		ui.ButtonLink("/documentos/novo", ui.Icon("plus"), h.Text("Novo documento"))),
)
resultado
Acervo

Documentos

Onde você está, e quem está logado#

O ui.Breadcrumb desenha a trilha com links de verdade, com a página atual num <span aria-current="page"> em vez de um link para ela mesma. O ui.Avatar cai para as iniciais quando não há foto.

app/…/page.go
ui.Row(
	ui.Breadcrumb(
		ui.Crumb{Label: "Documentos", Href: "/documentos"},
		ui.Crumb{Label: "Contrato 41"},
	),
	ui.Spacer(),
	ui.Avatar("AP", ""),
)
resultado
AP

Um menu que abre uma lista pequena de ações é o ui.MenuTrigger e o ui.Menu dividindo um id, sobre o atributo popover do próprio navegador — sem script nenhum.

app/…/page.go
ui.Row(
	ui.MenuTrigger("acoes", ui.Outline(), ui.Icon("settings"), h.Text("Ações")),
	ui.Menu("acoes",
		ui.MenuItem(h.Text("Duplicar")),
		ui.MenuItem(h.Text("Arquivar")),
		ui.MenuLink("/documentos/41/editar", h.Text("Editar")),
	),
)
resultado

Mais conteúdo atrás de um clique#

O ui.Collapsible é um <details> com estilo: nenhum script decide se está aberto, o navegador já faz isso.

app/…/page.go
ui.Stack(
	ui.Collapsible("Como funciona o repasse?",
		h.P(h.Text("O pedido passa para outro atendente sem perder o histórico."))),
	ui.Collapsible("Dá para reabrir um pedido fechado?",
		h.P(h.Text("Sim, em até 7 dias após o fechamento."))),
)
resultado
Como funciona o repasse?

O pedido passa para outro atendente sem perder o histórico.

Dá para reabrir um pedido fechado?

Sim, em até 7 dias após o fechamento.

O ui.Tabs funciona igual onde quer que apareça — as setas e Home/End movem a seleção, a primeira aba começa aberta:

app/…/page.go
ui.Tabs("config",
	ui.Tab{Label: "Geral", Content: h.P(h.Text("Nome, fuso horário, idioma."))},
	ui.Tab{Label: "Notificações", Content: h.P(h.Text("E-mail e push, por evento."))},
	ui.Tab{Label: "Segurança", Content: h.P(h.Text("Senha e sessões abertas."))},
)
resultado

Nome, fuso horário, idioma.

Os blocos com que o layout é construído#

ui.Row, ui.Stack e ui.Grid são <div>s com uma classe cada — linha, coluna, grade responsiva — e ui.Separator é o traço entre seções de uma tela.

app/…/page.go
ui.Stack(
	ui.Row(ui.H3(h.Text("Resumo do mês")), ui.Spacer(), ui.Muted(h.Text("Setembro"))),
	ui.Separator(),
	ui.Grid(
		ui.Card(ui.CardContent(ui.Muted(h.Text("Pedidos")), h.P(h.Text("128")))),
		ui.Card(ui.CardContent(ui.Muted(h.Text("Receita")), h.P(h.Text("R$ 42.300")))),
		ui.Card(ui.CardContent(ui.Muted(h.Text("Em aberto")), h.P(h.Text("9")))),
	),
)
resultado

Resumo do mês

Setembro

Pedidos

128

Receita

R$ 42.300

Em aberto

9

Antes do dado chegar#

O ui.Skeleton é a forma do que está por vir; o ui.Progress é uma barra numa posição conhecida. Nenhum dos dois precisa de script — o ui.Defer, mais adiante, é o que troca um skeleton pelo conteúdo de verdade.

app/…/page.go
ui.Stack(
	ui.Progress(7, 10),
	ui.Skeleton(h.StyleAttr("height: 1.2rem")),
	ui.Skeleton(h.StyleAttr("height: 1.2rem; width: 70%")),
)
resultado

Uma tecla, e um trecho#

ui.Kbd e ui.Code ficam em linha com a frase ao redor.

app/…/page.go
h.P(
	h.Text("Abra a busca com "), ui.Kbd("⌘"), ui.Kbd("K"),
	h.Text(", ou rode "), ui.Code("trilha dev"), h.Text(" no terminal."),
)
resultado

Abra a busca com ⌘K, ou rode trilha dev no terminal.

Um valor do enum, como emblema#

ui.Status(enum, valor) lê o rótulo e o tom que um trilha.Enum declarou uma vez só — a mesma declaração que as opções de um <select> e a validação de um formulário já usam. Um valor que o enum não conhece mais renderiza discreto, não em branco.

app/…/page.go
var Status = trilha.Enum{
	{Value: "enviado", Label: "Enviado", Tone: "info"},
	{Value: "entregue", Label: "Entregue", Tone: "success"},
	{Value: "cancelado", Label: "Cancelado", Tone: "danger"},
}

ui.Row(
	ui.Status(Status, "enviado"),
	ui.Status(Status, "entregue"),
	ui.Status(Status, "cancelado"),
)
resultado
EnviadoEntregueCancelado

Nada para mostrar, e o que não deu para carregar#

O ui.Empty é a tela sem nada nela: ícone, título, uma dica do que fazer a seguir e uma saída. O ui.EmptyError é a mesma forma para uma tela que falhou ao carregar — o erro em si só aparece em desenvolvimento, nunca na tela de quem está visitando.

app/…/page.go
ui.Row(
	ui.Empty(ui.EmptyOpts{
		Icon:   "info",
		Title:  "Nenhum documento ainda",
		Hint:   "Envie o primeiro PDF e a classificação começa sozinha.",
		Action: ui.ButtonLink("/documentos/novo", h.Text("Enviar documento")),
	}),
	ui.EmptyError(c, "Não deu para carregar os documentos", err,
		ui.ButtonLink("/documentos", h.Text("Tentar de novo"))),
)
resultado

Nenhum documento ainda

Envie o primeiro PDF e a classificação começa sozinha.

Um formulário em várias telas#

O ui.Steps desenha onde alguém está: o que ficou para trás linka de volta, o que está à frente é texto simples, e a etapa atual carrega aria-current="step". O estado entre as telas é o Ctx.Draft — veja o formulário em etapas.

app/…/page.go
ui.Steps([]ui.Step{
	{Label: "Arquivo", Href: "/importar/1"},
	{Label: "Mapeamento", Href: "/importar/2"},
	{Label: "Confirmação"},
}, 2)
resultado
  1. Arquivo
  2. Mapeamento
  3. Confirmação

A parte lenta, um instante depois#

Um painel que precisa de sete consultas não deveria segurar a página inteira pela que demora dois segundos. O ui.Defer desenha um placeholder agora e pede o fragmento assim que a página carregou; veja Fragmentos vivos para a rota que ele espera do outro lado.

app/…/page.go
ui.Container(
	ui.Grid(estatisticasRapidas...),
	ui.Defer(c, "insights", "/painel/insights", ui.DeferOpts{Height: "8rem"}),
	ui.LiveScript(c),
)
// /painel/insights responde Ctx.Fragment com um elemento id="insights";
// ui.LiveScript(c) é o que liga a troca automática.
resultado

Um arquivo ao lado dos seus metadados#

O ui.Preview mostra uma imagem como <img> que abre em tamanho cheio, um documento que o navegador desenha embutido, ou — quando o tipo não dá para mostrar no lugar — um cartão com botão de download em vez de um quadro em branco.

app/…/page.go
ui.Row(
	ui.Preview(c, logoURL, ui.PreviewOpts{Title: "Logo", Type: "image/png"}),
	ui.Preview(c, "/anexos/relatorio.zip", ui.PreviewOpts{
		Title: "Relatório", Type: "application/zip", NoOpen: true,
	}),
)
resultado
Relatório

This file cannot be previewed

A browser would run it instead of showing it, so it is offered as a download.

O que acontece durante uma troca#

O ui.Indicator(id) marca qualquer coisa — um emblema, um spinner — para aparecer só enquanto o alvo daquele id estiver esperando além do limite do ui.PendingAfter (120 ms por padrão), e o ui.NoTransition() desliga o esmaecimento cruzado de um gatilho que dispara com frequência, como uma busca ao vivo.

app/…/page.go
ui.Row(
	ui.ButtonLink("/pedidos/atualizar", ui.Swap("lista"), ui.PendingAfter(150), ui.NoTransition(),
		h.Text("Atualizar")),
	ui.Spinner(ui.Indicator("lista")),
)
// ui.Spinner(ui.Indicator("lista")) só aparece enquanto o alvo "lista" estiver
// esperando além do limite de ui.PendingAfter; ui.NoTransition desliga o
// esmaecimento cruzado deste gatilho.
resultado

Tabelas que vivem na URL#

O ui.DataTable é a tela que todo app de gestão tem: filtro em cima, tabela no meio, paginação no rodapé, e todo o estado — página, ordem, busca — no endereço. Veja Listagens para o trilha.ListParams, a peça que o lê de volta.

app/…/page.go
ui.DataTable(c, ui.Columns[Documento]{
	{Key: "nome", Label: "Arquivo", Sort: true, Cell: func(d Documento) h.Node { return h.Text(d.Nome) }},
	{Key: "tipo", Label: "Tipo", Cell: func(d Documento) h.Node { return h.Text(d.Tipo) }},
	{Key: "tamanho", Label: "Tamanho", Sort: true, Num: true, Cell: func(d Documento) h.Node { return h.Text(d.Tamanho) }},
}, docs, ui.ListState{
	Params: q.ListParams, Total: total, Search: "Buscar arquivos",
	RowHref: func(i int) string { return "/documentos/" + docs[i].Slug },
})
// docs, total := repo.Listar(c.Context(), q.Sort, q.Asc(), q.Offset(), q.Limit(), q.Q)
resultado
Usar este padrão — list-with-filter

Uma listagem que alguém busca, filtra, ordena e pagina, com tudo isso no endereço.

Componentes

ui.DataTable ui.Columns ui.ListState ui.Select ui.SelectOptions ui.Option ui.Badge ui.Empty ui.EmptyOpts ui.PageHeader ui.Stack

Dados de que precisa

type Query struct{ trilha.ListParams; Status string `form:"status"` } bound by c.Bind; Orders(c, q) ([]Order, int, error) answers one page and the total from your store, ordering only by the columns marked Sort.

Acessibilidade

  • A tabela tem uma legenda (ListState.Caption) que o leitor de tela anuncia.
  • O select do filtro leva um aria-label, já que não tem rótulo visível.
  • Ordenar e paginar são links de verdade: funcionam pelo teclado e sem JavaScript.

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

// Package listwithfilter is the list-with-filter pattern: a sortable table, a
// search box and one filter, paged — all of it in the address.
package listwithfilter

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

// Order is one row. Replace it with your type.
type Order struct {
	ID, Customer, Status string
	Total                float64
}

// Query is what the address says: the listing plus this screen's filter.
type Query struct {
	trilha.ListParams
	Status string `form:"status"`
}

// Orders is where the rows come from — your store, filtered, ordered and
// paged. It answers one page and the total.
var Orders = func(c *trilha.Ctx, q Query) ([]Order, int, error) { return nil, 0, nil }

var columns = ui.Columns[Order]{
	{Key: "customer", Label: "Customer", Sort: true, Cell: func(o Order) h.Node { return h.Text(o.Customer) }},
	{Key: "status", Label: "Status", Cell: func(o Order) h.Node { return ui.Badge(ui.Outline(), h.Text(o.Status)) }},
	{Key: "total", Label: "Total", Sort: true, Num: true, Cell: func(o Order) h.Node { return h.Textf("%.2f", o.Total) }},
}

// Page answers the whole screen, or only the table when the kit asks for it.
func Page(c *trilha.Ctx) (h.Node, error) {
	var q Query
	if err := c.Bind(&q); err != nil {
		return nil, err
	}
	if q.Status != "open" && q.Status != "paid" {
		q.Status = "" // only a value this screen offers reaches the store
	}
	rows, total, err := Orders(c, q)
	if err != nil {
		return nil, err
	}
	status := ui.Select(h.Name("status"), h.Aria("label", "Status"), ui.SelectOptions([]ui.Option{
		{Value: "", Label: "All"}, {Value: "open", Label: "Open"}, {Value: "paid", Label: "Paid"}}, q.Status))
	table := ui.DataTable(c, columns, rows, ui.ListState{Params: q.ListParams, Total: total, ID: "orders",
		Search: "Search customers", Filters: status, Caption: "Orders", Cards: true,
		Empty: ui.Empty(ui.EmptyOpts{Title: "No orders match"})})
	if c.Fragment() == "orders" {
		return table, nil
	}
	return ui.Stack(ui.PageHeader("Orders"), table), nil
}

trilha ui patterns list-with-filter · get_pattern {"name":"list-with-filter"}

Vazia, e filtrada até vazia#

Uma lista sem nada dentro e uma lista que uma busca não encontrou nada são duas telas diferentes; o ui.DataTable já faz essa distinção sozinho, em inglês, e o ListState.Empty é como uma aplicação diz isso no seu próprio idioma.

app/…/page.go
// mesma chamada nos dois casos — o kit lê q.ListParams.Q e escolhe a mensagem:
ui.DataTable(c, cols, docs, ui.ListState{Params: q.ListParams, Total: total, Search: "Buscar arquivos"})
// GET /documentos                    → "Nothing here yet" (o texto do kit é inglês)
// GET /documentos?q=invoice-9999     → "No results for “invoice-9999”"
//
// para dizer isso em português, ListState.Empty substitui os dois:
ui.DataTable(c, cols, docs, ui.ListState{
	Params: q.ListParams, Total: total, Search: "Buscar arquivos",
	Empty: vazioPara(q.Q), // "Nada por aqui ainda" ou "Nenhum resultado para “%s”"
})
resultado
Arquivo

Nada por aqui ainda

0 resultados
Arquivo

Nenhum resultado para “invoice-9999”

Tente outro termo, ou limpe a busca.

0 resultados

Uma hierarquia que abre nó a nó#

O ui.Tree é um plano de classificação, uma árvore de pastas, um organograma: cada nó é um <details>, então abre sem script nenhum. O ui.TreePicker é a mesma árvore como campo de formulário — veja Árvores.

app/…/page.go
ui.Tree(ui.TreeOpts{
	Nodes:   plano.Raizes(atual),  // com o caminho até o nó atual já aberto
	Label:   "Classificação",
	Current: atual,
})

ui.Field("codigo", "Classificação", ui.TreePicker(ui.TreePickerOpts{
	Name:  "codigo",
	Value: atual,
	Nodes: plano.Raizes(atual),
	Label: "Classificação",
}))
resultado
100 Administrativo
100.1 Orçamento
100.2 Pessoal
200 Jurídico

Um campo que busca enquanto você digita#

O ui.Combobox é um campo de texto que o servidor busca — uma classificação entre centenas, uma cidade entre milhares — sem viagem ao servidor para uma lista curta o bastante para filtrar no navegador. Veja Combobox.

app/…/page.go
ui.Field("cidade", "Cidade", ui.Combobox(ui.ComboboxOpts{
	Name: "cidade_id", Value: doc.CidadeID, Label: doc.CidadeNome,
	Options:     cidades, // lista curta: filtra no navegador
	Placeholder: "Buscar uma cidade",
}))
resultado

Uma caixa, vários tipos de coisa#

O ui.SearchBox e o ui.SearchResults são a busca da barra de cima, sobre um trilha.Search — um índice, vários tipos, resultado agrupado e contado. Veja Search.

app/…/page.go
var Busca = trilha.NewSearch(trilha.SearchOpts{Store: trilha.SearchMemory()}).
	Kind("documento", trilha.KindOpts{Label: "Documentos"}).
	Kind("pessoa", trilha.KindOpts{Label: "Pessoas"})

ui.SearchBox(c, "/busca", ui.SearchBoxOpts{Value: c.Query("q")})

res, _ := Busca.Query(c, c.Query("q"), trilha.SearchQuery{Limit: 10})
ui.SearchResults(c, res, ui.SearchResultsOpts{})
resultado

Documentos1

Pessoas1

A mesma tela, dois idiomas#

O ui.Date, o ui.Bytes, o ui.Duration e o ui.Number leem o Config.Locale do Ctx — a palavra muda, a chamada não. Veja Formatação.

app/…/page.go
ui.Date(c, doc.CriadoEm)                 // 08/09/2026 12:04
ui.Date(c, doc.CriadoEm, ui.Relative())  // há 3 min
ui.Bytes(c, doc.Tamanho)                 // 1,4 MB
ui.Duration(c, tarefa.Decorrido)         // 2 h 5 min
ui.Number(c, total)                      // 12.345
ui.Number(c, preco, ui.Decimals(2))      // 1.234,56
// o mesmo código, com cfg.Locale = "en" em vez de "pt-BR", escreve a coluna ao lado
resultado
Config.Localeenpt-BR
Data
Somente a data
Somente a hora
Relativo
Bytes1.4 MB1,4 MB
Duração2 h 5 min2 h 5 min
Número12,34512.345
Número, 2 casas12,345.6812.345,68

Quatro números e os desenhos ao lado#

O ui.Stat, o ui.Bars, o ui.Sparkline e o ui.Donut desenham um painel no servidor, em SVG, sem biblioteca de gráfico. Veja Gráficos.

app/…/page.go
ui.Grid(
	ui.Card(ui.CardContent(ui.Stat("Aberto", "1.204", ui.StatHint("+38 nesta semana")), ui.Sparkline(semana, ui.SparkOpts{}))),
	ui.Card(ui.CardHeader(ui.CardTitle("Documentos por tipo")), ui.CardContent(ui.Bars(dados, ui.ChartTitle("Documentos por tipo")))),
	ui.Card(ui.CardHeader(ui.CardTitle("Parte do mês")), ui.CardContent(ui.Donut(dados, ui.ChartTitle("Parte do mês")))),
	ui.Card(ui.CardContent(ui.Stat("Resposta média", "2 h 5 min"), ui.SparklineTitle(semana, ui.SparkOpts{Width: 160}, ui.ChartTitle("Tempo de resposta, 7 dias")))),
)
resultado
Aberto1.204+38 nesta semana

Documentos por tipo

Documentos por tipoDocumentosDocumentos: 1.2041.204ContratosContratos: 340340FaturasFaturas: 512512RelatóriosRelatórios: 8888
Documentos por tipo
Documentos1.204
Contratos340
Faturas512
Relatórios88

Parte do mês

Parte do mêsDocumentos: 1.204Contratos: 340Faturas: 512Relatórios: 88
  • Documentos1.204
  • Contratos340
  • Faturas512
  • Relatórios88
Parte do mês
Documentos1.204
Contratos340
Faturas512
Relatórios88
Resposta média2 h 5 min
Tempo de resposta, 7 dias
Tempo de resposta, 7 dias
118
222
319
427
524
631
729
Usar este padrão — dashboard-chart

Os números em cima e os desenhos ao lado, SVG feito no servidor sem biblioteca de gráfico.

Componentes

ui.Stat ui.SparklineTitle ui.SparkOpts ui.ChartTitle ui.Bars ui.Donut ui.Datum ui.Card ui.CardHeader ui.CardTitle ui.CardContent ui.Grid ui.Cols

Dados de que precisa

Load(c) (Numbers, error): the formatted totals, []ui.Datum per category and the weekly []float64 — counted by your store, not by the page.

Acessibilidade

  • Todo gráfico leva um ui.ChartTitle, que vira o nome acessível do SVG.
  • Os valores estão também no texto do gráfico, então os números são lidos e não só desenhados.
  • Os cartões viram uma coluna só numa tela estreita (ui.Cols(1, 2)).

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

// Package dashboardchart is the dashboard-chart pattern: the numbers on top,
// the drawings beside them — SVG from the server, no chart library, readable
// without JavaScript.
package dashboardchart

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

// Numbers is what the dashboard shows. The strings are already formatted:
// the store counts, the page does not.
type Numbers struct {
	Open, Paid string
	ByType     []ui.Datum
	Weekly     []float64
}

// Load computes the numbers — your store, one query per card at most.
var Load = func(c *trilha.Ctx) (Numbers, error) { return Numbers{}, nil }

// Page renders the dashboard.
func Page(c *trilha.Ctx) (h.Node, error) {
	n, err := Load(c)
	if err != nil {
		return nil, err
	}
	return ui.Stack(
		ui.PageHeader("Dashboard"),
		ui.Grid(ui.Cols(1, 2),
			ui.Card(ui.CardContent(ui.Stat("Open orders", n.Open,
				ui.SparklineTitle(n.Weekly, ui.SparkOpts{Width: 160}, ui.ChartTitle("Open orders, last 7 days"))))),
			ui.Card(ui.CardContent(ui.Stat("Paid this month", n.Paid))),
		),
		ui.Grid(ui.Cols(1, 2),
			ui.Card(ui.CardHeader(ui.CardTitle("Orders by type")),
				ui.CardContent(ui.Bars(n.ByType, ui.ChartTitle("Orders by type")))),
			ui.Card(ui.CardHeader(ui.CardTitle("Share by type")),
				ui.CardContent(ui.Donut(n.ByType, ui.ChartTitle("Share by type")))),
		),
	), nil
}

trilha ui patterns dashboard-chart · get_pattern {"name":"dashboard-chart"}

Uma célula que se atualiza sozinha#

O ui.Poll pergunta de novo pela rota de um fragmento, no relógio; o ui.Live e o ui.On fazem isso quando o servidor avisa, em vez disso. Veja Fragmentos vivos.

app/…/page.go
h.Div(h.ID("fila"), ui.Poll("6s", "/tarefas/fila/status"),
	ui.Stat("Tarefas na fila", "3"),
)
// GET /tarefas/fila/status responde Ctx.Fragment com o mesmo id do elemento;
// ui.LiveScript(c) no layout é o que liga a troca automática.
ui.On("fila:mudou", "/tarefas/fila/status")  // também acorda por evento
ui.Live("/eventos")                          // aberto uma vez, no layout
resultado
Tarefas na fila3atualizado há 6 s

Atualizar e customizar#

  • trilha ui regrava ui.css e ui.js quando você atualiza o Trilha; nunca toca em ui.theme.css. Se você editou ui.css, ele avisa e só sobrescreve com --force.
  • Para mudar um componente, edite ui.css (ele é seu) ou sobreponha em style.css. Para um componente novo, escreva a função no seu pacote: func Preco(v int) h.Node { return h.Span(h.Class("ui-badge preco"), …) }.
  • Ícones: ui.Icon("check"), um conjunto pequeno do Lucide (ISC). ui.Icons() lista os nomes. Para outros, cole o SVG num h.Raw seu.

Desafio#

Faça um formulário de cadastro em que o campo "Empresa" só aparece quando "Tipo" é "Jurídica" e, ao enviar sem preencher, o erro apareça no campo e um aviso some após 3 s.

Mostrar solução
func Page(c *trilha.Ctx) (h.Node, error) {
	erro := c.Query("erro")
	return h.Form(h.Method("post"), h.Class("ui-stack"), trilha.CSRFInput(c),
		ui.Field("tipo", "Tipo", ui.Select(h.ID("tipo"), h.Name("tipo"),
			h.Option(h.Value("pf"), h.Text("Física")), h.Option(h.Value("pj"), h.Text("Jurídica")))),
		ui.Field("empresa", "Empresa", ui.Input(h.ID("empresa"), h.Name("empresa"), h.If(erro != "", ui.Invalid())),
			ui.Error(erro), ui.With(ui.ShowWhen("tipo", "pj"))),
		ui.Submit(h.Text("Cadastrar")),
		h.If(erro != "", ui.Toaster(ui.Toast("error", erro, 3000))),
	), nil
}

func POST(c *trilha.Ctx) error {
	if c.Form("tipo") == "pj" && strings.TrimSpace(c.Form("empresa")) == "" {
		return c.Redirect("/cadastro?erro=Empresa+obrigat%C3%B3ria")
	}
	return c.Redirect("/cadastro/ok")
}

O que vem depois: as telas prontas#

O catálogo acima são as peças. Telas prontas é uma tela montada com elas, de ponta a ponta — a moldura, os números, a tabela que vive na URL, a parte lenta, o arquivo, o formulário em etapas —, com o motivo de cada peça estar onde está.