Ir al contenido

Datos y tablas

Los componentes con los que se enseñan datos: la tabla completa, el editor de líneas, la lista, y las piezas sueltas que los acompañan.

La tabla del ERP. Ordenación, selección múltiple, paginación de cliente o de servidor, scroll infinito, filas expandibles, acciones de fila, columnas fijas, redimensionables y ocultables, esqueleto de carga, fila de totales y modo tarjetas en móvil.

import { Table, Badge, type TableColumn } from '@openfactu/ui';
interface Factura {
id: number;
numero: string;
cliente: string;
total: number;
estado: 'borrador' | 'contabilizada' | 'pagada';
}
const ESTADO = {
borrador: <Badge variant="neutral">Borrador</Badge>,
contabilizada: <Badge variant="info">Contabilizada</Badge>,
pagada: <Badge variant="success">Pagada</Badge>,
};
const columnas: TableColumn<Factura>[] = [
{ header: 'Número', accessor: 'numero', primary: true, sortable: true },
{ header: 'Cliente', accessor: 'cliente', sortable: true },
{ header: 'Estado', cell: (f) => ESTADO[f.estado], align: 'center' },
{
header: 'Total',
accessor: (f) => `${f.total.toFixed(2)}`,
align: 'right',
sortable: true,
sortAccessor: (f) => f.total,
},
];
<Table columns={columnas} data={facturas} onRowClick={(f) => abrir(f)} />

accessor puede ser una clave del objeto o una función. cell es lo mismo pero solo para pintar. primary marca la columna identificadora, que se pinta en monoespaciada.

Si dos columnas comparten header, dales un id: es la clave que se usa para ordenación, visibilidad y redimensionado.

const [seleccionadas, setSeleccionadas] = React.useState<Set<string | number>>(new Set());
<Table
columns={columnas}
data={facturas}
selectable
selectedKeys={seleccionadas}
onSelectionChange={setSeleccionadas}
/>

Si omites selectedKeys, la tabla mantiene la selección por dentro. La clave de cada fila sale de item.id; con otro nombre, pásale rowKey.

Se combina con BulkActionsBar para las acciones masivas.

{/* cliente: le pasas todas las filas y la tabla las trocea */}
<Table columns={columnas} data={facturas} pagination={{ pageSize: 10 }} />
{/* servidor: `data` es solo la página actual y `total` el número real de registros */}
<Table
columns={columnas}
data={pagina}
pagination={{
pageSize: 25,
page,
total: totalRegistros,
onPageChange: setPage,
onPageSizeChange: setPageSize,
}}
/>

Alternativa a la paginación: carga la página siguiente al acercarse al final.

<Table
columns={columnas}
data={filas}
infinite={{
hasMore,
loading: cargando,
onLoadMore: traerSiguientePagina,
endMessage: 'No hay más facturas.',
}}
/>

Aparecen en un botón ⋯ al pasar el ratón, y también con click derecho sobre la fila. Usan el mismo shape de ítem de menú que el resto.

<Table
columns={columnas}
data={facturas}
rowActions={(f) => [
{ label: 'Editar', onClick: () => editar(f) },
{ label: 'Duplicar', onClick: () => duplicar(f) },
{ label: 'Eliminar', destructive: true, separatorBefore: true, onClick: () => eliminar(f) },
]}
/>
<Table
columns={columnas}
data={facturas}
renderExpanded={(f) => (
<div className="flex gap-8 text-[12px]">
<p>{f.numero} · {f.cliente}</p>
<p className="font-mono">{f.total.toFixed(2)}</p>
</div>
)}
/>

emptyMessage admite nodos, no solo texto: un vacío útil suele llevar icono y un botón para crear el primer registro.

<Table
columns={columnas}
data={[]}
emptyMessage={
<div className="flex flex-col items-center gap-2 py-4">
<Inbox className="h-6 w-6 text-[var(--fg-subtle)]" />
<p className="text-[13px] text-[var(--fg-muted)]">Todavía no hay facturas.</p>
<Button size="sm" variant="accent">
<Plus className="h-3.5 w-3.5" /> Crear la primera
</Button>
</div>
}
/>
<Table columns={columnas} data={[]} isLoading />
<Table columns={columnas} data={[]} isLoading loadingVariant="spinner" loadingLabel="Sincronizando…" />

