Tucano v0.37.2

Tabla

Lista renderizada por el servidor. El <table> de la plantilla sigue siendo la fuente de la verdad y la celda es libre. El JavaScript entra solo donde el HTML no llega: ordenar por columna y marcar filas en bloque.

Ninguna fila marcada.
Cliente Estado Vencimiento Importe Acciones
CVConstructora ValleNIF B12345678 En revisión 12/09/2026 R$ 12.400,00
ASAgropecuaria SierraNIF B98765432 Aprobado 03/07/2026 R$ 3.890,50
CHCarpintería HorizonteNIF B45678901 Vencido 28/11/2026 R$ 760,00
TATransportes AuroraNIF B23456789 Nuevo 19/02/2026 R$ 45.120,90

Esta demostración ordena en pantalla (data-sort-mode="client") porque no hay servidor detrás. En una lista paginada, lo predeterminado es el servidor — mira Ordenación.

Ejemplos

Las variantes son clases en la propia tabla. Por defecto las filas se separan con un borde inferior, y no con cebra: en una lista de treinta filas la cebra se vuelve ruido. Quien quiera cebra, que la pida.

Cebreada

is-striped pinta las filas pares con --tuc-elevated.

ProductoStock
Tornillo 6 mm1.240
Tuerca hexagonal860
Arandela plana3.105
Taco 8 mm412

Con cuadrícula

is-bordered cierra la tabla y separa las columnas.

MesIngresosGastos
JulioR$ 48.200R$ 31.900
AgostoR$ 52.750R$ 33.400
SeptiembreR$ 50.100R$ 35.020

Compacta

is-compact reduce el espaciado de la celda, para listas largas.

CódigoDescripciónEstado
NF-1021Servicio de mantenimientoPagada
NF-1022Cambio de piezasPendiente
NF-1023Visita técnicaBorrador
NF-1024InstalaciónCancelada

Estado vacío

La tabla sin filas sigue siendo una tabla: una sola celda, con tuc-table__empty.

ClienteVencimientoImporte
Ningún contrato encontrado con esos filtros.

Encabezado fijo

is-sticky va en el contenedor, que recibe altura máxima para tener contra qué pegarse.

HoraEvento
08:00Apertura de caja
08:12Venta 3391
08:40Venta 3392
09:05Retiro de efectivo
09:31Venta 3393
10:02Venta 3394
10:48Devolución 88
11:20Venta 3395

Usuario y segunda línea

Foto o iniciales, nombre y una línea discreta debajo. Con ancho en la columna, la segunda línea se corta con puntos suspensivos.

PersonaRol
ALAna Limaana.lima@constructoravalle.esAdministradora
RSRafael Souzarafael@agrosierra.esFinanzas

Cómo usar

Marca la <table> con data-tuc-table y se inicializa sola, incluida la que llegue después por HTMX. En cada columna ordenable, data-sort indica el tipo y data-field indica el nombre que va en la 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">Importe</th>
    <th data-sort="none" class="tuc-table__actions">Acciones</th>
  </tr></thead>
  <tbody>
    <tr data-id="12">
      <td>Constructora Valle</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>

En la plantilla de Django

El bucle es el de siempre. El data-id de la fila es lo que envía la selección múltiple, y el {% empty %} dibuja el estado vacío.

<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">Estado</th>
    <th data-sort="number" data-field="amount" class="is-number">Importe</th>
    <th data-sort="none" class="tuc-table__actions">Acciones</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">Ningún contrato encontrado.</td></tr>
    {% endfor %}
  </tbody>
</table>

El colspan cuenta la columna de selección

Con data-selectable el script añade una columna al principio de cada fila, así que la celda vacía tiene que cubrir una columna más que el encabezado escrito en la plantilla. Por el mismo motivo, la fila vacía recibe una casilla sin valor; si eso molesta, renderiza la tabla sin data-selectable cuando no haya filas.

En JavaScript

Para pasar onSort y onSelect, que no existen como atributo, o para leer la selección.

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

Ordenación

