Tucano v0.37.2

Tabela

Lista renderizada pelo servidor. O <table> do template continua sendo a fonte da verdade e a célula é livre. O JavaScript entra só onde o HTML não alcança: ordenar pela coluna e marcar linhas em massa.

Nenhuma linha marcada.
Cliente Situação Vencimento Valor Ações
CVConstrutora ValeCNPJ 12.345.678/0001-90 Em análise 12/09/2026 R$ 12.400,00
ASAgropecuária SerraCNPJ 98.765.432/0001-10 Aprovado 03/07/2026 R$ 3.890,50
MHMarcenaria HorizonteCNPJ 45.678.901/0001-55 Vencido 28/11/2026 R$ 760,00
TATransportes AuroraCNPJ 23.456.789/0001-77 Novo 19/02/2026 R$ 45.120,90

Esta demonstração ordena na tela (data-sort-mode="client") porque não há servidor atrás dela. Numa lista paginada, o padrão é o servidor — veja Ordenação.

Exemplos

As variantes são classes na própria tabela. Por padrão as linhas se separam por uma borda de baixo, e não por zebra: numa lista de trinta linhas a zebra vira ruído. Quem quiser zebra pede.

Zebrada

is-striped pinta as linhas pares com --tuc-elevated.

ProdutoEstoque
Parafuso 6 mm1.240
Porca sextavada860
Arruela lisa3.105
Bucha 8 mm412

Com grade

is-bordered fecha a tabela e separa as colunas.

MêsReceitaDespesa
JulhoR$ 48.200R$ 31.900
AgostoR$ 52.750R$ 33.400
SetembroR$ 50.100R$ 35.020

Compacta

is-compact reduz o espaçamento da célula, para listas longas.

CódigoDescriçãoSituação
NF-1021Serviço de manutençãoPaga
NF-1022Troca de peçasPendente
NF-1023Visita técnicaRascunho
NF-1024InstalaçãoCancelada

Estado vazio

A tabela sem linhas continua sendo tabela: uma célula só, com tuc-table__empty.

ClienteVencimentoValor
Nenhum contrato encontrado com esses filtros.

Cabeçalho fixo

is-sticky vai no envólucro, que ganha altura máxima para ter contra o que grudar.

HorárioEvento
08:00Abertura do caixa
08:12Venda 3391
08:40Venda 3392
09:05Sangria
09:31Venda 3393
10:02Venda 3394
10:48Devolução 88
11:20Venda 3395

Usuário e segunda linha

Foto ou iniciais, nome e uma linha discreta embaixo. Com largura na coluna, a segunda linha corta com reticência.

PessoaPapel
ALAna Limaana.lima@construtoravale.com.brAdministradora
RSRafael Souzarafael@agroserra.com.brFinanceiro

Como usar

Marque a <table> com data-tuc-table e ela inicializa sozinha, inclusive a que chegar depois por HTMX. Em cada coluna ordenável, data-sort diz o tipo e data-field diz o nome que vai na URL.

<table data-tuc-table data-selectable>
  <thead><tr>
    <th data-sort="text" data-field="customer" style="width:38%">Cliente</th>
    <th data-sort="number" data-field="amount" class="is-number">Valor</th>
    <th data-sort="none" class="tuc-table__actions">Ações</th>
  </tr></thead>
  <tbody>
    <tr data-id="12">
      <td>Construtora Vale</td>
      <td class="is-number" data-sort-value="12400">R$ 12.400,00</td>
      <td class="tuc-table__actions">
        <button type="button" class="tuc-btn is-outline is-icon is-sm" aria-label="Editar">...</button>
      </td>
    </tr>
  </tbody>
</table>

No template do Django

O laço é o de sempre. O data-id da linha é o que a seleção em massa posta, e o {% empty %} desenha o estado vazio.

<table data-tuc-table data-selectable>
  <thead><tr>
    <th data-sort="text" data-field="customer" style="width:38%">Cliente</th>
    <th data-sort="text" data-field="status">Situação</th>
    <th data-sort="number" data-field="amount" class="is-number">Valor</th>
    <th data-sort="none" class="tuc-table__actions">Ações</th>
  </tr></thead>
  <tbody>
    {% for obj in page_obj %}
    <tr data-id="{{ obj.pk }}">
      <td>
        <span class="tuc-table__user">
          <span class="tuc-table__avatar"><img src="{{ obj.photo.url }}" alt=""></span>
          <span>{{ obj.name }}<span class="tuc-table__sub">{{ obj.email }}</span></span>
        </span>
      </td>
      <td><span class="tuc-badge is-success">{{ obj.get_status_display }}</span></td>
      <td class="is-number" data-sort-value="{{ obj.amount }}" data-tuc-format="brl">{{ obj.amount }}</td>
      <td class="tuc-table__actions">
        <a class="tuc-btn is-outline is-icon is-sm" href="{% url 'contract-edit' obj.pk %}" aria-label="Editar">...</a>
      </td>
    </tr>
    {% empty %}
    <tr><td colspan="5" class="tuc-table__empty">Nenhum contrato encontrado.</td></tr>
    {% endfor %}
  </tbody>
</table>