Columnas fijas, redimensionables y ocultables

Sección titulada «Columnas fijas, redimensionables y ocultables»
<Table
columns={columnas}
data={facturas}
selectable
stickyFirstColumn
resizableColumns
showColumnToggle
/>

Se le pasan las filas visibles y devuelve una celda por columna visible, así que el pie queda alineado con la tabla sin montar un bloque aparte.

<Table
columns={columnas}
data={facturas}
summaryRow={(filas) => [
null,
null,
null,
`${filas.reduce((a, f) => a + f.total, 0).toFixed(2)}`,
]}
/>

Por debajo del punto de corte la tabla pinta una tarjeta por fila en lugar de obligar a desplazarse en horizontal. El papel de cada columna se declara con card; lo que no lo lleve va al cuerpo de la tarjeta con su etiqueta delante.

<Table
responsive="cards"
cardsBreakpoint={640}
columns={[
{ header: 'Número', accessor: 'numero', primary: true, card: 'title' },
{ header: 'Cliente', accessor: 'cliente', card: 'subtitle' },
{ header: 'Estado', cell: (f) => ESTADO[f.estado], card: 'status' },
{ header: 'Total', accessor: (f) => `${f.total.toFixed(2)}`, align: 'right', card: 'meta' },
]}
data={facturas}
/>
Prop Tipo Por defecto Descripción
columns TableColumn<T>[] Definición de columnas
data T[] [] Filas
onRowClick (item: T) => void Click en una fila
emptyMessage ReactNode 'No se encontraron registros.' Estado vacío
rowClassName (item: T, index: number) => string | undefined Clases extra por fila
isLoading boolean Estado de carga
density 'compact' | 'normal' | 'comfy' 'normal' Densidad de filas
selectable boolean false Columna de checkboxes y multiselección
selectedKeys Set<string | number> Selección controlada
onSelectionChange (keys: Set<string | number>) => void Cambio de selección
rowKey (item: T, index: number) => string | number item.id Cómo extraer la clave de cada fila
pagination TablePagination Paginación de cliente o de servidor
infinite TableInfinite Scroll infinito. Excluyente con pagination
renderExpanded (item: T) => ReactNode Contenido del detalle. Añade columna chevron
expandedKeys Set<string | number> Expansión controlada
onExpandedChange (keys: Set<string | number>) => void Cambio de expansión
rowActions (item: T) => RowAction[] Acciones de fila (botón ⋯ y click derecho)
stickyFirstColumn boolean false Fija la primera columna al hacer scroll horizontal
columnVisibility Record<string, boolean> Visibilidad controlada; clave col.id ?? col.header
onColumnVisibilityChange (v: Record<string, boolean>) => void Cambio de visibilidad
showColumnToggle boolean false Botón para mostrar/ocultar columnas
resizableColumns boolean false Redimensionar arrastrando el borde de la cabecera
loadingVariant 'skeleton' | 'spinner' 'skeleton' Cómo se representa isLoading
skeletonRows number tamaño de página, u 8 Filas fantasma
skeletonRowHeight number | string 1.25em Alto de la caja de cada celda fantasma
loadingLabel string 'Sincronizando…' Texto bajo el spinner
responsive 'scroll' | 'cards' 'scroll' Qué hacer cuando no cabe
cardsBreakpoint number 640 Ancho por debajo del cual se pasa a tarjetas
summaryRow (rows: T[]) => ReactNode[] Fila de totales al pie
summaryLabel ReactNode 'Total' Etiqueta de la primera celda del pie
appendRow ReactNode Fila extra al final, para el «+ Añadir línea»
className string Clases
Prop Tipo Descripción
header string Título de la columna
accessor keyof T | ((item: T, index: number) => ReactNode) Valor de la celda
cell (item: T, index: number) => ReactNode Renderizado de la celda
align 'left' | 'center' | 'right' Alineación
width string Ancho fijo
primary boolean Estilo de columna identificadora (monoespaciada)
sortable boolean Columna ordenable
sortAccessor (item: T) => any Clave de ordenación cuando accessor es función
id string Clave estable. Obligatoria si dos columnas comparten header
card 'title' | 'subtitle' | 'status' | 'meta' | 'body' | 'hidden' Papel en el modo tarjetas
className string Clases de la celda
Prop Tipo Descripción
pageSize number Filas por página
page number Página controlada, 1-based
onPageChange (page: number) => void Cambio de página
total number Modo servidor: total real de registros
pageSizeOptions number[] Opciones del selector de tamaño
onPageSizeChange (size: number) => void Si se pasa, pageSize es controlado
Prop Tipo Descripción
hasMore boolean Quedan más filas por traer
onLoadMore () => void Trae la página siguiente
loading boolean Hay una petición en vuelo; no se pide otra
endMessage ReactNode | false Qué poner al llegar al final