Ordenar es trabajo del servidor, y eso es lo predeterminado. En una lista paginada, reordenar las veinte filas que están en pantalla produce un orden falso: el mayor importe real puede estar en la página 7, y la tabla pasa a mentir con cara de verdad. Quien sabe ordenar el conjunto entero es la base de datos.

Por eso, en el modo servidor, el encabezado se convierte en un <a> a la misma URL con ?sort= y ?dir=. Funciona sin nada de JavaScript, funciona con el hx-boost de HTMX, se abre en otra pestaña y vuelve con el botón del navegador. La flecha sale de la query string, así que sigue siendo correcta después de recargar. El enlace conserva el resto de la query string — filtro y búsqueda no se pierden — y quita el page: seguir en la página 7 con otro orden mostraría un trozo del medio de una lista que nadie vio.

Cliente Vencimiento Importe
Estudio Aurora05/10/2026R$ 2.300,00
Panadería Pan Caliente21/08/2026R$ 480,00
Clínica Bienestar01/12/2026R$ 9.150,00

Aquí los encabezados son los enlaces del modo servidor — pasa el puntero para ver el href. Un onSort cancela la navegación y simula la respuesta; en tu proyecto el clic carga la página ordenada.

En la vista

?sort y ?dir entran en el order_by() antes del Paginator, que es exactamente donde tiene que ocurrir la ordenación. Traduce el nombre de la URL con una lista cerrada: pasar el parámetro directo a order_by() dejaría a cualquiera ordenar por cualquier campo, y un nombre inexistente tumba la vista con 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: sin un orden total, la misma fila puede aparecer en dos 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>

La paginación hace lo mismo desde el otro lado: el enlace de página conserva sort y dir, así que pasar de página no deshace el orden.

Tipos y valor crudo

AtributoDóndePara qué
data-sort="text"<th>Texto, comparado en pt-BR, sin distinguir mayúsculas y con números en orden natural
data-sort="number"<th>Número; acepta 1.234,50 e ignora símbolos como R$
data-sort="date"<th>Fecha, leída por new Date() — usa un valor ISO
data-sort="none" o ausente<th>Columna sin ordenación
data-field<th>Nombre que va en ?sort=; sin él vale el índice de la columna
data-sort-value<td>Valor crudo, cuando el texto mostrado no ordena bien

El valor crudo importa en el modo cliente: 12/09/2026 no es una fecha que el navegador lea en el formato brasileño, y "hace 3 días" o "En revisión" no ordenan como texto. Escribe el valor mostrado para la persona y la fecha ISO o el número en data-sort-value.

En pantalla, para tabla pequeña

data-sort-mode="client" existe para el otro caso: tabla pequeña y completa, sin paginación, donde ordenar en pantalla es lo correcto. Ahí el encabezado se convierte en <button>, porque no hay adónde navegar, y el aria-sort se actualiza con cada clic.

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

Con HTMX

Como el encabezado y la paginación son enlaces de verdad, el hx-boost en un ancestro basta: HTMX cambia el contenido sin recargar, la URL acompaña y la tabla nueva se inicializa sola en el 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 la navegación por tu cuenta, pasa onSort: con él definido, el clic se cancela y quien lo escribió toma el control. El evento tucano:sort se dispara igualmente, antes de la navegación.

new Tucano.Table('#contracts', {
  onSort: ({ column, field, direction }, table) => {
    htmx.ajax('GET', `?sort=${field}&dir=${direction}`, '#list');
  },
});
AtributoPor defectoPara qué
data-sort-modeserverclient ordena en pantalla
data-sort-paramsortNombre del parámetro del campo en la URL
data-dir-paramdirNombre del parámetro de la dirección; los valores son asc y desc
data-sortabletruefalse desactiva la ordenación de toda la tabla

Selección múltiple

La selección sigue siendo un formulario. Con data-selectable, cada fila recibe un <input type="checkbox" name="selected"> de verdad con su data-id como valor — en Django llega como request.POST.getlist("selected"), sin JavaScript de por medio.

