supafast-ui · sub-skill: TABLAS
Las tablas se montan con el componente genérico EntityDataTable (shared/components/data-table/),
dirigido por columnas (columns) + filas (rows). No se escribe <table> a mano en cada pantalla:
se define el array de columnas y se reutiliza. Va acompañado de EntityTableToolbar (orden/filtros, ver
menus-selects.md) y de un skeleton de carga.
Anatomía visual (claves del look)
- Wrapper con scroll horizontal:
overflow-x-auto, tablaw-full border-collapsecon ancho mínimo (min-w-[900px]) para que en pantallas estrechas haga scroll en vez de aplastarse. - Cabecera:
thenpx-4 py-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground(mayúsculas pequeñas grises — el sello de estas tablas). Sin fondo, sin borde superior. - Filas: separadas por
border-t border-border/50(no zebra). Hoverbg-muted/30, seleccionadabg-muted/40. Celdaspx-4 py-3.5 align-middle. - Fila interactiva:
cursor-pointer, accesible conrole="button"+tabIndex={0}+ Enter/Espacio. - Acción por fila (
Ver detalle →): botón ghost que aparece solo en hover/selección (opacity-0→opacity-100), iconoArrowRight size-3.5. - Vacío: una fila con
colSpanypx-4 py-10 text-sm text-muted-foreground. - Valores nulos/vacíos se muestran como
—(em dash), no en blanco.
Código verbatim — data-table/EntityDataTable.tsx
import { Button } from '@/shared/components/ui/button'
import { cn } from '@/shared/lib/utils'
import { ArrowRight } from 'lucide-react'
import { useState, type ReactNode } from 'react'
type EntityDataAlign = 'left' | 'center' | 'right'
export type EntityDataColumn<T> = {
label: string
key: string
align?: EntityDataAlign
render?: (row: T) => ReactNode
headerClassName?: string
cellClassName?: string
}
type EntityDataTableProps<T> = {
columns: EntityDataColumn<T>[]
rows: T[]
getRowId: (row: T) => string
onRowClick?: (row: T) => void
onRowAction?: (row: T) => void
rowActionLabel?: string
getRowAriaLabel?: (row: T) => string
emptyMessage?: string
caption?: string
minTableWidthClassName?: string
selectedRowId?: string | null
renderRowActions?: (row: T) => ReactNode
}
function getAlignClassName(align: EntityDataAlign | undefined, isHeader = false) {
if (align === 'right') return 'text-right'
if (align === 'center') return 'text-center'
return isHeader ? 'text-left' : 'text-left'
}
function getValueByPath(row: unknown, key: string) {
const value = key.split('.').reduce<unknown>((currentValue, part) => {
if (currentValue === null || currentValue === undefined) return undefined
if (typeof currentValue !== 'object') return undefined
return (currentValue as Record<string, unknown>)[part]
}, row)
if (value === null || value === undefined || value === '') return '—'
return value as ReactNode
}
export function EntityDataTable<T>({
columns, rows, getRowId, onRowClick, onRowAction,
rowActionLabel = 'Ver detalle', getRowAriaLabel,
emptyMessage = 'No hay datos disponibles.', caption = 'Listado de entidades',
minTableWidthClassName = 'min-w-[900px]', selectedRowId, renderRowActions,
}: EntityDataTableProps<T>) {
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null)
const isRowInteractive = Boolean(onRowClick)
return (
<div className="overflow-x-auto">
<table className={cn('w-full border-collapse text-left', minTableWidthClassName)}>
<caption className="sr-only">{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.key}
className={cn(
'px-4 py-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground',
getAlignClassName(column.align, true),
column.headerClassName,
)}
>
{column.label}
</th>
))}
{onRowAction || renderRowActions ? (
<th className="w-[120px] px-4 py-3" aria-hidden="true" />
) : null}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td
className="px-4 py-10 text-sm text-muted-foreground"
colSpan={columns.length + (onRowAction || renderRowActions ? 1 : 0)}
>
{emptyMessage}
</td>
</tr>
) : (
rows.map((row) => {
const rowId = getRowId(row)
const isHovered = hoveredRowId === rowId
const isSelected = selectedRowId === rowId
return (
<tr
key={rowId}
className={cn(
'border-t border-border/50 transition-colors',
isRowInteractive && 'cursor-pointer',
isSelected ? 'bg-muted/40' : isHovered ? 'bg-muted/30' : 'bg-transparent',
)}
onClick={() => onRowClick?.(row)}
onMouseEnter={() => setHoveredRowId(rowId)}
onMouseLeave={() => setHoveredRowId(null)}
onKeyDown={(event) => {
if (!onRowClick) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onRowClick(row)
}
}}
tabIndex={isRowInteractive ? 0 : undefined}
role={isRowInteractive ? 'button' : undefined}
aria-label={getRowAriaLabel?.(row)}
>
{columns.map((column) => (
<td
key={`${rowId}-${column.key}`}
className={cn('px-4 py-3.5 align-middle', getAlignClassName(column.align), column.cellClassName)}
>
{column.render ? column.render(row) : getValueByPath(row, column.key)}
</td>
))}
{renderRowActions ? (
<td className="px-4 py-3.5 align-middle">
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
{renderRowActions(row)}
</div>
</td>
) : null}
{!renderRowActions && onRowAction ? (
<td className="px-4 py-3.5 align-middle">
<Button
type="button" variant="ghost" size="sm"
className={cn(
'ml-auto flex items-center gap-1 text-xs text-muted-foreground transition-opacity',
isHovered || isSelected ? 'opacity-100' : 'opacity-0',
)}
onClick={(event) => { event.stopPropagation(); onRowAction(row) }}
tabIndex={-1}
>
{rowActionLabel}
<ArrowRight className="size-3.5" />
</Button>
</td>
) : null}
</tr>
)
})
)}
</tbody>
</table>
</div>
)
}
Uso típico (definir columnas + render por celda)
import { EntityDataTable, type EntityDataColumn } from '@/shared/components/data-table/EntityDataTable'
import { Badge } from '@/shared/components/ui/badge'
const columns: EntityDataColumn<Objective>[] = [
{ key: 'name', label: 'Nombre', cellClassName: 'font-medium text-foreground' },
{ key: 'advertiser', label: 'Anunciante' },
{
key: 'status', label: 'Estado',
render: (row) => <Badge variant={row.active ? 'default' : 'outline'}>{row.active ? 'Activo' : 'Pausado'}</Badge>,
},
{ key: 'updatedAt', label: 'Actualizado', align: 'right',
render: (row) => <span className="text-muted-foreground">{formatDate(row.updatedAt)}</span> },
]
<EntityDataTable
columns={columns}
rows={objectives}
getRowId={(row) => row.id}
onRowClick={(row) => navigate(`/objectives/${row.id}`)}
getRowAriaLabel={(row) => `Ver ${row.name}`}
emptyMessage="No hay objetivos todavía."
/>
Patrón de sección completa
Una tabla en pantalla va dentro de una superficie con borde + la toolbar arriba:
<div className="rounded-xl border border-border bg-card">
<EntityTableToolbar count={rows.length} singularLabel="objetivo" pluralLabel="objetivos"
sortBy={sortBy} sortOptions={SORT_OPTIONS} onSortByChange={setSortBy} searchSlot={<EntityTableSearch … />} />
{isLoading ? <ObjectivesTableSkeleton /> : <EntityDataTable columns={columns} rows={rows} … />}
</div>
Reglas
- Usa
EntityDataTable+ array decolumns; no escribas<table>/<thead>a mano por pantalla. - Formato/badges/fechas → en
column.render, no metas lógica de presentación en los datos. - Cabeceras siempre
text-[11px] uppercase tracking-wide text-muted-foreground. - Filas separadas por
border-t border-border/50; hover/seleccion conbg-muted/30÷/40. Nada de zebra ni bordes verticales. - Mientras carga, muestra un skeleton con la misma estructura (mismo nº de columnas), no un spinner suelto.
- Acciones por fila que aparecen en hover deben ser accesibles igualmente (no las escondas solo con
opacitysin foco). - Para ampliar el detalle de una celda/fila no abras un modal: usa el popover anclado (
column.rendercon unPopover). Verreferences/detail-popover.md.