Table con edición dentro. Los controles se declaran, no se pintan: cada columna dice qué editor quiere y el componente monta el resto.

El modo de las líneas de un documento de venta: cada celda escribe directamente, sin botones de guardar.

import { EditableTable, Card, type EditableColumn } from '@openfactu/ui';
const columnas: EditableColumn<Linea>[] = [
{
header: 'Artículo',
accessor: (l) => ARTICULOS.find((a) => a.value === l.articulo)?.label ?? l.articulo,
field: 'articulo',
editor: { type: 'search-select', options: ARTICULOS, placeholder: 'Buscar artículo…' },
isDisabled: (l) => !!l.origen,
},
{
header: 'Cantidad',
accessor: 'cantidad',
align: 'right',
width: '124px',
editor: { type: 'number', precision: 2, min: 0 },
},
{ header: 'Precio', accessor: (l) => money(l.precio), field: 'precio', align: 'right', editor: { type: 'currency' } },
{ header: 'Dto.', accessor: (l) => `${l.descuento} %`, field: 'descuento', align: 'right', editor: { type: 'percent' } },
{ header: 'Importe', accessor: (l) => money(total(l)), align: 'right' },
];
<Card title="Líneas del documento" noPadding>
<EditableTable
mode="cell"
density="compact"
columns={columnas}
data={lineas}
onChange={(fila) => setLineas((prev) => prev.map((l) => (l.id === fila.id ? fila : l)))}
onDelete={(fila) => setLineas((prev) => prev.filter((l) => l.id !== fila.id))}
onAddRow={() => {
const nueva = { id: Date.now(), articulo: '', cantidad: 1, precio: 0, descuento: 0 };
setLineas((prev) => [...prev, nueva]);
return nueva;
}}
summaryRow={() => [null, null, null, null, money(suma)]}
/>
</Card>

isDisabled bloquea la celda en algunas filas: lo que hace falta para las líneas que vienen de un pedido y no se tocan.

En mode="row" la fila entera entra en edición con Guardar y Cancelar. Intro guarda y Escape cancela.

<EditableTable
columns={[
{ header: 'Código', accessor: 'codigo', primary: true, width: '120px' },
{ header: 'Nombre', accessor: 'nombre', editor: { type: 'text' } },
{ header: 'Tipo', accessor: (i) => `${i.tipo} %`, field: 'tipo', align: 'right', editor: { type: 'percent' } },
]}
data={impuestos}
onSave={(fila) => setImpuestos((prev) => prev.map((r) => (r.id === fila.id ? fila : r)))}
/>

Las columnas sin editor siguen siendo de solo lectura también mientras se edita la fila, que es como se protege el código de un registro.

text · number · currency · percent · select · search-select · date · checkbox · custom.

Con custom montas el control tú:

{
header: 'Responsable',
field: 'responsable',
editor: {
type: 'custom',
render: ({ value, onChange, row, disabled }) => (
<MiSelectorDeEmpleados value={value} onChange={onChange} disabled={disabled} />
),
},
}

Hereda todas las de Table salvo columns y rowActions.

Prop Tipo Por defecto Descripción
columns EditableColumn<T>[] Columnas, con su editor
mode 'row' | 'cell' 'row' Fila entera con botones, o celda a celda
onSave (row: T, original: T) => void | Promise<void> Confirmación de la edición de una fila
onChange (row: T, field: string, value: any) => void En mode="cell", cada cambio
onDelete (row: T) => void | Promise<void> Eliminar una fila
onAddRow () => T Muestra la fila «añadir» y devuelve la fila nueva
addLabel string Texto de la fila de añadir
isRowEditable (row: T) => boolean Qué filas se pueden editar
editingId string | number | null Fila que arranca en edición
onEditingIdChange (id: string | number | null) => void Cambio de la fila en edición