<form method="post" action="{% url 'contracts-approve' %}">
  {% csrf_token %}
  <table data-tuc-table data-selectable>...</table>
  <button class="tuc-btn is-primary">Aprobar seleccionados</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")

Un botón de acción dentro del formulario lleva type="button"

Un <button> sin tipo dentro de un <form> envía el formulario. En la columna de acciones, escribe type="button" o usa <a>; si no, "Editar" se convierte en "Aprobar seleccionados".

La casilla del encabezado marca y desmarca la página entera y muestra el estado mixto cuando solo una parte está marcada. Cada cambio dispara tucano:select en la tabla, con los valores marcados — los mismos que el formulario enviaría.

document.querySelector('#contracts').addEventListener('tucano:select', (e) => {
  e.detail.selected;   // ['1', '3']
  e.detail.row;        // la <tr> que cambió
});
AtributoPor defectoPara qué
data-selectabledesactivadoActiva la columna de selección
data-select-nameselectedEl name de las casillas en el POST
data-id en la <tr>—El valor de la casilla de esa fila; sin él la casilla va vacía

La celda es libre

Dentro del <td> cabe lo que quieras. Lo que ofrece la biblioteca son las disposiciones que aparecen en todo listado, para que nadie tenga que reinventar la alineación de un avatar junto a un nombre.

ClaseQué hace
.tuc-table__userLínea con avatar y texto, alineados al centro
.tuc-table__avatarCírculo de 32px con iniciales en mayúsculas; con <img> dentro, la foto cubre el círculo
.tuc-table__subSegunda línea discreta: correo bajo el nombre, código bajo el título
.tuc-badgeEtiqueta de estado: is-success, is-warning, is-danger, is-info; sin tono queda neutra
.tuc-table__actionsColumna pegada a la derecha, sin salto de línea, para los botones del sistema — tuc-btn is-outline is-icon is-sm
.is-numberEn th y td: alinea a la derecha con dígitos de ancho fijo, para comparar valores de un vistazo
data-tuc-format="brl"En la celda de importe: el 12400.00 que imprime Django se convierte en R$ 12.400,00. Acepta punto o coma como decimal; data-tuc-format="currency" formatea sin el símbolo
.tuc-table__emptyCelda única del estado vacío, centrada y con espacio

Una celda de tabla no se vuelve flex

display: flex en un <th> o <td> saca la celda del cálculo de columnas: deja de participar en el ancho y dibuja su propia caja encima del encabezado. Para pegar a la derecha usa text-align, como hace .tuc-table__actions; el flex va en el contenido, como en .tuc-table__user. Y los puntos suspensivos de la segunda línea solo aparecen con ancho en la columna — sin él la columna crece con el texto: <th style="width:38%">.

Si es clicable, tiene aspecto de botón

La columna de acciones usa los botones del sistema con contorno, y no iconos sueltos: sin borde y sin fondo, un icono se lee como decoración desactivada. El desplazamiento horizontal queda en el .tuc-table-wrap, que el script crea si no existe — una <table> con overflow pierde el comportamiento de tabla.

Teclado y accesibilidad

Todo lo que la tabla añade es elemento nativo: enlace, botón y casilla. Por eso cada uno ya está en el recorrido del Tab y responde a las teclas de siempre.

TeclaAcción
TabPasa por los encabezados ordenables, por las casillas y por los botones de cada fila
EnterEn el encabezado, ordena — sigue el enlace en el modo servidor
EspacioMarca o desmarca la casilla; en el modo cliente, también ordena con el botón del encabezado

El encabezado ordenado anuncia aria-sort="ascending" o "descending"; los demás, "none". La casilla del encabezado se llama "Selecionar todas as linhas desta página" y la de cada fila, "Selecionar linha"; el estado mixto es el indeterminate nativo, que el lector de pantalla anuncia. La flecha de ordenación es aria-hidden: quien informa la dirección es el atributo, y no el dibujo.

API

Generada a partir del código en cada build — si algo no está aquí, no existe.

Marcado
table[data-tuc-table]
En 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

Opciones

Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.

OpciónPor defectoPara 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