O colspan conta a coluna de seleção

Com data-selectable o script acrescenta uma coluna no começo de cada linha, então a célula vazia precisa cobrir uma coluna a mais do que o cabeçalho escrito no template. Pelo mesmo motivo, a linha vazia ganha uma caixa de seleção sem valor; se isso incomodar, renderize a tabela sem data-selectable quando não houver linhas.

Em JavaScript

Para passar onSort e onSelect, que não existem como atributo, ou para ler a seleção.

const t = new Tucano.Table('#contracts', { selectable: true, sortMode: 'client' });
t.getSelected();          // ['12', '15'] — os data-id marcados
t.clearSelection();
t.sort(2, 'desc', 'number');
t.destroy();              // solta os listeners

Ordenação

Ordenar é trabalho do servidor, e esse é o padrão. Numa lista paginada, reordenar as vinte linhas que estão na tela produz uma ordem falsa: o maior valor real pode estar na página 7, e a tabela passa a mentir com cara de verdade. Quem sabe ordenar o conjunto inteiro é o banco.

Por isso, no modo servidor, o cabeçalho vira um <a> para a mesma URL com ?sort= e ?dir=. Funciona sem JavaScript nenhum, funciona com o hx-boost do HTMX, abre em outra aba e volta pelo botão do navegador. A seta sai da query string, então continua certa depois do recarregamento. O link preserva o resto da query string — filtro e busca não se perdem — e tira o page: continuar na página 7 com outra ordem mostraria um pedaço do meio de uma lista que ninguém viu.

Cliente Vencimento Valor
Estúdio Aurora05/10/2026R$ 2.300,00
Padaria Pão Quente21/08/2026R$ 480,00
Clínica Bem-Estar01/12/2026R$ 9.150,00

Aqui os cabeçalhos são os links do modo servidor — passe o ponteiro para ver o href. Um onSort cancela a navegação e simula a resposta; no seu projeto o clique carrega a página ordenada.

Na view

?sort e ?dir entram no order_by() antes do Paginator, que é exatamente onde a ordenação tem de acontecer. Traduza o nome da URL por uma lista fechada: passar o parâmetro direto ao order_by() deixaria qualquer um ordenar por qualquer campo, e um nome inexistente derruba a view com FieldError.

from django.core.paginator import Paginator

ORDERS = {"customer": "name", "status": "status", "amount": "amount"}

def contracts(request):
    field = ORDERS.get(request.GET.get("sort"), "name")
    if request.GET.get("dir") == "desc":
        field = f"-{field}"
    # "pk" desempata: sem ordem total, a mesma linha pode aparecer em duas páginas.
    qs = Contract.objects.order_by(field, "pk")
    page_obj = Paginator(qs, 20).get_page(request.GET.get("page"))
    return render(request, "contracts/list.html", {"page_obj": page_obj})
<table data-tuc-table>...</table>
<div data-tuc-pagination
     data-page="{{ page_obj.number }}"
     data-pages="{{ page_obj.paginator.num_pages }}"></div>

A paginação faz a mesma coisa pelo outro lado: o link de página preserva sort e dir, então virar a página não desfaz a ordem.

Tipos e valor cru

AtributoOndePara quê
data-sort="text"<th>Texto, comparado em pt-BR, sem diferenciar maiúscula e com números em ordem natural
data-sort="number"<th>Número; aceita 1.234,50 e ignora símbolos como R$
data-sort="date"<th>Data, lida por new Date() — use valor ISO
data-sort="none" ou ausente<th>Coluna sem ordenação
data-field<th>Nome que vai em ?sort=; sem ele vale o índice da coluna
data-sort-value<td>Valor cru, quando o texto exibido não ordena bem

O valor cru importa no modo cliente: 12/09/2026 não é uma data que o navegador leia no formato brasileiro, e "há 3 dias" ou "Em análise" não ordenam como texto. Escreva o valor exibido para a pessoa e o ISO ou o número em data-sort-value.

Na tela, para tabela pequena

data-sort-mode="client" existe para o outro caso: tabela pequena e completa, sem paginação, onde ordenar na tela é a coisa certa. Ali o cabeçalho vira <button>, porque não há para onde navegar, e o aria-sort é atualizado a cada clique.

<table data-tuc-table data-sort-mode="client">...</table>

Com HTMX

Como o cabeçalho e a paginação são links de verdade, o hx-boost num ancestral basta: o HTMX troca o conteúdo sem recarregar, a URL acompanha e a tabela nova se inicializa sozinha no htmx:afterSwap.

<div hx-boost="true">
  <table data-tuc-table>...</table>
  <div data-tuc-pagination data-page="3" data-pages="12"></div>
</div>

Para decidir a navegação por conta própria, passe onSort: com ele definido, o clique é cancelado e quem escreveu assume. O evento tucano:sort sai de qualquer jeito, antes da navegação.

new Tucano.Table('#contracts', {
  onSort: ({ column, field, direction }, table) => {
    htmx.ajax('GET', `?sort=${field}&dir=${direction}`, '#list');
  },
});
AtributoPadrãoPara quê
data-sort-modeserverclient ordena na tela
data-sort-paramsortNome do parâmetro do campo na URL
data-dir-paramdirNome do parâmetro do sentido; os valores são asc e desc
data-sortabletruefalse desliga a ordenação da tabela inteira

