Componentes · Ola 3 · Datos
Table
Datos en filas y columnas, con orden por columna, selección de filas y acciones por fila. Es un <table> nativo que se arma con
columnas y filas: los encabezados en text-muted sobre la capa base,
las cifras en mono con cifras tabulares y la fila elegida con la luz de la selección.
import { Table } from '@archeblack/ui';
Ejemplos
Proyectos
La tabla del demo: selectable con bind:selected, orden con bind:sort y sortRows, un Badge por estado y un Menu de acciones por fila con el snippet cell.
| Estado | Acciones | |||||
|---|---|---|---|---|---|---|
Portal de clientes prj_7f3a92c1 | Activo | Sudamérica | 184.302 | hace 2 min | ||
API de pagos prj_2b91d0e4 | Degradado | EE. UU. Este | 92.418 | hace 6 min | ||
Notificaciones prj_5d88f3b2 | Desplegando | EE. UU. Este | 45.910 | hace 1 min | ||
Motor de búsqueda prj_c40e7a18 | Error | Sudamérica | 12.077 | hace 14 min | ||
Panel interno prj_91aa0c67 | Detenido | Europa | 0 | hace 3 días |
<script lang="ts">
import {
Badge,
Button,
Menu,
MenuItem,
MenuSeparator,
Table,
type BadgeVariant,
type TableColumn,
type TableKey,
type TableSort
} from '@archeblack/ui';
import { IconDots, IconEdit, IconRocket, IconTrash } from '@archeblack/ui/icons';
type Status = 'active' | 'degraded' | 'error' | 'deploying' | 'stopped';
interface Project {
id: string;
name: string;
status: Status;
region: string;
requests: number;
updated: number;
}
const statuses: Record<Status, { label: string; variant: BadgeVariant }> = {
active: { label: 'Activo', variant: 'success' },
degraded: { label: 'Degradado', variant: 'warning' },
error: { label: 'Error', variant: 'danger' },
deploying: { label: 'Desplegando', variant: 'info' },
stopped: { label: 'Detenido', variant: 'neutral' }
};
const rows: Project[] = [
{
id: 'prj_7f3a92c1',
name: 'Portal de clientes',
status: 'active',
region: 'Sudamérica',
requests: 184302,
updated: 2
},
{
id: 'prj_2b91d0e4',
name: 'API de pagos',
status: 'degraded',
region: 'EE. UU. Este',
requests: 92418,
updated: 6
},
{
id: 'prj_c40e7a18',
name: 'Motor de búsqueda',
status: 'error',
region: 'Sudamérica',
requests: 12077,
updated: 14
},
{
id: 'prj_5d88f3b2',
name: 'Notificaciones',
status: 'deploying',
region: 'EE. UU. Este',
requests: 45910,
updated: 1
},
{
id: 'prj_91aa0c67',
name: 'Panel interno',
status: 'stopped',
region: 'Europa',
requests: 0,
updated: 4320
}
];
const number = new Intl.NumberFormat('es-AR');
const ago = (minutes: number) =>
minutes < 60 ? `hace ${minutes} min` : `hace ${Math.round(minutes / 1440)} días`;
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto', sortable: true },
{ key: 'status', label: 'Estado', format: (row) => statuses[row.status].label },
{ key: 'region', label: 'Región', sortable: true },
{
key: 'requests',
label: 'Solicitudes (24 h)',
numeric: true,
sortable: true,
format: (row) => number.format(row.requests)
},
{ key: 'updated', label: 'Actualizado', sortable: true, format: (row) => ago(row.updated) },
{ key: 'actions', label: 'Acciones', hideLabel: true, align: 'end', width: '3rem' }
];
let sort = $state<TableSort>({ key: 'requests', direction: 'descending' });
let selected = $state<TableKey[]>(['prj_2b91d0e4']);
</script>
<div style:inline-size="100%">
<Table
caption="Proyectos"
hideCaption
{columns}
{rows}
rowKey="id"
bind:sort
sortRows
selectable
bind:selected
>
{#snippet cell(row, column, text)}
{#if column.key === 'name'}
<div>{row.name}</div>
<div style:font="var(--arche-typography-code)" style:color="var(--arche-color-text-muted)">
{row.id}
</div>
{:else if column.key === 'status'}
<Badge variant={statuses[row.status].variant} dot={row.status !== 'stopped'}>{text}</Badge>
{:else if column.key === 'actions'}
<Menu align="end">
{#snippet trigger(props)}
<Button
{...props}
variant="ghost"
size="sm"
icon={IconDots}
aria-label="Acciones de {row.name}"
/>
{/snippet}
<MenuItem icon={IconEdit}>Renombrar</MenuItem>
<MenuItem icon={IconRocket}>Desplegar ahora</MenuItem>
<MenuSeparator />
<MenuItem icon={IconTrash} variant="danger">Eliminar</MenuItem>
</Menu>
{:else}
{text}
{/if}
{/snippet}
</Table>
</div> Orden en la tabla
Con sortRows la tabla ordena en el cliente. El estado usa un compare propio (por gravedad) y las solicitudes se ordenan por número aunque se muestren con format. Los datos de este y los siguientes ejemplos salen de projects.ts.
| Motor de búsqueda | Error | 12.077 |
|---|---|---|
| API de pagos | Degradado | 92.418 |
| Notificaciones | Desplegando | 45.910 |
| Portal de clientes | Activo | 184.302 |
| Panel interno | Detenido | 0 |
<script lang="ts">
import { Badge, Table, type BadgeVariant, type TableColumn, type TableSort } from '@archeblack/ui';
import { number, projects, type Project, type ProjectStatus } from './projects.ts';
// El estado se ordena por gravedad, no por orden alfabético.
const severity: ProjectStatus[] = ['error', 'degraded', 'deploying', 'active', 'stopped'];
const statuses: Record<ProjectStatus, { label: string; variant: BadgeVariant }> = {
error: { label: 'Error', variant: 'danger' },
degraded: { label: 'Degradado', variant: 'warning' },
deploying: { label: 'Desplegando', variant: 'info' },
active: { label: 'Activo', variant: 'success' },
stopped: { label: 'Detenido', variant: 'neutral' }
};
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto', sortable: true },
{
key: 'status',
label: 'Estado',
sortable: true,
format: (row) => statuses[row.status].label,
compare: (a, b) => severity.indexOf(a.status) - severity.indexOf(b.status)
},
{
key: 'requests',
label: 'Solicitudes (24 h)',
numeric: true,
sortable: true,
format: (row) => number.format(row.requests)
}
];
let sort = $state<TableSort>({ key: 'status', direction: 'ascending' });
</script>
<div style:inline-size="100%">
<Table
caption="Proyectos por estado"
hideCaption
{columns}
rows={projects}
rowKey="id"
bind:sort
sortRows
>
{#snippet cell(row, column, text)}
{#if column.key === 'status'}
<Badge variant={statuses[row.status].variant}>{text}</Badge>
{:else}
{text}
{/if}
{/snippet}
</Table>
</div> Orden del producto
Sin sortRows, la tabla solo marca el orden: onSortChange pide las filas ordenadas (por ejemplo, al servidor) y un role='status' anuncia el cambio.
| Región | |||
|---|---|---|---|
| Portal de clientes | Sudamérica | hace 2 min | 184.302 |
| API de pagos | EE. UU. Este | hace 6 min | 92.418 |
| Motor de búsqueda | Sudamérica | hace 14 min | 12.077 |
| Notificaciones | EE. UU. Este | hace 1 min | 45.910 |
| Panel interno | Europa | hace 3 días | 0 |
Sin orden: las filas llegan como las guarda el servidor.
<script lang="ts">
import { Table, type TableColumn, type TableSort } from '@archeblack/ui';
import { ago, number, projects, type Project } from './projects.ts';
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto', sortable: true },
{ key: 'region', label: 'Región' },
{ key: 'updated', label: 'Actualizado', sortable: true, format: (row) => ago(row.updated) },
{
key: 'requests',
label: 'Solicitudes (24 h)',
numeric: true,
sortable: true,
format: (row) => number.format(row.requests)
}
];
let sort = $state<TableSort | undefined>();
let rows = $state(projects);
let status = $state('');
// Sin sortRows, la tabla no reordena: el producto pide las filas en el orden nuevo (aquí las
// ordena en el cliente para simular la respuesta del servidor) y anuncia el cambio.
function load(next: TableSort) {
const sign = next.direction === 'ascending' ? 1 : -1;
const key = next.key as keyof Project;
rows = [...projects].sort(
(a, b) => sign * String(a[key]).localeCompare(String(b[key]), 'es', { numeric: true })
);
const label = columns.find((column) => column.key === next.key)?.label;
status = `Ordenado por ${label}, ${next.direction === 'ascending' ? 'ascendente' : 'descendente'}.`;
}
</script>
<div style:display="grid" style:gap="var(--arche-spacing-3)" style:inline-size="100%">
<Table
caption="Proyectos"
hideCaption
{columns}
{rows}
rowKey="id"
bind:sort
onSortChange={load}
/>
<p role="status" style:margin="0" style:color="var(--arche-color-text-muted)">
{status || 'Sin orden: las filas llegan como las guarda el servidor.'}
</p>
</div> Selección
bind:selected guarda las claves de rowKey. La casilla del encabezado queda mixta con algunas filas elegidas.
Ningún proyecto elegido.
| Proyecto | Región | Solicitudes (24 h) | |
|---|---|---|---|
| Portal de clientes | Sudamérica | 184.302 | |
| API de pagos | EE. UU. Este | 92.418 | |
| Motor de búsqueda | Sudamérica | 12.077 | |
| Notificaciones | EE. UU. Este | 45.910 | |
| Panel interno | Europa | 0 |
<script lang="ts">
import { Button, Table, type TableColumn, type TableKey } from '@archeblack/ui';
import { number, projects, type Project } from './projects.ts';
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto' },
{ key: 'region', label: 'Región' },
{
key: 'requests',
label: 'Solicitudes (24 h)',
numeric: true,
format: (row) => number.format(row.requests)
}
];
let selected = $state<TableKey[]>([]);
const count = $derived(selected.length);
</script>
<div style:display="grid" style:gap="var(--arche-spacing-3)" style:inline-size="100%">
<div
style:display="flex"
style:flex-wrap="wrap"
style:align-items="center"
style:justify-content="space-between"
style:gap="var(--arche-spacing-3)"
style:min-block-size="2rem"
>
<p role="status" style:margin="0" style:color="var(--arche-color-text-muted)">
{count === 0
? 'Ningún proyecto elegido.'
: `${count} de ${projects.length} proyectos elegidos.`}
</p>
<Button size="sm" variant="ghost" disabled={count === 0} onclick={() => (selected = [])}>
Quitar la selección
</Button>
</div>
<Table
caption="Proyectos"
hideCaption
{columns}
rows={projects}
rowKey="id"
selectable
bind:selected
/>
</div> Sin filas
El snippet empty ocupa todas las columnas cuando rows está vacío. La casilla del encabezado queda deshabilitada.
| Proyecto | Región | Solicitudes (24 h) | |
|---|---|---|---|
Todavía no hay proyectos. | |||
<script lang="ts">
import { Button, Table, type TableColumn } from '@archeblack/ui';
import { IconPlus } from '@archeblack/ui/icons';
import type { Project } from './projects.ts';
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto' },
{ key: 'region', label: 'Región' },
{ key: 'requests', label: 'Solicitudes (24 h)', numeric: true }
];
const rows: Project[] = [];
</script>
<div style:inline-size="100%">
<Table caption="Proyectos" hideCaption {columns} {rows} rowKey="id" selectable>
{#snippet empty()}
<div style:display="grid" style:justify-items="center" style:gap="var(--arche-spacing-3)">
<p style:margin="0">Todavía no hay proyectos.</p>
<Button size="sm" iconStart={IconPlus}>Crear proyecto</Button>
</div>
{/snippet}
</Table>
</div> Densa y con título
size='sm' quita relleno. Sin hideCaption, el caption se ve como título de la tabla.
| Proyecto | Identificador | Región | Actualizado | Solicitudes (24 h) | |
|---|---|---|---|---|---|
| Portal de clientes | prj_7f3a92c1 | Sudamérica | hace 2 min | 184.302 | |
| API de pagos | prj_2b91d0e4 | EE. UU. Este | hace 6 min | 92.418 | |
| Motor de búsqueda | prj_c40e7a18 | Sudamérica | hace 14 min | 12.077 | |
| Notificaciones | prj_5d88f3b2 | EE. UU. Este | hace 1 min | 45.910 | |
| Panel interno | prj_91aa0c67 | Europa | hace 3 días | 0 |
<script lang="ts">
import { Table, type TableColumn } from '@archeblack/ui';
import { ago, number, projects, type Project } from './projects.ts';
const columns: TableColumn<Project>[] = [
{ key: 'name', label: 'Proyecto' },
{ key: 'id', label: 'Identificador' },
{ key: 'region', label: 'Región' },
{ key: 'updated', label: 'Actualizado', format: (row) => ago(row.updated) },
{
key: 'requests',
label: 'Solicitudes (24 h)',
numeric: true,
format: (row) => number.format(row.requests)
}
];
</script>
<!-- El título visible es el <caption>: también nombra la tabla para los lectores de pantalla. -->
<div style:inline-size="100%">
<Table
caption="Tráfico de las últimas 24 horas"
size="sm"
{columns}
rows={projects}
rowKey="id"
selectable
/>
</div> Encabezado fijo
stickyHeader con maxHeight: la tabla se desplaza dentro de su contenedor y el encabezado queda arriba.
| Despliegue | Commit | Autor | Duración (s) |
|---|---|---|---|
| #1240 | 7f3a92c | Ana | 38 |
| #1239 | 7f3a778 | Bruno | 55 |
| #1238 | 7f3a5c4 | Carla | 72 |
| #1237 | 7f3a410 | Diego | 89 |
| #1236 | 7f3a25c | Ana | 106 |
| #1235 | 7f3a0a9 | Bruno | 123 |
| #1234 | 7f39ef5 | Carla | 50 |
| #1233 | 7f39d41 | Diego | 67 |
| #1232 | 7f39b8d | Ana | 84 |
| #1231 | 7f399d9 | Bruno | 101 |
| #1230 | 7f39825 | Carla | 118 |
| #1229 | 7f39672 | Diego | 45 |
| #1228 | 7f394be | Ana | 62 |
| #1227 | 7f3930a | Bruno | 79 |
| #1226 | 7f39156 | Carla | 96 |
| #1225 | 7f38fa2 | Diego | 113 |
<script lang="ts">
import { Table, type TableColumn } from '@archeblack/ui';
interface Deploy {
id: number;
commit: string;
author: string;
duration: number;
}
const authors = ['Ana', 'Bruno', 'Carla', 'Diego'];
const rows: Deploy[] = Array.from({ length: 16 }, (_, index) => ({
id: 1240 - index,
commit: (0x7f3a92c1 - index * 0x1b3d).toString(16).slice(0, 7),
author: authors[index % authors.length],
duration: 38 + ((index * 17) % 90)
}));
const columns: TableColumn<Deploy>[] = [
{ key: 'id', label: 'Despliegue', format: (row) => `#${row.id}` },
{ key: 'commit', label: 'Commit' },
{ key: 'author', label: 'Autor' },
{ key: 'duration', label: 'Duración (s)', numeric: true }
];
</script>
<!-- Con maxHeight la tabla se desplaza dentro de su contenedor y el encabezado queda fijo. -->
<div style:inline-size="100%">
<Table
caption="Últimos despliegues"
hideCaption
size="sm"
stickyHeader
maxHeight="18rem"
{columns}
{rows}
rowKey="id"
/>
</div> Props
| Prop | Descripción |
|---|---|
columns TableColumn<Row>[] | Las columnas, en orden. Cada una: key, label y, si hace falta, hideLabel, align (start o end), numeric, sortable, width, wrap, format(row) y compare(a, b). Las celdas no se parten salvo con wrap (para texto largo, junto con width): si la tabla no entra, se desplaza. |
rows Row[] | Las filas: un objeto por fila. La tabla no las modifica. |
rowKey keyof Row | ((row: Row) => TableKey) | Clave única de cada fila: el nombre de una propiedad ("id") o una función. Se usa para el {#each} con clave y para selected. |
caption string | Título de la tabla, en su <caption>. Es su nombre accesible y el de la región con scroll. |
hideCaption? boolean Por defecto false | Oculta el título a la vista cuando un encabezado de la página ya lo dice; los lectores de pantalla lo siguen leyendo. |
rowHeader? string | La columna que nombra cada fila: sus celdas son <th scope="row">, en text-strong. Por defecto, la primera. |
size? 'sm' | 'md' Por defecto 'md' | Densidad: md es la del demo; sm quita relleno para tablas largas. |
stickyHeader? boolean Por defecto false | El encabezado queda fijo al desplazar la tabla. La tabla se desplaza dentro de su contenedor: se usa con maxHeight. |
maxHeight? string | Alto máximo del contenedor ("18rem"). Si las filas no entran, la tabla se desplaza. |
bind:sort? TableSort | Orden actual: { key, direction }, con direction en ascending o descending (los valores de aria-sort). Una columna nueva empieza ascendente; la misma alterna. |
onSortChange? (sort: TableSort) => void | Se llama con el orden nuevo al elegir un encabezado, después de actualizar sort y solo si el cambio se aceptó. |
sortRows? boolean Por defecto false | La tabla ordena las filas en el cliente con el compare de la columna o el comparador por defecto: números y fechas por valor, textos con Intl.Collator («Proyecto 2» antes que «Proyecto 10») y los vacíos al final. Sin sortRows, el producto ordena. |
selectable? boolean Por defecto false | Suma una columna de casillas, una por fila, y una en el encabezado que elige o quita todas las filas visibles (mixta si hay algunas). |
bind:selected? TableKey[] Por defecto [] | Claves de las filas elegidas (string | number). Las que no están en rows se conservan. Un $state sin valor en bind:selected es una selección vacía. |
onSelectedChange? (selected: TableKey[]) => void | Se llama con las claves nuevas al marcar o desmarcar, después de actualizar selected y solo si el cambio se aceptó. |
selectAllLabel? string Por defecto 'Seleccionar todas las filas' | Nombre accesible de la casilla del encabezado. |
selectRowLabel? (row: Row) => string | Nombre accesible de la casilla de cada fila. Por defecto, «Seleccionar» y el texto de la columna rowHeader. |
cell? Snippet<[row: Row, column: TableColumn<Row>, text: string]> | Celdas propias (un Badge, un Menu de acciones). Si está, dibuja todas las celdas; text es lo que mostraría la tabla, para las columnas que no cambian. |
empty? Snippet | Contenido sin filas. Por defecto, «Sin datos para mostrar.». |
class? ClassValue | Clases del producto; se suman a arche-table, el contenedor con scroll. |
...rest HTMLTableAttributes | Cualquier otro atributo (id, aria-describedby…) va al <table>. |
Accesibilidad
- Es un
<table>nativo con<caption>,<th scope="col">en el encabezado y<th scope="row">en la columna que nombra cada fila: el lector anuncia el encabezado de la fila y de la columna al moverse por las celdas. -
captiones obligatorio. ConhideCaptionse oculta a la vista, pero sigue nombrando la tabla. - Cada columna ordenable es un botón dentro de su encabezado. La columna ordenada lleva
aria-sort(ascendingodescending) y un ícono de flecha; las demás, un ícono de doble flecha y sinaria-sort. Al ordenar el foco se queda en el botón. Si el orden llega del servidor, anuncia el resultado con unrole="status"(como en «Orden del producto»). - Las casillas son el Checkbox nativo con nombre: «Seleccionar Portal de clientes» en cada fila y «Seleccionar todas las filas» en el encabezado, que queda en estado mixto si hay algunas elegidas. La fila elegida se ve con fondo y una barra de 2 px, además de la casilla marcada.
- Las cifras van en IBM Plex Mono con cifras tabulares y alineadas al final, así las unidades quedan en columna.
- Si la tabla no entra, el contenedor pasa a ser una región con el nombre del
captiony recibe el foco, para desplazarlo con las flechas. Si entra, no suma una parada de Tab. - Una columna de acciones sin texto visible lleva igual su
labelconhideLabel(«Acciones»), y cada botón de la fila nombra su fila: «Acciones de Portal de clientes».
Qué evitar
- Una tabla para maquetar contenido que no son datos en filas y columnas. Una grilla de CSS o una lista de Card.
- Ordenar una tabla paginada solo con las filas de la página actual. Sin
sortRows:onSortChangepide al servidor las filas en el orden nuevo. - Hacer clicable la fila entera para abrir el detalle. Un link en la celda que nombra la fila y las acciones en un
Menu: cada una se alcanza con Tab. - Cifras alineadas al inicio o en la fuente de la interfaz.
numericen la columna: mono, cifras tabulares y alineadas al final. - Estados solo con color en una celda. Un Badge con su texto.