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` atualizaO 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ó.
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")),
)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.
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"))),
)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.
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.
Cards, abas, progresso#
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"))),
)Meta do mês
7 de 10 posts
Faltam 3.
Abas com teclado e ARIA.
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.
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().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.
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")))),
),
)| Conta | Orçado | Realizado |
|---|---|---|
| Despesas | 12.000 | 11.240 |
| Pessoal | 8.000 | 8.000 |
| Marketing | 4.000 | 3.240 |
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.
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",
}),
)Atualizar e customizar#
trilha uiregravaui.csseui.jsquando você atualiza o Trilha; nunca toca emui.theme.css. Se você editouui.css, ele avisa e só sobrescreve com--force.- Para mudar um componente, edite
ui.css(ele é seu) ou sobreponha emstyle.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 numh.Rawseu.
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")
}