Todo lo de TableColumn<T>, más:

Prop Tipo Descripción
editor CellEditor<T> Hace la columna editable
field keyof T & string Campo que se escribe. Por defecto, accessor si es una clave
isDisabled (row: T) => boolean Bloquea la celda en algunas filas
Prop Tipo Descripción
type CellEditorType Tipo de editor
options SearchableSelectOption[] Opciones de select y search-select
precision number Decimales de number, currency y percent
min / max number Límites numéricos
placeholder string Texto de marcador
currency string Divisa del editor currency
render (args) => ReactNode Control propio cuando type es 'custom'

Lista de elementos con icono, título, subtítulo, metadatos, acciones y estado. Es la alternativa a Table cuando cada elemento es una ficha y no una fila de datos: adjuntos, notificaciones, conversaciones, historial.

import { List } from '@openfactu/ui';
import { Trash2, MessageSquare } from 'lucide-react';
<List
variant="plain"
activeId={activa}
items={[
{ id: 'c1', icon: <MessageSquare className="h-4 w-4" />, title: 'Presupuesto Acme', onClick: () => setActiva('c1') },
{ id: 'c2', icon: <MessageSquare className="h-4 w-4" />, title: 'Incidencia de envío', onClick: () => setActiva('c2') },
]}
/>
<List
variant="bordered"
items={[
{
id: 1,
icon: <FileText className="h-4 w-4" />,
title: 'FAC-2026-0042.pdf',
subtitle: 'Subido por Ana Pérez',
meta: '248 KB',
badge: <Badge variant="success">Firmado</Badge>,
status: { tone: 'success', label: 'Validado' },
actions: [
{ icon: <Download className="h-3.5 w-3.5" />, label: 'Descargar', onClick: descargar },
{ icon: <Trash2 className="h-3.5 w-3.5" />, label: 'Eliminar', tone: 'danger', onClick: borrar },
],
},
]}
/>

label es obligatorio en cada acción: los botones son solo icono, así que es su nombre accesible.

<List
sections={[
{
key: 'hoy',
label: 'Hoy',
meta: '3',
collapsible: true,
items: [/* … */],
},
{ key: 'anteriores', label: 'Anteriores', collapsible: true, defaultOpen: false, items: [/* … */] },
]}
/>
<List
selectable
selectedIds={seleccionados}
onSelectionChange={setSeleccionados}
items={elementos}
/>
<List isLoading skeletonCount={5} items={[]} />
<List
items={elementos}
emptyState={{ title: 'Sin adjuntos', description: 'Arrastra un archivo para empezar.' }}
infinite={{ hasMore, onLoadMore: traerMas, loading: cargando }}
/>

emptyState acepta un nodo propio o directamente las props del EmptyState de la librería.

