Ir al contenido

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.

Proyectos
EstadoAcciones
Portal de clientes
prj_7f3a92c1
ActivoSudamérica184.302hace 2 min
API de pagos
prj_2b91d0e4
DegradadoEE. UU. Este92.418hace 6 min
Notificaciones
prj_5d88f3b2
DesplegandoEE. UU. Este45.910hace 1 min
Motor de búsqueda
prj_c40e7a18
ErrorSudamérica12.077hace 14 min
Panel interno
prj_91aa0c67
DetenidoEuropa0hace 3 días
Svelte
<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.

Proyectos por estado
Motor de búsqueda Error12.077
API de pagos Degradado92.418
Notificaciones Desplegando45.910
Portal de clientes Activo184.302
Panel interno Detenido0
Svelte
<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.

Proyectos
Región
Portal de clientesSudaméricahace 2 min184.302
API de pagosEE. UU. Estehace 6 min92.418
Motor de búsquedaSudaméricahace 14 min12.077
NotificacionesEE. UU. Estehace 1 min45.910
Panel internoEuropahace 3 días0

Sin orden: las filas llegan como las guarda el servidor.

Svelte
<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.

Proyectos
ProyectoRegiónSolicitudes (24 h)
Portal de clientesSudamérica184.302
API de pagosEE. UU. Este92.418
Motor de búsquedaSudamérica12.077
NotificacionesEE. UU. Este45.910
Panel internoEuropa0
Svelte
<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.

Proyectos
ProyectoRegiónSolicitudes (24 h)

Todavía no hay proyectos.

Svelte
<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.

Tráfico de las últimas 24 horas
ProyectoIdentificadorRegiónActualizadoSolicitudes (24 h)
Portal de clientesprj_7f3a92c1Sudaméricahace 2 min184.302
API de pagosprj_2b91d0e4EE. UU. Estehace 6 min92.418
Motor de búsquedaprj_c40e7a18Sudaméricahace 14 min12.077
Notificacionesprj_5d88f3b2EE. UU. Estehace 1 min45.910
Panel internoprj_91aa0c67Europahace 3 días0
Svelte
<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.

Últimos despliegues
DespliegueCommitAutorDuración (s)
#12407f3a92cAna38
#12397f3a778Bruno55
#12387f3a5c4Carla72
#12377f3a410Diego89
#12367f3a25cAna106
#12357f3a0a9Bruno123
#12347f39ef5Carla50
#12337f39d41Diego67
#12327f39b8dAna84
#12317f399d9Bruno101
#12307f39825Carla118
#12297f39672Diego45
#12287f394beAna62
#12277f3930aBruno79
#12267f39156Carla96
#12257f38fa2Diego113
Svelte
<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

Props de Table
PropDescripció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 falseOculta 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 falseEl 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 falseLa 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 falseSuma 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.
  • caption es obligatorio. Con hideCaption se 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 (ascending o descending) y un ícono de flecha; las demás, un ícono de doble flecha y sin aria-sort. Al ordenar el foco se queda en el botón. Si el orden llega del servidor, anuncia el resultado con un role="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 caption y 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 label con hideLabel («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: onSortChange pide 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. numeric en la columna: mono, cifras tabulares y alineadas al final.
  • Estados solo con color en una celda. Un Badge con su texto.