Seleção em massa

A seleção continua sendo um formulário. Com data-selectable, cada linha ganha um <input type="checkbox" name="selected"> de verdade com o data-id dela como valor — no Django chega como request.POST.getlist("selected"), sem JavaScript no meio.

<form method="post" action="{% url 'contracts-approve' %}">
  {% csrf_token %}
  <table data-tuc-table data-selectable>...</table>
  <button class="tuc-btn is-primary">Aprovar selecionados</button>
</form>
from django.views.decorators.http import require_POST

@require_POST
def approve(request):
    ids = request.POST.getlist("selected")
    Contract.objects.filter(pk__in=ids).update(status="approved")
    return redirect("contracts")

Botão de ação dentro do formulário leva type="button"

Um <button> sem tipo dentro de <form> envia o formulário. Na coluna de ações, escreva type="button" ou use <a>, senão "Editar" vira "Aprovar selecionados".

A caixa do cabeçalho marca e desmarca a página inteira e mostra o estado misto quando só parte está marcada. Cada mudança dispara tucano:select na tabela, com os valores marcados — os mesmos que o formulário enviaria.

document.querySelector('#contracts').addEventListener('tucano:select', (e) => {
  e.detail.selected;   // ['1', '3']
  e.detail.row;        // a <tr> que mudou
});
AtributoPadrãoPara quê
data-selectabledesligadoLiga a coluna de seleção
data-select-nameselectedO name das caixas no POST
data-id na <tr>—O valor da caixa daquela linha; sem ele a caixa vai vazia

A célula é livre

Dentro do <td> cabe o que você quiser. O que a biblioteca oferece são os arranjos que aparecem em toda listagem, para ninguém ter de reinventar o alinhamento de um avatar ao lado de um nome.

ClasseO que faz
.tuc-table__userLinha com avatar e texto, alinhados ao centro
.tuc-table__avatarCírculo de 32px com iniciais em maiúsculas; com <img> dentro, a foto cobre o círculo
.tuc-table__subSegunda linha discreta: e-mail sob o nome, código sob o título
.tuc-badgeEtiqueta de estado: is-success, is-warning, is-danger, is-info; sem tom fica neutra
.tuc-table__actionsColuna encostada à direita, sem quebra de linha, para os botões do sistema — tuc-btn is-outline is-icon is-sm
.is-numberEm th e td: alinha à direita com dígitos de largura fixa, para comparar valores com a vista
data-tuc-format="brl"Na célula de valor: o 12400.00 que o Django imprime vira R$ 12.400,00. Aceita ponto ou vírgula como decimal; data-tuc-format="currency" formata sem o símbolo
.tuc-table__emptyCélula única do estado vazio, centrada e com respiro

Célula de tabela não vira flex

display: flex num <th> ou <td> tira a célula do cálculo de colunas: ela para de participar da largura e desenha a própria caixa por cima do cabeçalho. Para encostar à direita use text-align, como faz .tuc-table__actions; o flex fica no conteúdo, como em .tuc-table__user. E a reticência da segunda linha só aparece com largura na coluna — sem ela a coluna cresce com o texto: <th style="width:38%">.

Se é clicável, tem cara de botão

A coluna de ações usa os botões do sistema com contorno, e não ícones soltos: sem borda e sem fundo, um ícone lê como decoração desativada. A rolagem horizontal fica no .tuc-table-wrap, que o script cria se não existir — uma <table> com overflow perde o comportamento de tabela.

Teclado e acessibilidade

Tudo o que a tabela acrescenta é elemento nativo: link, botão e caixa de seleção. Por isso cada um já está no caminho do Tab e responde às teclas de sempre.

TeclaAção
TabPassa pelos cabeçalhos ordenáveis, pelas caixas e pelos botões de cada linha
EnterNo cabeçalho, ordena — segue o link no modo servidor
EspaçoMarca ou desmarca a caixa; no modo cliente, também ordena pelo botão do cabeçalho

O cabeçalho ordenado anuncia aria-sort="ascending" ou "descending"; os outros, "none". A caixa do cabeçalho se chama "Selecionar todas as linhas desta página" e cada linha, "Selecionar linha"; o estado misto é o indeterminate nativo, que o leitor de tela anuncia. A seta de ordenação é aria-hidden: quem informa o sentido é o atributo, e não o desenho.

API

Gerada do código a cada build — se algo não está aqui, não existe.

Marcação
table[data-tuc-table]
Em JS
new Tucano.Table(alvo, opcoes)
Atributos
data-dir-param data-select-name data-selectable data-sort-mode data-sort-param data-sortable
Métodos
sort getSelected clearSelection destroy
Eventos
tucano:sort tucano:select

Opções

OpçãoPadrãoPara quê
sortabletrue
sortMode'server'server | client
sortParam'sort'
dirParam'dir'
selectablefalsecoluna de selecao em massa
selectName'selected'
onSortnulldefinido, intercepta o clique e cancela a navegacao
onSelectnull