<List variant="divided">
<List.Item title="Alta de proveedor" subtitle="Talleres del Norte" meta="hace 2 h" />
<List.Item title="Cierre de inventario" subtitle="Almacén central" meta="ayer" unread />
</List>
Prop Tipo Por defecto Descripción
items ListItemData[] Modo plano. Excluyente con sections
sections ListSectionData[] Modo agrupado por secciones
density 'compact' | 'normal' | 'comfy' 'normal' Densidad
variant 'plain' | 'divided' | 'bordered' | 'cards' 'divided' Estilo de la lista
activeId string | number | null Elemento resaltado (navegación)
selectable boolean false Casillas de selección múltiple
selectedIds Array<string | number> Selección controlada
onSelectionChange (ids: Array<string | number>) => void Cambio de selección
actionsVisibility 'hover' | 'always' 'hover' Cuándo se ven las acciones
isLoading boolean false Estado de carga
skeletonCount number 5 Filas fantasma
emptyState ReactNode | EmptyStateProps Estado vacío
maxHeight number | string Alto máximo con scroll
staggerAnimation boolean true Entrada escalonada de las filas
header / footer ReactNode Contenido fijo arriba y abajo
infinite ListInfinite Carga la página siguiente al llegar al final
renderItem (item: ListItemData, index: number) => ReactNode Sustituye el renderizado de cada fila
children ReactNode API de composición: <List.Item />
aria-label string Nombre accesible de la lista
className / itemClassName string Clases
Prop Tipo Descripción
id string | number Clave del elemento
title ReactNode Título
subtitle ReactNode Segunda línea
description ReactNode Texto largo; se recorta a dos líneas
icon / avatar ReactNode Bloque izquierdo. Prioridad: avatar > icon > status
status ListItemStatus { tone, label? }: con texto es un chip, sin él un punto
meta ReactNode Bloque derecho: importe, fecha, contador
badge ReactNode Distintivo
actions ListAction[] Botones de icono: { icon, label, onClick, tone?, disabled? }
content ReactNode Zona libre bajo el texto: progreso, error, chips
onClick (event: React.MouseEvent) => void Click en el elemento
href string Ruta interna (<Link>) o URL absoluta (<a>)
disabled boolean Desactiva el elemento
unread boolean Lo resalta como no leído
Prop Tipo Por defecto Descripción
key string Clave de la sección
items ListItemData[] Elementos
label ReactNode Encabezado
icon ReactNode Icono del encabezado
tone ListTone 'default' Color del encabezado
meta ReactNode Contenido a la derecha del encabezado
collapsible boolean false Se puede plegar
defaultOpen boolean true Empieza desplegada
emptyState ReactNode Qué enseñar si la sección está vacía

El paginador suelto, por si lo necesitas fuera de una Table.

import { Pagination } from '@openfactu/ui';
<Pagination
page={pagina}
pageSize={tamano}
total={totalRegistros}
onPageChange={setPagina}
onPageSizeChange={setTamano}
/>
{/* sin selector de tamaño */}
<Pagination page={pagina} pageSize={25} total={480} onPageChange={setPagina} pageSizeOptions={[]} />
Prop Tipo Por defecto Descripción
page number Página actual, 1-based
pageSize number Registros por página
total number Total de registros
onPageChange (page: number) => void Cambio de página
pageSizeOptions number[] [10, 25, 50, 100] Opciones del selector. [] lo oculta
onPageSizeChange (size: number) => void Cambio de tamaño
className string Clases

Barra de búsqueda y filtros declarativa: describes los filtros y el componente monta los controles.

import { FilterBar, type FilterBarConfig } from '@openfactu/ui';
const CONFIG: FilterBarConfig[] = [
{
key: 'estado',
label: 'Estado',
type: 'select',
options: [
{ label: 'Borrador', value: 'borrador' },
{ label: 'Emitida', value: 'emitida' },
{ label: 'Pagada', value: 'pagada' },
],
},
{ key: 'cliente', label: 'Cliente', type: 'search-select', width: 200, options: CLIENTES },
{ key: 'desde', label: 'Desde', type: 'date' },
{ key: 'referencia', label: 'Referencia', type: 'text' },
];
<FilterBar
config={CONFIG}
searchTerm={termino}
onSearchChange={setTermino}
activeFilters={filtros}
onFilterChange={(k, v) => setFiltros((p) => ({ ...p, [k]: v }))}
onClear={() => { setTermino(''); setFiltros({}); }}
searchPlaceholder="Buscar facturas…"
/>
Prop Tipo Por defecto Descripción
config FilterBarConfig[] Filtros a montar
searchTerm string Texto del buscador
onSearchChange (value: string) => void Cambio del buscador
activeFilters Record<string, any> Valores activos, por key
onFilterChange (key: string, value: any) => void Cambio de un filtro
onClear () => void Limpiar todo
searchPlaceholder string Texto del buscador
shortcut 'mod+k' | 'ctrl+k' | '/' | false 'mod+k' Atajo que enfoca el buscador
className string Clases
Prop Tipo Por defecto Descripción
key string Clave del filtro
label string Etiqueta
type 'text' | 'select' | 'search-select' | 'date' Control a montar
options { label: string; value: any }[] Opciones de los desplegables
placeholder string Texto de marcador
width number 160 Ancho del control en píxeles

