Referencia / Sub-Skill

tables

UBICACIÓN: skills/supafast-ui/references/tables.md

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, tabla w-full border-collapse con ancho mínimo (min-w-[900px]) para que en pantallas estrechas haga scroll en vez de aplastarse.
  • Cabecera: th en px-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). Hover bg-muted/30, seleccionada bg-muted/40. Celdas px-4 py-3.5 align-middle.
  • Fila interactiva: cursor-pointer, accesible con role="button" + tabIndex={0} + Enter/Espacio.
  • Acción por fila (Ver detalle →): botón ghost que aparece solo en hover/selección (opacity-0opacity-100), icono ArrowRight size-3.5.
  • Vacío: una fila con colSpan y px-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 de columns; 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 con bg-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 opacity sin foco).
  • Para ampliar el detalle de una celda/fila no abras un modal: usa el popover anclado (column.render con un Popover). Ver references/detail-popover.md.