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.
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.
| Producto | Stock |
|---|---|
| Tornillo 6 mm | 1.240 |
| Tuerca hexagonal | 860 |
| Arandela plana | 3.105 |
| Taco 8 mm | 412 |
Con cuadrícula
is-bordered cierra la tabla y separa las columnas.
| Mes | Ingresos | Gastos |
|---|---|---|
| Julio | R$ 48.200 | R$ 31.900 |
| Agosto | R$ 52.750 | R$ 33.400 |
| Septiembre | R$ 50.100 | R$ 35.020 |
Compacta
is-compact reduce el espaciado de la celda, para listas largas.
| Código | Descripción | Estado |
|---|---|---|
| NF-1021 | Servicio de mantenimiento | Pagada |
| NF-1022 | Cambio de piezas | Pendiente |
| NF-1023 | Visita técnica | Borrador |
| NF-1024 | Instalación | Cancelada |
Estado vacío
La tabla sin filas sigue siendo una tabla: una sola celda, con tuc-table__empty.
| Cliente | Vencimiento | Importe |
|---|---|---|
| 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.
| Hora | Evento |
|---|---|
| 08:00 | Apertura de caja |
| 08:12 | Venta 3391 |
| 08:40 | Venta 3392 |
| 09:05 | Retiro de efectivo |
| 09:31 | Venta 3393 |
| 10:02 | Venta 3394 |
| 10:48 | Devolución 88 |
| 11:20 | Venta 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.
| Persona | Rol |
|---|---|
| ALAna Limaana.lima@constructoravalle.es | Administradora |
| RSRafael Souzarafael@agrosierra.es | Finanzas |
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 listenersOrdenació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 Aurora | 05/10/2026 | R$ 2.300,00 |
| Panadería Pan Caliente | 21/08/2026 | R$ 480,00 |
| Clínica Bienestar | 01/12/2026 | R$ 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
| Atributo | Dónde | Para 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');
},
});| Atributo | Por defecto | Para qué |
|---|---|---|
data-sort-mode | server | client ordena en pantalla |
data-sort-param | sort | Nombre del parámetro del campo en la URL |
data-dir-param | dir | Nombre del parámetro de la dirección; los valores son asc y desc |
data-sortable | true | false 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ó
});| Atributo | Por defecto | Para qué |
|---|---|---|
data-selectable | desactivado | Activa la columna de selección |
data-select-name | selected | El 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.
| Clase | Qué hace |
|---|---|
.tuc-table__user | Línea con avatar y texto, alineados al centro |
.tuc-table__avatar | Círculo de 32px con iniciales en mayúsculas; con <img> dentro, la foto cubre el círculo |
.tuc-table__sub | Segunda línea discreta: correo bajo el nombre, código bajo el título |
.tuc-badge | Etiqueta de estado: is-success, is-warning, is-danger, is-info; sin tono queda neutra |
.tuc-table__actions | Columna pegada a la derecha, sin salto de línea, para los botones del sistema — tuc-btn is-outline is-icon is-sm |
.is-number | En 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__empty | Celda ú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.
| Tecla | Acción |
|---|---|
Tab | Pasa por los encabezados ordenables, por las casillas y por los botones de cada fila |
Enter | En el encabezado, ordena — sigue el enlace en el modo servidor |
Espacio | Marca 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.
table[data-tuc-table]new Tucano.Table(alvo, opcoes)data-dir-param data-select-name data-selectable data-sort-mode data-sort-param data-sortablesort getSelected clearSelection destroytucano:sort tucano:selectOpciones
Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.
| Opción | Por defecto | Para qué |
|---|---|---|
sortable | true | |
sortMode | 'server' | server | client |
sortParam | 'sort' | |
dirParam | 'dir' | |
selectable | false | coluna de selecao em massa |
selectName | 'selected' | |
onSort | null | definido, intercepta o clique e cancela a navegacao |
onSelect | null |