La tarjeta de cifra del dashboard: etiqueta, valor, subtítulo y tendencia.

import { KpiCard } from '@openfactu/ui';
import { Receipt } from 'lucide-react';
<KpiCard
label="Facturado este mes"
value="48.230,55 €"
sub="122 facturas"
trend={{ dir: 'up', text: '+12 % vs. mes anterior' }}
icon={<Receipt className="h-4 w-4" />}
/>
<KpiCard label="Pendiente de cobro" value="12.480,00 €" trend={{ dir: 'down', text: '-4 %' }} />
<KpiCard label="Cargando…" value="" isLoading />

isLoading sustituye las cifras por barras de carga sin cambiar la altura, así que el dashboard no da un salto al terminar de cargar.

Prop Tipo Descripción
label string Etiqueta
value ReactNode Cifra principal
sub ReactNode Segunda línea
trend { dir: 'up' | 'down' | 'flat'; text: string } Tendencia con su flecha
icon ReactNode Icono decorativo arriba a la derecha
onClick () => void Hace la tarjeta clicable
isLoading boolean Barras de carga en lugar de las cifras
className string Clases

El contenedor de siempre: título, subtítulo, acción de cabecera, cuerpo y pie.

import { Card, Button } from '@openfactu/ui';
<Card title="Datos fiscales" subtitle="Aparecen en todas las facturas">
<p></p>
</Card>
<Card
title="Líneas del documento"
headerAction={<Button size="sm" variant="ghost">Añadir</Button>}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm">Cancelar</Button>
<Button variant="accent" size="sm">Guardar</Button>
</div>
}
>
</Card>

noPadding quita el relleno del cuerpo, que es lo que se quiere al meter una Table dentro para que llegue hasta los bordes.

<Card title="Resumen" isLoading />
<Card title="Movimientos" isLoading skeleton="list" />
<Card title="Personalizado" isLoading skeleton={<MiEsqueleto />} skeletonHeader={false} />
Prop Tipo Por defecto Descripción
children ReactNode Cuerpo
title ReactNode Título
subtitle ReactNode Subtítulo
headerAction ReactNode Acción a la derecha de la cabecera
footer ReactNode Pie
noPadding boolean false Quita el relleno del cuerpo
isLoading boolean false Sustituye el contenido por un esqueleto
skeleton 'text' | 'list' | 'none' | ReactNode 'text' Qué dibujar mientras carga
skeletonLines number 3 Líneas del esqueleto de texto
skeletonHeader boolean true También esqueletiza título y subtítulo
className / bodyClassName string Clases

El distintivo de estado. Extiende React.HTMLAttributes<HTMLDivElement>.

import { Badge } from '@openfactu/ui';
<Badge variant="success">Pagada</Badge>
<Badge variant="warning">Vence hoy</Badge>
<Badge variant="danger">Vencida</Badge>
<Badge variant="info">Contabilizada</Badge>
<Badge variant="accent">Nueva</Badge>
<Badge variant="neutral">Borrador</Badge>

Variantes: success, warning, danger, error, info, neutral, accent y teal.

Las variantes de estado no siguen al tema, y es a propósito: un color de estado tiene que significar lo mismo en todos los temas. La única que sigue al acento del tenant es accent.

Prop Tipo Por defecto Descripción
variant BadgeVariant 'neutral' Color del distintivo
…resto HTMLAttributes<HTMLDivElement> className, onClick, title

Iniciales o foto, con indicador de estado opcional.

import { Avatar } from '@openfactu/ui';
<Avatar name="Ana Pérez" />
<Avatar name="Ana Pérez" size="xs" />
<Avatar name="Bruno Gil" src="/fotos/bruno.jpg" size="lg" />
<Avatar name="Carla Ruiz" status="online" />

Sin src se pintan las iniciales de las dos primeras palabras del nombre, sobre un degradado derivado de la escala fija --k-ink-*.

Prop Tipo Por defecto Descripción
name string Nombre completo; de ahí salen las iniciales
src string URL de la foto
size 'xs' | 'sm' | 'md' | 'lg' 'md' Tamaño
status 'online' | 'offline' | 'busy' | 'away' Punto de estado
className string Clases