Ir al contenido
Portal de documentación del EGD
Menú de la sección

Tablas, buscador, ordenamiento y selector de columnas

El SGD construye todas sus tablas sobre una base compartida con TanStack Table v9 (@tanstack/react-table, versión instalada 9.2.4). TanStack aporta únicamente la lógica (orden, visibilidad, anchos, selección, búsqueda y paginación de cliente); la presentación sigue siendo shadcn/ui + Tailwind + Design System (Table*, TableHeadSortable, TableColumnResizer, DataTableToolbar, DataTableSelectionBar, badges y tooltips existentes).

PiezaArchivoRol
useSgdTablesrc/components/ui/data-table/use-sgd-table.tsHook sobre useTable de TanStack: features registradas, estado, persistencia y reglas del SGD.
DataTable / SgdDataTable.../data-table/data-table.tsxTabla (cabecera ordenable, separadores, filas, vacío, totales) y composición con barra de herramientas y pie.
AdminDataTable.../data-table/admin-data-table.tsxVariante para Server Components: columnas y filas serializables (patrón de /admin).
SgdColumn, compareTableValues.../data-table/sgd-table.tsTipos y utilidades puras (sin React).
monoCell, mutedCell, textCell.../data-table/cells.tsxCeldas de apoyo (monoespaciada, secundaria, guion si vacío).

Se importa todo desde @/components/ui/data-table. Los componentes de presentación previos (TableHeadSortable, TableColumnResizer, TableColumnVisibilitySelector, DataTableToolbar, DataTableSelectionBar, TableCellDescription) se conservan sin cambio visual; los hooks anteriores (useTableSort, useTableColumnVisibility, useTableColumnResize, useTableSelection) se retiraron al quedar sustituidos y verificados.


1. Barra de herramientas (DataTableToolbar) y posición de controles

  • Botón único de visibilidad en el extremo derecho: El botón de visibilidad de columnas (TableColumnVisibilitySelector, icono tune de 18 px de Material Symbols con tooltip y aria-label="Mostrar u ocultar columnas") se ubica siempre en el extremo derecho de la barra de herramientas superior de la tabla (como en el Design System), alineado a la derecha sin elementos que lo desplacen.
  • Selector de filas por página en la parte inferior: El control de «Filas por página» no se coloca en la cabecera superior para no saturar las acciones principales; se ubica en la parte inferior de la tabla integrado en la barra de paginación (PaginationBar o DataTablePagination). Cuenta con un diseño minimalista moderno (altura compacta h-8 de 32 px, tipografía text-xs, select sutil sin marcos pesados y alineado junto a los botones de página mediante un divisor vertical sutil).
  • Prohibición de botones duplicados: No se debe insertar el selector de columnas dentro de las cabeceras <th> de la tabla, garantizando una única acción visible y consistente en todas las vistas.
  • Renderizado en Portal: El menú de columnas utiliza Popover con Portal directo a document.body, evitando que el desplazamiento horizontal de la tabla (overflow-x-auto) recorte la ventana flotante. Permanece abierto al seleccionar múltiples columnas y actualiza la tabla en tiempo real.

2. Tablas con y sin buscador

DataTableToolbar permite habilitar o deshabilitar el buscador según las necesidades de cada vista:

  • Con buscador (searchable={true}):
    • Ubica el campo de búsqueda en el extremo izquierdo de la barra (384 px desde sm, 240 px en pantallas angostas), como campo completo (SearchInput, el mismo de los buscadores de modales y filtros): borde, 36 px de alto (igual que «Más filtros»), lupa dentro a la izquierda que toma el color de acento con el foco, aro de foco del tema y botón de limpieza (close / ×) a la derecha al escribir. Sin insignia fija del atajo Alt/Control + Q: se muestra al mantener el modificador. Antes era un campo de solo línea inferior (estilo underline).
    • Las acciones complementarias (conteo, selector de columnas, filtros) se mantienen alineadas a la derecha.
    • La búsqueda opera sobre los campos definidos en la tabla y se combina de forma transparente con el ordenamiento y la paginación.
  • Sin buscador (searchable={false}):
    • No renderiza ningún campo de texto ni reserva espacio en blanco a la izquierda.
    • Conserva todas las acciones y el selector de columnas alineados automáticamente al extremo derecho (ml-auto), manteniendo una composición equilibrada y compacta.

3. Celda de descripción adaptable (TableCellDescription) sin texto cortado

Para evitar que los textos largos se corten por la mitad o rompan la altura de la fila:

  • Densidad dinámica: TableRow utiliza min-h-[var(--sgd-table-row-height)] en lugar de una altura fija rígida. Cuando una celda requiere dos líneas, la fila se adapta suavemente a la altura necesaria manteniendo un padding uniforme (py-2.5) e interlineado óptimo (leading-snug).
  • Límite de dos líneas (line-clamp-2): El contenedor interno muestra hasta dos líneas de texto íntegras. Si el texto excede este espacio, muestra puntos suspensivos (...).
  • Consulta accesible del texto completo: Envuelto en un componente accesible Tooltip (Radix UI) que permite consultar el texto íntegro tanto al situar el puntero (hover) como al navegar con teclado (foco visible accesible con tabIndex={0}).

3 bis. Texto largo en celdas: tope de líneas y puntos suspensivos (ClampedText)

Una celda con texto largo no debe crecer sin límite: al estrechar la columna, una oficina o un nombre largo llegaba a 6 líneas y la fila se estiraba. Criterio:

  • 1 línea (lines={1}, o TruncatedText): códigos, números, fechas y cualquier dato corto; nunca se corta un identificador clave (n.º de emisión, estado), que ya tiene un ancho mínimo.
  • 2 líneas (valor por defecto): nombres de personas y oficinas, destinatarios, remitentes y el asunto (TableCellDescription).
  • 3 líneas (lines={3}): solo texto libre extenso (descripciones, observaciones). Más de 3 se lee en el detalle del registro, no en la tabla.
  • Con puntos suspensivos (line-clamp); el texto completo sigue en el DOM (lector de pantalla, copiar, buscar). Solo si de verdad se corta —se mide, no se supone por la cantidad de caracteres—, el texto se vuelve alcanzable con Tab y muestra el texto completo en el tooltip del Design System (con el mouse tras la pausa de 1 s, con el teclado al instante; Escape lo cierra y se mantiene con el puntero encima: WCAG 1.4.13). El texto que cabe no es tabulable ni muestra tooltip.
  • El tooltip no es la única vía: el texto completo también debe poder leerse en el detalle del registro (la información no puede depender solo del hover).
  • Aplicación global, sin tocar cada pantalla: el criterio vive en los componentes compartidos. (1) TableCell (ui/table.tsx) recorta a 2 líneas el contenido que sea texto plano (string), con lines={1 | 2 | 3 | false} para cambiarlo o desactivarlo; las celdas que abarcan varias columnas (colSpan: mensajes y pies) no se tocan. (2) DataTable hace lo mismo con el texto que devuelve cell de cada columna, con la opción lines de SgdColumn. (3) mutedCell de las tablas de /admin y TableCellDescription (asunto, motivos) usan ClampedText. Los elementos (insignias, botones, enlaces, códigos en monoespaciada) se dibujan tal cual: un identificador no se corta. Para un campo que deba verse completo (p. ej. una nota larga que es la razón de la fila) se usa lines={3} o lines={false}.
  • Un cell o un TableCell con texto mezclado con elementos (varios hijos) no se recorta solo: envuélvalo en ClampedText con el texto completo como text.

4. Ordenamiento funcional (TableHeadSortable y useSgdTable)

Un único componente (TableHeadSortable, src/components/ui/table-sorting.tsx) pinta el indicador en todas las tablas; la lógica es getToggleSortingHandler de TanStack con enableSortingRemoval: false y sortDescFirst: false. Icono: Icon (Material Symbols) swap_vert / arrow_upward / arrow_downward, 15 px.

Estado de la columnaIndicadoraria-sort
Ordenable sin ordenDoble flecha ⇅ a la derecha del título, solo al pasar el mouse sobre el encabezado o al enfocar el botón con teclado (:focus-visible)none
Primer clicOrden ascendente, flecha ↑ascending
Segundo clicOrden descendente, flecha ↓descending
Clics siguientesAlterna ascendente ↔ descendente (no vuelve a «sin orden»)—
Orden activoFlecha siempre visible (color primario) aunque el mouse salgasegún dirección
No ordenable (sortable: false)Sin botón ni indicador; no reserva espacio—
  • Implementación del hover: regla CSS plana en globals.css (.sgd-sort-th:hover .sgd-sort-icon, :focus-within y button:focus-visible). No usar variantes group-hover: de Tailwind v4 para este icono: dependen de @media (hover: hover) y no se activaban en algunos equipos.
  • Hover del encabezado (15-oct-2026): al pasar el mouse (o enfocar con teclado) un encabezado ordenable, toda la celda toma un gris algo más oscuro (color-mix(in srgb, var(--foreground) 7%, var(--muted))) y el texto pasa a color pleno, con transición de 120 ms (sin ella con movimiento reducido). Los encabezados que no ordenan (p. ej. «Acciones») no reaccionan, porque no hacen nada al pulsarse. Regla plana en globals.css (.sgd-data-table thead th.sgd-sort-th:hover).
  • Sin saltos: el icono ocupa siempre 15 px (opacity-0 en reposo, nunca display:none), por lo que el título no se mueve al aparecer.
  • Contraste: la doble flecha usa text-muted-foreground (≥5,5:1 sobre la cabecera en los tres temas, claro y oscuro; medido); la flecha activa usa text-primary (≥5,7:1).
  • Arrastre del separador: TableColumnResizer se monta fuera del botón y detiene click/mousedown; arrastrarlo nunca ordena.
  • Tipo de dato real: SgdColumn.dataType + accessor (fecha por epoch, número, texto es-PE natural, estado), vacíos al final.
  • Paginación de servidor: con sorting.mode: "server" el clic solo cambia sortBy/sortOrder en la URL y el servicio ordena el conjunto completo antes de paginar (Emisión de documentos). Con datos de cliente se ordena el conjunto filtrado completo antes de paginar. Los listados de /admin ordenan la página cargada porque sus servicios no aceptan orden.
  • Pruebas manuales: el emulador de hover de algunos navegadores automatizados no activa :hover; verificar con mouse real o con Tab.

5. Salvaguardas de visibilidad y persistencia

  1. Columnas indispensables (required: true): Muestran la etiqueta «Fija» y su casilla permanece bloqueada para que el usuario no pueda ocultarlas accidentalmente.
  2. Protección contra tabla vacía: Si el usuario desmarca columnas hasta dejar solo una activa, la última casilla se bloquea.
  3. Restablecer configuración: Botón «Restablecer» en la cabecera del desplegable que restaura el diseño inicial y elimina sobreescrituras en almacenamiento.
  4. Persistencia por tabla y usuario: Clave en localStorage: sgd_table_cols_${userId}_${tableId} (o sgd_table_cols_${tableId}). Uso estricto de useSyncExternalStore para prevenir desajustes de hidratación SSR (hydration mismatch).

6. Barra de acciones para registros seleccionados (DataTableSelectionBar y useSgdTable().selection)

Permite realizar operaciones por lote sobre uno o varios registros seleccionados mediante casillas de verificación (Checkbox):

  • Regla de selección (solo el checkbox): una fila se selecciona o deselecciona únicamente con su checkbox, por clic o con teclado cuando ese control tiene el foco (Espacio). Hacer clic en cualquier otra parte de la fila o de una celda no modifica la selección (tampoco la deselecciona). El checkbox del encabezado, la selección múltiple y las acciones masivas no cambian; las acciones propias de una fila (botones, enlaces, menús) no activan la selección. El componente compartido DataTable ya no tiene la opción rowClickSelects; onRowClick queda como punto de extensión opcional y sin uso por defecto (no hay navegación ni acción asignada al número de documento u otra columna: se definirá más adelante qué campos serán clicables). Las filas sin acción propia llevan data-static-row y cursor normal (no de mano); el resaltado al pasar el mouse se conserva como ayuda visual. Aplica a las tablas del usuario final y a las de administración.

Excepción acotada: fila activa con onRowClick + isRowActive. Cuando una pantalla tiene una sola fila que gobierna una barra de acciones o un panel de detalle (supervisión), la fila puede elegirse con clic y con Enter o Espacio (con onRowClick, la fila recibe tabIndex={0}). isRowActive la resalta con el color de fila seleccionada y la marca con aria-current="true". No sustituye a la casilla de selección múltiple: las listas con acciones masivas siguen usando selectable (p. ej. «Documentos por entregar» de Cargo de entrega).

  • Pastilla de conteo y limpieza ([ N seleccionado × ]): Presenta el contador con número destacado en color primario y botón con icono close para desmarcar todos los registros inmediatamente. Cuando no hay registros seleccionados, la barra permanece oculta.
  • Alineación con herramientas sin saltos de altura: Integrada en DataTableToolbar (o cabecera equivalente) con altura estándar (min-h-[56px]), evitando superponerse sobre las filas o provocar desplazamientos visuales de la tabla. Compatible con tablas con y sin buscador.
  • Menú desplegable [ ⚙ Acciones ▾ ]: Agrupa las operaciones permitidas para los registros seleccionados (TableBatchAction<T>).
  • Alcance transparente de selección (página visible vs. conjunto completo):
    • El checkbox de cabecera selecciona estrictamente los registros visibles de la página activa, evitando asumir falsamente que se seleccionó la totalidad de resultados.
    • Si existen más registros en el conjunto filtrado, ofrece el enlace explícito: «Seleccionar todos los N resultados».
    • Al cambiar de página o aplicar filtros de búsqueda, detecta registros seleccionados fuera de la vista y muestra una advertencia con la opción «Conservar únicamente los visibles».
  • Confirmación de operaciones destructivas: Toda acción marcada con destructive: true despliega obligatoriamente un modal accesible (ConfirmDialog) antes de ejecutar la mutación y comunica el resultado mediante notifySuccess.
  • Explicación clara de no aplicabilidad: Si una acción no aplica a la totalidad de registros (ej. intentar conciliar asientos ya conciliados), la propiedad isApplicable rechaza la ejecución y explica el motivo detallado con notifyWarning, previniendo modificaciones parciales silenciosas.

7. Cómo configurar una tabla nueva (useSgdTable)

7.1 Tabla de cliente (datos completos, orden/búsqueda/paginación locales)

"use client";
import { SgdDataTable, useSgdTable, type SgdColumn } from "@/components/ui/data-table";

const COLUMNS: SgdColumn<Expediente>[] = [
  { id: "codigo", header: "Código", required: true, width: 150, minWidth: 110 }, // no se puede ocultar
  { id: "fecha", header: "Fecha", dataType: "date", accessor: (r) => r.fechaIso, cell: (r) => r.fecha },
  { id: "monto", header: "Monto", dataType: "number", align: "right", accessor: (r) => r.montoNumero, cell: (r) => r.montoTexto },
  { id: "estado", header: "Estado", dataType: "status", cell: (r) => <StatusBadge status={r.estado} /> },
  { id: "acciones", header: "Acciones", sortable: false, resizable: false, required: true, cell: (r) => <Acciones row={r} /> },
];

const getRowId = (r: Expediente) => r.id; // estable: nunca el índice

export function Tabla({ data }: { data: Expediente[] }) {
  const sgd = useSgdTable<Expediente>({
    tableId: "expedientes",            // separa anchos y columnas guardados
    data, columns: COLUMNS, getRowId,
    selectable: true,                  // checkboxes + barra de acciones
    clientSearch: true,                // buscador de cliente
    sorting: { mode: "client", initial: { id: "fecha", desc: true } },
    pagination: { mode: "client", pageSize: 10 },
  });
  return (
    <SgdDataTable
      sgd={sgd}
      ariaLabel="Expedientes"
      searchable searchPlaceholder="Buscar…"
      batchActions={ACCIONES_POR_LOTE}   // TableBatchAction[]
      totalCountLabel="registros"
      footerCells={{ monto: totalTexto }} // fila de totales opcional
    />
  );
}

7.2 Tabla con paginación y orden en el servidor (p. ej. Emisión de documentos)

const sgd = useSgdTable<Doc>({
  tableId: "sgd-docs-emission", data: slice, columns, getRowId, selectable: true,
  sorting: { mode: "server", value: sortBy ? { id: sortBy, desc: order === "desc" } : null,
             onChange: (next) => router.push(urlConOrden(next)) }, // el servicio ordena el conjunto completo
});
// Composición manual: toolbar propio + <DataTable sgd={sgd} /> + <PaginationBar … />

El orden, los filtros y la página viajan en la URL; TanStack solo refleja el estado (manualSorting, manualPagination). No se inventan endpoints.

7.3 Listado del administrador (Server Component)

const COLUMNS: AdminColumn[] = [
  { id: "name", header: "Nombre", required: true, minWidth: 220 },
  { id: "isActive", header: "Estado", width: 130 },
  { id: "actions", header: "Acciones", required: true, sortable: false, resizable: false, width: 110 },
];

<AdminDataTable
  tableId="admin-motivos"
  columns={COLUMNS}
  rows={items.map<AdminRow>((m) => ({
    id: m.id,
    values: { name: m.name, isActive: m.isActive ? "Activo" : "Inactivo" }, // dato real para ordenar
    cells: { isActive: <StatusBadge status={…} />, actions: <MotiveActions motive={m} /> }, // JSX ya renderizado
  }))}
  emptyMessage="No hay motivos registrados."
/>

La paginación sigue en PaginationBar (URL). Alcance honesto: los servicios del admin no exponen parámetros de orden, así que el orden actúa sobre los registros de la página cargada. Para ordenar el conjunto completo hará falta que el servicio lo admita (no inventado aquí).

7.4 Opciones por columna (SgdColumn / AdminColumn)

OpciónEfecto
requiredColumna indispensable: no se puede ocultar (aparece como «Fija»). Siempre queda al menos una visible.
defaultVisibleVisibilidad inicial; «Restablecer» vuelve a este valor.
sortablefalse en acciones/iconos. Por defecto ordenable.
dataType / accessorOrden por tipo real (date, number, string, status); accessor entrega el valor ordenable (p. ej. ISO, número, peso de prioridad). Vacíos siempre al final.
width, minWidth, maxWidth, resizableAncho inicial y límites del arrastre; resizable: false quita el separador.
align, className, headerClassNameAlineación y estilos de celda/encabezado.
labelNombre completo para la lista de columnas y lectores de pantalla (si el encabezado es abreviado).
searchableParticipa en la búsqueda general de cliente aunque la columna esté oculta. Por defecto no (opt-in): marcar solo los campos relevantes, nunca ids internos o técnicos.

7.5 Opciones de tabla

OpciónEfecto
selectableColumna de checkboxes con IDs estables (getRowId), barra «N seleccionados × · Acciones».
clientSearch + searchableBuscador de campo completo (SearchInput); sin él, las herramientas se alinean a la derecha.
sorting.modeclient (ordena lo recibido) o server (se controla con value/onChange).
pagination{ mode: "client", pageSize }; con servidor, omitir y usar PaginationBar.
persist / userIdPreferencias en localStorage por tabla (y por usuario si se pasa userId).
toolbar (AdminDataTable)false en listas compactas dentro de diálogos.

7.6 Comportamiento verificado

  • Orden: primer clic ascendente (flecha ↑), segundo descendente (↓), después alterna; nunca vuelve a «sin orden» por clic. aria-sort en cada th; la doble flecha aparece en hover/foco de columnas sin orden y el indicador activo permanece visible.
  • Modo ajustado (sin anchos personalizados, p. ej. al abrir o tras «Anchos» → restablecer): los anchos por defecto son proporciones que se escalan al contenedor (<col> en %), de modo que no hay scroll horizontal salvo que ni los anchos mínimos (minWidth) quepan. Al ajustar la primera columna se «congelan» los anchos renderizados de todas (snapshot) y se pasa al modo siguiente.
  • Scroll solo si hace falta (15-oct-2026): en modo ajustado, si el contenedor es más angosto que los anchos de diseño pero alcanza para los mínimos, DataTable mide el contenedor (ResizeObserver) y reparte el espacio: cada columna recibe su minWidth más una parte del sobrante proporcional a cuánto le sobra a su width sobre su minWidth. Ninguna columna queda bajo su mínimo y no aparece barra horizontal; solo se desplaza cuando la suma de los mínimos supera el ancho disponible (p. ej. porque el usuario activó muchas columnas). Por eso minWidth debe ser el ancho mínimo legible de la columna (encabezado con ícono de orden y contenido sin partirse), no un valor arbitrario; y no conviene fijar la prop minWidth de la tabla salvo que haya una razón. El asa de ajuste de la última columna queda dentro de la tabla (centrada en el borde sobresalía 6 px y creaba una barra falsa). Con anchos personalizados el comportamiento anterior se mantiene.
  • Anchos: al arrastrar un separador solo cambia esa columna; las demás conservan su ancho. Se logra con layout fijo + <colgroup>: el ancho total es la suma de las columnas y, si sobra espacio, lo absorbe una columna de relleno invisible (si alguna columna no tiene ancho propio, esa columna flexible lo absorbe). Ensanchar hace crecer la tabla y aparece el desplazamiento horizontal; estrechar lo quita. El arrastre se aplica una vez por cuadro (requestAnimationFrame) para que sea fluido. Antes: layout fijo; el ancho total es la suma de columnas visibles (desplazamiento horizontal dentro de la tabla). Arrastrar el separador no ordena; teclado ←/→ (Shift ±25), Home/End, Enter/doble clic restablece.
  • Visibilidad: ocultar/mostrar conserva los anchos guardados; «Restablecer» (columnas) y «Anchos» (anchos) en el desplegable.
  • Selección: por ID estable; sobrevive al ordenar, filtrar y cambiar de página («+N ocultos» lo advierte). «Seleccionar todos» del encabezado actúa sobre la página visible; «Seleccionar todos los N resultados» solo existe con datos de cliente (con paginación de servidor no se puede garantizar el conjunto completo).
  • Persistencia: sgd_table_cols_[usuario_]{tableId} (ids visibles) y sgd_col_widths_[usuario_]{tableId} (anchos). Se leen con useSyncExternalStore: el servidor y la primera pasada usan los valores por defecto (sin desajuste de hidratación).

7.7 Tablas que no usan la base (excepciones)

  • permission-matrix.tsx: matriz de permisos (casillas por rol × permiso), no un listado de registros.
  • bulk-assign-dialog.tsx y replace-document-dialog.tsx: selectores de lista dentro de un diálogo (la fila entera es la acción); conservan Table plano.

7.8 Columna de fecha con hora (DateTimeCell)

En las tablas de documentos la columna «Fecha» muestra la fecha y, debajo, la hora en una línea más pequeña y tenue (03:58 p. m.): DateTimeCell (components/ui/date-time-cell.tsx) con splitDateTime (lib/format-date.ts). Siempre hora de Lima, no la del equipo; un valor sin hora (2026-09-18) muestra solo la fecha, sin inventar «12:00 a. m.». Con inline va en una línea (30/10/2026 · 03:58 p. m.) para las tarjetas del celular. Ancho de columna 112 px (mín. 104). Orden y filtros usan la fecha y hora completas; para filtrar por día se usa limaDayKey (mismo día que muestra la tabla). Aplicado en Emisión y recepción de documentos (y vistos buenos), la bandeja de Mesa de Partes, los reportes de documentos y las ventanas de referencias. Mockup: docs/mockups/anexos-zip-y-hora-en-fecha.html.

7b. Búsqueda, filtros y favoritos

Criterio de diseño: Identidad visual LATAM con patrones de búsqueda, filtrado y favoritos. Se adopta el flujo (buscar → desplegar filtros con checks → guardar la búsqueda con un nombre → recuperarla desde Favoritos); la apariencia sigue siendo la del SGD (tokens, tipografía, iconos, bordes y temas LATAM, Adobe Spectrum e Institucional en claro y oscuro).

Alcance: frontend del usuario final (bandeja Emisión de documentos y el ejemplo del Design System). El módulo /admin queda fuera: conserva sus formularios de filtro por URL.

Barra y componentes (@/components/ui/data-table)

PiezaRol
DataTableToolbarBarra fuera de la tabla: buscador («Buscar en la tabla…», «×») → filterControl → herramientas a la derecha (selector de columnas, sin duplicados). Chips en activeFilters.
DataTableFilterMenu«Más filtros · N»: desplegable anclado (Popover) con dos secciones, Filtros y Favoritos.
DataTableFilterSheet (= FilterEditorSheet)Panel lateral derecho para criterios extensos (fechas exactas, texto). Borrador hasta «Aplicar filtros».
DataTableActiveFiltersChips con «×» (máx. 4 y «Ver todos · N») y «Limpiar filtros» (nunca borra el buscador).
useDebouncedSearchBúsqueda en vivo con espera de 350 ms (modo servidor).
useTableFavoritesFavoritos por usuario y tabla (backend, SGD_AUTH.TRS_SAVED_SEARCH): guardar, editar, actualizar, eliminar, favorito por defecto.
FilterEditorSheetPanel lateral con borrador (criterios avanzados y edición de favoritos).
filters.tsFilterField, valores planos de URL, relativas, validación, comparación en cliente.

Comportamiento

  • Búsqueda en vivo sin Enter ni botón. Cliente: columnas con searchable: true (incluso ocultas; nunca ids técnicos). Servidor: los campos de SEARCH_FIELDS de la consulta. Se combina (AND) con los filtros, conserva el orden y vuelve a la página 1.
  • Cierre del panel lateral: la X del panel de criterios avanzados/favoritos es la compartida de diálogos (DialogCloseButton vía SheetContent; ver dialogs.md, «Paneles laterales»): gris en reposo, hover circular, foco visible del tema.
  • Desplegable «Filtros»: opciones múltiples (fila clicable con un ✓ a la izquierda al marcarse, sin caja y con el espacio del ✓ reservado; role="checkbox", Espacio/Enter) que no cierran el menú y actualizan la tabla al instante; categorías con más de 8 opciones traen buscador interno; fechas relativas (Hoy, Esta semana, Últimos 7 días, Este mes, Últimos 30 días) como regla de una sola elección. «Criterios avanzados…» abre el panel lateral (fechas exactas y campos de texto).
  • Combinación: varias opciones de un mismo campo = OR («Pendiente o En proceso»); campos distintos = AND; el texto del buscador = AND con los filtros. El contador de «Más filtros · N» cuenta campos con criterio (un campo con varias opciones o un rango completo cuenta 1).
  • Chips y vacío: chips por campo («Estado: FIRMADO o PENDIENTE»); sin resultados se ofrece «Borrar búsqueda» y «Limpiar filtros» por separado.
  • Selección: cambiar búsqueda o filtros limpia la selección y lo avisa (no se actúa sobre registros ocultos). Ordenar y paginar la conservan, como antes.
  • Secciones expandibles (Collapsible): las categorías con 5 o más filas (opciones de un select o reglas de un dateRange; COLLAPSE_MIN_ROWS y isCollapsibleField en filters.ts, FilterField.collapsible fuerza o impide el modo) se muestran contraídas: solo el título y una flecha (expand_more, gira 180° al abrir). Las categorías breves (p. ej. Estado, Prioridad, Envío, Sí/No) quedan siempre visibles, sin desplegable. El encabezado es el componente compartido Collapsible (src/components/ui/collapsible.tsx, sobre Radix Collapsible de radix-ui; aria-expanded/aria-controls, clic, Enter o Espacio) con hover suave --accent, cursor de mano y foco visible (reglas planas .sgd-collapsible-* en globals.css, no dependen de @media (hover)). Cada categoría es independiente: se pueden expandir varias a la vez. Junto al título se muestra el contador de opciones seleccionadas («1», con texto accesible «seleccionada/seleccionadas»); las selecciones se conservan al contraer y expandir (viven en los filtros, no en el estado de la sección), la selección sigue siendo por check sin caja y expandir o contraer no cambia los filtros aplicados ni cierra el desplegable. Las listas internas no tienen scroll propio: cuando el contenido supera el alto disponible hay un único scroll vertical en el extremo derecho, a la derecha de Favoritos. Ejemplo: tabla 1 del Design System (Diario con collapsible: true; «Fecha del asiento» con 5 reglas; Conciliación, breve, siempre visible).
  • Editar un favorito: el icono «Editar» abre el panel lateral con el nombre y solo los criterios que ese favorito tiene guardados (otros campos se incorporan con «Agregar criterio»), (opciones marcadas, reglas relativas o fechas exactas, texto), el texto del buscador (si se incluyó) y «Usar por defecto en esta bandeja» ya cargados y editables. «Guardar cambios» actualiza el favorito (y, si estaba activo, los resultados de la tabla); «Cancelar» o cerrar conservan el original. Los criterios que dejaron de ser válidos se listan en el panel y no se cargan, para corregirlos.
  • Favorito por defecto: interruptor «Filtro por defecto» al guardar y, justo bajo el nombre, al editar. A lo sumo uno por usuario y bandeja (marcar otro reemplaza al anterior; desmarcar conserva el favorito; eliminar el favorito elimina la preferencia). Si está habilitado, se aplica siempre al entrar a la bandeja (una vez por montaje), reemplazando los criterios con los que se llegó (también una URL con criterios); las fechas relativas se recalculan. Ordenar o paginar no lo vuelve a aplicar. Se marca con «Por defecto» y se guarda en el mismo registro (isDefault). Limitación: recargar la página con otros criterios vuelve a aplicar el favorito.
  • Guardar como favorito (nombre sugerido): el formulario usa FloatingUnderlineInput del Design System (sin caja, línea inferior, etiqueta flotante «Nombre de la búsqueda», foco azul y error rojo con icono junto al campo; noValidate, sin tooltip nativo). Al abrirlo el campo trae una sugerencia local (suggestFavoriteName en filters.ts, sin IA ni dependencias) con las etiquetas que ve el usuario, en el orden de los campos y máximo 3 criterios: «Pendiente · Oficio · Esta semana»; varias opciones de un campo se unen con «o» («Conciliado o Observado») y, si pasan de 2, «+N»; sí/no como «Campo: Sí»; texto como «texto»; fechas con la regla relativa o el rango. Si hay texto de búsqueda y caben, se añade «texto». Se recorta a ~48 caracteres con «+N»/«…». Sin filtros: «Búsqueda: texto» o «Mi búsqueda». Mientras no se edite, la sugerencia sigue a los criterios (y a «Incluir el texto del buscador»); al escribir, el texto del usuario ya no se sobrescribe. Se selecciona al enfocar para reemplazarla de inmediato. Si el nombre ya existe se añade « (2)». Validación en useTableFavorites.nameError: no vacío ni solo espacios, ≤ 60 caracteres y único. Al editar un favorito se conserva su nombre guardado.
  • Jerarquía de textos (tipo oración): todo va en minúscula con mayúscula inicial, también los títulos de sección («Filtros», «Favoritos», con la clase .sgd-group-title, que se distingue por peso y tamaño), los subtítulos de categoría (.sgd-subtitle: «Tipo de documento», «Estado»), las etiquetas, las opciones y las acciones («Guardar búsqueda como favorito», «Filtro por defecto»). No se usan mayúsculas sostenidas ni text-transform: uppercase (comprobación: npm run text:check). Siglas y nombres propios se escriben como son (SGD, PDF, DOCX, V°B°, unidades orgánicas). Los códigos de datos y de URL (p. ej. EMITIDO) no se alteran: se exponen como { value, label } con la etiqueta en minúscula con mayúscula inicial (ver emission-filter-config.ts). Tamaño mínimo 12 px (--sgd-font-help).
  • Scroll único del desplegable: «Más filtros» tiene un solo contenedor con scroll vertical (la rejilla Filtros | Favoritos, overflow-y-auto overscroll-contain) con altura máxima min(40rem, altura disponible); su barra queda en el extremo derecho, a la derecha de Favoritos (que se estira a toda la altura). Las secciones y listas internas no tienen scroll (sin barras duplicadas ni desplazamientos anidados), de modo que con muchos favoritos todas las opciones y acciones («Guardar búsqueda como favorito») siguen alcanzables. Solo el listado emergente del selector con chips (una capa aparte) conserva su propio scroll.
  • Dos columnas independientes (no CSS columns): las categorías del desplegable se reparten en dos contenedores de columna (splitFilterColumns en filters.ts): un único corte que respeta el orden de los campos (primera mitad a la izquierda, resto a la derecha) y equilibra el peso estático de cada categoría contraída (categoryWeight: contraída = 1 fila, no depende de lo desplegado). Expandir o contraer una categoría solo desplaza lo que hay debajo, en su misma columna; la otra columna no se mueve, ninguna categoría desaparece y el reparto es idéntico al volver a contraer. Antes se usaba columns-2 (CSS columns), que rebalanceaba las categorías entre columnas al cambiar sus alturas (con «Tipo de documento» abierto, «Prioridad» saltaba de columna). En pantallas pequeñas las dos columnas se apilan en una (grid de una columna) conservando el orden original, sin ocultar opciones. No se usan alturas fijas, espacios vacíos ni posicionamiento absoluto, y el único scroll sigue siendo el del desplegable.
  • Filas clicables: opciones (.sgd-option-row) y favoritos (.sgd-fav-row) usan reglas planas en globals.css (no dependen de @media (hover)): hover = fondo suave --accent; seleccionada/activa = ✓ + texto medium sin fondo propio: el fondo aparece solo mientras el mouse está encima, nunca queda fijo en una opción marcada; cursor de mano en toda la fila; foco visible de 2 px (--ring) con offset interno. En un favorito toda la fila aplica el favorito (el botón se extiende con ::after); sincronizar, editar y eliminar quedan por encima (z-10) y no lo aplican.
  • Selector con chips («Elaborado por», «Producto»…) y etiqueta flotante: el título de la categoría («Elaborado por») queda fuera del campo, en su posición, igual que «Estado» o «Tipo de documento» (.sgd-subtitle, vinculado con aria-labelledby). Lo que flota es una indicación corta dentro del campo («Buscar…»; no repite el título, para que el filtro se vea limpio), con el mecanismo del Design System (.sgd-floating-field / .sgd-floating-label, como FloatingUnderlineInput). Vacío y sin foco está dentro del campo (14 px); con clic o foco por teclado sube (12 px, color primario) y deja espacio para escribir o elegir; con valores (chips) o texto escrito permanece arriba (data-floating="always"); al quedar vacío y perder el foco vuelve a su sitio, con transición de 140 ms (prefers-reduced-motion la elimina). El <input> lleva placeholder=" " (la etiqueta usa :placeholder-shown para saber si está vacío; sin él quedaría siempre arriba) y no tiene placeholder visible: no depende de él, su nombre accesible es ariaLabel («Elaborado por: buscar y agregar»). La etiqueta se trunca para no tocar la X ni la flecha. Línea inferior y foco azul como el resto de campos underline.
  • Favorito activo vs. favorito por defecto (son cosas distintas):
    • Activo = el favorito que el usuario seleccionó (o el «por defecto» que se aplicó al entrar) y cuyos criterios siguen aplicados. Es lo único que muestra el check. Abrir «Más filtros» nunca marca un favorito: no se deduce por coincidencia de criterios (un favorito con criterios vacíos u obsoletos ya no aparece marcado en una bandeja sin filtros) ni se asigna al primero de la lista. Si el usuario modifica los criterios, el check se quita y aparece el aviso «fue modificada» (Actualizar / Guardar como nueva); con «Limpiar» no queda ningún favorito activo. Si seleccionó uno antes, el check se conserva al cerrar y reabrir mientras sus criterios sigan aplicados. Un favorito recién guardado tampoco queda marcado: solo lo marca una selección explícita.
    • Por defecto = preferencia guardada de forma explícita con el interruptor «Filtro por defecto» (insignia «Por defecto»; a lo sumo uno). Nunca se asigna automáticamente al primer favorito ni al guardar uno nuevo sin marcar el interruptor. Solo cuando existe se aplica al entrar a la bandeja y, como queda aplicado, aparece además como activo (con check). Sin favorito por defecto, al entrar todos los favoritos aparecen sin check.
  • Acciones de cada favorito (función definitiva de los iconos): seleccionar y ejecutar acciones son interacciones independientes; ninguna acción aplica ni marca el favorito (el check solo lo da seleccionar el nombre o la zona de la fila).
    • Nombre / zona de selección: aplica los filtros del favorito y muestra el check.
    • Actualizar un favorito (sin icono por fila): se selecciona el favorito, se cambian los filtros y el aviso «“X” fue modificada» ofrece Actualizar (sobrescribe ese favorito) o Guardar como nueva. Antes cada fila tenía un icono de disquete (save, «Actualizar favorito») que duplicaba esa acción y no se entendía; se retiró, igual que el antiguo sync, que además sobrescribía cualquier favorito sin cambios ni confirmación y lo marcaba activo.
    • edit · «Editar favorito»: abre el panel con el nombre y los filtros guardados de ese favorito; no lo aplica ni cambia los filtros de la tabla.
    • delete · «Eliminar favorito»: pide confirmación en la propia fila. Si era el activo, deja de estarlo pero los filtros aplicados no cambian; si era el predeterminado, pierde esa preferencia y el aviso lo indica.
    • Visibilidad: con puntero fino las dos acciones aparecen al pasar el mouse sobre la fila y al enfocar cualquiera de sus controles (:focus-within), y solo entonces ocupan espacio: en reposo el nombre usa todo el ancho disponible (respetando el check y los márgenes); al mostrarse las acciones el nombre se acorta y se trunca con puntos suspensivos sin superponerse a los botones; al retirar el mouse o el foco las acciones se ocultan (max-width: 0, siguen siendo enfocables) y el nombre recupera el ancho. La altura de la fila no cambia. Las acciones permanecen visibles mientras el foco esté dentro de la fila (:focus-within). Si el nombre queda truncado, el tooltip reutilizable (Tooltip, detección con useIsTruncated) muestra el texto completo a un costado (side="left", para no tapar los favoritos vecinos de arriba o abajo) y, como todos los tooltips, tras una pausa de 1 s con el mouse (ver components/tooltips.md); si cabe, no hay tooltip. (Este criterio reemplaza al anterior de reservar de forma permanente el espacio de los iconos.) Si el usuario está usando un toque (no un mouse) se sustituyen por un botón «Más acciones» (more_vert, DropdownMenu) con las mismas dos opciones; usarlo no cierra el desplegable. El tipo de puntero no se deduce de la pantalla (pointer: coarse también lo reportan portátiles táctiles y navegadores integrados aunque se usen con mouse): usePointerModality (src/lib/pointer-modality.ts) marca <html data-sgd-pointer="mouse|touch"> con el último puntero real (mouse/lápiz → mouse, toque → touch; parte de any-pointer: fine) y el CSS de .sgd-fav-actions/.sgd-fav-more depende de ese atributo. El ancho de las acciones no se anima (animarlo hacía que los botones desbordaran un instante y asomaba un scroll horizontal al pasar el mouse). Nombres accesibles: «Editar favorito X», «Eliminar favorito X», «Más acciones de X».
  • Reinicio tras guardar: solo si el guardado tiene éxito (aviso «Búsqueda guardada» y el favorito aparece en la lista) el formulario se reinicia: nombre vacío (la próxima apertura calcula una nueva sugerencia con los filtros actuales), sin error y con «Incluir el texto del buscador» y «Filtro por defecto» desmarcados (estas casillas parten siempre desmarcadas). No se conserva el id del favorito creado, de modo que guardar otra vez siempre crea un registro nuevo; actualizar uno existente solo ocurre con las acciones explícitas «Editar» o «Actualizar». El reinicio no modifica los filtros aplicados a la tabla, la selección de filas ni las preferencias de los favoritos ya guardados (el «por defecto» anterior se conserva hasta que se marque otro). Si el guardado falla (nombre vacío, repetido o demasiado largo) el formulario conserva nombre, casillas y el error junto al campo para reintentar.
  • Favoritos: «Guardar búsqueda como favorito» pide un nombre obligatorio (único, ≤ 60 caracteres) y permite incluir o no el texto del buscador; no guarda filas seleccionadas. Un favorito se aplica a la vez y reemplaza filtros y texto (si no incluye texto, lo limpia). El activo se marca con ✓. Si se modifican los criterios aparece «fue modificada» con Actualizar / Guardar como nueva (nunca se sobrescribe solo). Por fila: actualizar con los criterios actuales, renombrar y eliminar (con confirmación).
  • Fechas relativas se guardan como regla (fechaRel=7d) y se recalculan al reutilizarlas; los rangos exactos se conservan.
  • Validación: al aplicar o listar, cada favorito se valida contra la configuración vigente (campos/opciones que ya no existen, reglas o fechas inválidas). Se muestra ⚠ y, al aplicarlo, un aviso con los problemas; se aplica solo lo vigente y el usuario corrige actualizando el favorito.
  • Accesibilidad: botón con aria-label («Más filtros, 2 criterios activos»), grupos con título, checkboxes con etiqueta, foco visible, Esc cierra, el foco vuelve al botón al cerrar menú o panel.

Persistencia de favoritos

Se guardan en el backend (SGD_AUTH.TRS_SAVED_SEARCH, GET/POST /saved-searches, PATCH/DELETE /saved-searches/:id), escopados al usuario autenticado (actorFromContext en el handler, no un userId elegible por el cliente) y a la tabla (tableId) -- sincronizan entre dispositivos y navegadores del mismo usuario. favorites.ts (useTableFavorites) llama a features/table-favorites/api.client.ts; las mutaciones (save/edit/update/rename/ remove) son optimistas (cambian en pantalla al instante, revierten con un aviso si la llamada falla). userId en useTableFavorites sigue siendo necesario para separar la carga por tabla+ usuario en la URL del listado inicial, aunque la autorización real la pone el token de sesión, no ese parámetro. Los favoritos no amplían permisos: solo reaplican criterios y la consulta sigue ejecutándose con los permisos de la sesión.

Configurar una bandeja (FilterField)

const FILTROS: FilterField<Doc>[] = [   // fuera del componente (referencia estable)
  { id: "estado", label: "Estado", type: "select", options: ["EMITIDO", "FIRMADO"] },        // multi (OR) por defecto
  { id: "responsable", label: "Responsable", type: "select", options: LISTA_LARGA },         // >8 opciones → buscador interno
  { id: "firma", label: "Pendiente de firma", type: "boolean" },
  { id: "fecha", label: "Fecha de emisión", type: "dateRange",
    fromKey: "fechaDesde", toKey: "fechaHasta", relKey: "fechaRel", presets: ["hoy", "semana", "7d", "mes"],
    columnId: "fechaEmision", accessor: (r) => r.fechaIso },                                 // cliente
  { id: "asunto", label: "Asunto", type: "text" },                                           // solo en el panel lateral
];
  • Cliente (todos los datos en memoria): useSgdTable({ clientSearch: true, filterFields: FILTROS }) y <SgdDataTable searchable favorites={{ tableId: "mi-bandeja", userId }} … />. favorites omitido/false = sin favoritos; sin filterFields = sin filtros; sin searchable = sin buscador.
  • Servidor (ejemplo: documents-emission-table.tsx, emission-query.ts, emission-filter-config.ts): los criterios viajan en la URL (múltiples con |: estado=FIRMADO|PENDIENTE); navigate({ q, filters }) con page=1; router.replace en transición para el buscador (sin historial; Next descarta navegaciones superadas, evitando que una respuesta antigua reemplace una reciente) y aria-busy mientras carga. La consulta debe resolver selectedOptions, resolveDateRange (recalcula la regla relativa) y filtrar todo el conjunto antes de paginar. Si el servicio no admite un filtro, no se ofrece (no hay área, remitente ni responsable en el modelo actual de Emisión).

8. Compatibilidad de temas

El componente utiliza los tokens de diseño compartidos (--sgd-radius-control, --sgd-table-row-height, --popover, --border, --primary, --muted). Se adapta uniformemente a:

  • Theme LATAM: Modos claro y oscuro.
  • Theme Adobe Spectrum: Modos claro y oscuro.
  • Theme Institucional: Modos claro y oscuro.

9. Columnas redimensionables (useSgdTable().resize y TableColumnResizer)

El SGD incorpora soporte de columnas con ancho ajustable (drag-to-resize) integrado en la arquitectura compartida del Design System, compatible con tablas con y sin buscador:

  1. Separador interactivo y guía visual vertical en dos fases:
    • Cada cabecera de columna TableHead o TableHeadSortable integra TableColumnResizer en su borde derecho con área de interacción de 24 px (w-6 -mr-3), manteniendo fina la línea y el último separador dentro de la tabla y cursor: col-resize.
    • Al pasar el mouse sobre la columna (th): aparece a su derecha la línea divisoria en plomito (w-[1.5px] bg-[var(--sgd-table-resize-line)]), únicamente a la altura del header (h-full), manteniendo la interfaz despejada y limpia.
    • Al situar el cursor sobre la línea divisoria de la cabecera o al arrastrar: se activa la guía a la altura total de la tabla (table.offsetHeight - th.offsetTop) como una única línea continua (w-[1.5px] bg-[var(--sgd-table-resize-line)] z-40, con token --sgd-table-resize-line: #94a3b8 en claro y #525462 en oscuro) que corta a través de todas las filas hasta la arista inferior de la tabla, sin desfasarse ni duplicar líneas en el encabezado.
  2. Sincronización fluida y límites (min/max):
    • El ancho de la cabecera <th> y sus celdas <td> se actualizan de forma sincronizada y fluida durante el arrastre.
    • Cada columna define anchos mínimos (minWidth) y máximos (maxWidth) en su configuración (SgdColumn.minWidth / maxWidth), impidiendo que el contenido quede ilegible o colapse a cero.
  3. Separación estricta de eventos:
    • Clic en la cabecera: activa el ordenamiento funcional (TableHeadSortable).
    • Arrastre del separador: redimensiona el ancho de la columna sin activar accidentalmente el ordenamiento ni seleccionar texto (e.preventDefault(), e.stopPropagation(), document.body.style.userSelect = 'none').
  4. Adaptación de celdas de descripción:
    • Al estrechar o ensanchar columnas de texto largo (como «Descripción» o «Asunto»), TableCellDescription con className="max-w-full" adapta las 2 líneas y la altura de la fila suavemente (TableRow min-h), sin cortar palabras por la mitad ni forzar scroll vertical en la fila.
  5. Desplazamiento horizontal protegido y contención vertical:
    • Si la suma total de anchos de las columnas supera el espacio disponible en pantalla, el contenedor con overflow-x-auto overflow-y-hidden relative permite desplazamiento horizontal limpio dentro de la tabla sin desbordar la página y anulando cualquier scroll vertical interno no deseado. Los menús emergentes no se recortan al desplegarse a través de Radix Portal.
  6. Persistencia por tabla y usuario:
    • Los anchos se almacenan en localStorage bajo la clave sgd_col_widths_${tableId}.
    • Se conservan al ordenar registros, buscar, cambiar de página o mostrar/ocultar columnas con el selector.
  7. Restablecimiento ágil:
    • Doble clic en el separador: restaura inmediatamente el ancho de la columna individual a su valor predeterminado (resetColumnWidth).
    • Botón «Anchos» en el selector de visibilidad: cuando existen anchos personalizados (hasCustomWidths: true), aparece el botón de restablecimiento en la cabecera de TableColumnVisibilitySelector.
    • Restablecimiento global: resetWidths() restaura todas las columnas de la tabla simultáneamente.
  8. Alternativa accesible por teclado:
    • El separador es un elemento interactivo con tabIndex={0}, role="separator", aria-orientation="vertical", aria-valuenow, aria-valuemin y aria-valuemax.
    • Permite ajustar el ancho con ArrowLeft (-10 px) y ArrowRight (+10 px), con Shift para saltos de 25 px, Home para ir al ancho mínimo, End para ir al ancho máximo y Enter para restablecer el valor inicial.

Ajuste sin arrastre (lote 2, 15-oct-2026)

Clic, toque o Espacio en el separador abre el Popover con WidthControls (Button/Input existentes): reducir, aumentar, valor entero y Aplicar, y Restablecer. Conserva límites, callbacks y persistencia. Escape devuelve el foco al separador; el envío del ancho no se propaga a formularios contenedores. Flechas/Home/End y Enter para restablecer se mantienen. Teclado por sí solo no satisface 2.5.7.


10. Estado vacío guiado («Guided Empty Table State»)

Cuando una tabla no tiene registros (y no hay filtros ni búsqueda activos), el SGD ofrece una experiencia visual guiada inspirada en los patrones de onboarding de ERP modernos, implementada a través de TableEmptyState (src/components/ui/table-empty-state.tsx) y soportada nativamente mediante la propiedad guidedEmpty en DataTable y SgdDataTable.

1. Estructura visual en dos capas

  1. Fondo con estructura fantasma («Ghost Table»):
    • Conserva los encabezados reales (th), anchos configurados o ajustados por el usuario y entre 5 y 8 filas de ejemplo atenuadas (opacity-[0.28]).
    • Las celdas fantasma muestran barras de texto con anchos pseudo-aleatorios deterministas (basados en fórmulas puras (rowIndex * 37 + colIndex * 19) % ... para garantizar paridad absoluta entre SSR y cliente, evitando desajustes de hidratación), avatares, chips de estado y puntos de acción.
    • Las filas fantasma son completamente inertes: marcadas con aria-hidden="true", atributo inert y data-empty-state, con background-color: transparent !important y cursor predeterminado, impidiendo selecciones o efectos hover fantasma.
  2. Capa superpuesta centrada («Overlay»):
    • Cubre todo el cuerpo de la tabla (debajo del encabezado) con un velo bg-background/55 y desenfoque leve de 2 px, sin caja ni borde propios; el contenido va centrado y el fondo tenue sigue percibiéndose. El contraste del texto se verificó con axe en las seis apariencias.
    • Contiene una ilustración vectorial lineal (TableEmptyIllustration, 120×88 px) adaptada al lenguaje visual del SGD en modos claro y oscuro, un título conciso (title), texto de ayuda (description), botón de acción principal destacado (actionLabel, onAction o actionHref), y opción de acción secundaria (secondaryActionLabel).
    • Altura mínima: como el overlay es absoluto, DataTable da a la zona de la tabla vacía min-h-[24rem] (el alto no depende solo de las filas fantasma) y el overlay empieza justo debajo del encabezado real (se mide con ResizeObserver: en pantallas angostas los títulos se parten en dos líneas y el encabezado mide más de 44 px). En pantallas angostas la ilustración y los espacios se reducen (max-sm:) para que la tarjeta quepa sin tapar el encabezado a 768, 375 y 320 px.

2. Microinteracción de clic («Nudge / Comienza aquí»)

  • Clic en la zona vacía: Si el usuario hace clic en cualquier área del cuerpo vacío o del overlay (excepto directamente sobre botones u otros controles interactivos), no se dispara la acción automáticamente. En su lugar, se enfoca el botón de acción principal y se dispara una microinteracción sutil (.sgd-empty-nudge: animación CSS de 480 ms con escala de 1.04 y aro luminoso con color-mix(in srgb, var(--primary) 40%, transparent)), guiando la mirada y la atención del usuario hacia el punto de inicio («comienza por aquí»).
  • Clic directo en el botón: Ejecuta inmediatamente la función asignada (onAction) o navega al enlace (actionHref).
  • Accesibilidad y movimiento reducido:
    • Respeta @media (prefers-reduced-motion: reduce): la regla global del Design System deja la duración de las animaciones en 0, así que no hay animación y el clic solo enfoca el botón principal (sin escala ni vibración).
    • El botón es plenamente accesible por teclado (foco visible, activación con Enter/Espacio).

2 bis. Botón principal: no se repite

Si la pantalla ya tiene su botón principal en la cabecera (p. ej. «Nuevo documento»), el estado vacío no lo duplica: se usa actionTarget (en guidedEmpty; la bandeja de emisión lo recibe en emptyGuide.actionTarget) con el id de ese botón, y un clic en la zona vacía lo enfoca y lo resalta con la misma animación. El botón de la cabecera lleva id={PRIMARY_ACTION_ID} (src/components/ui/empty-state-target.ts). Sin actionTarget, el estado vacío conserva su propio botón (actionLabel + actionHref/onAction). Esto aplica a cualquier tabla con la misma lógica (acción principal ya visible arriba).

2 ter. Flujo de pasos bajo la descripción

steps (lista de { title, hint }, en guidedEmpty y en TableEmptyStateProps) dibuja bajo la descripción un flujo corto de pasos numerados unidos por una línea (EmptyFlow, en table-empty-state.tsx): lista ordenada decorativa y no interactiva, con nombre accesible propio (label), hasta 5 pasos en una fila. Con pasos, el bloque mide hasta 36 rem y la descripción conserva 28 rem para no estirarse. Las bandejas de documentos lo usan así (constantes en documents-emission-table.tsx): administrativos (Emisión y Documentos en Proyecto: Crea · Redacta · Para despacho · Firma · Emite; el borrador se edita hasta pasar a despacho y luego solo queda la firma), personales (Crea · Redacta · Firma · Emite; se edita hasta firmar) y Recepción (Llega · Recibe · Atiende · Archiva).

Con fillViewport (por defecto) la zona vacía mide el resto de la pantalla (100dvh − 21rem, mínimo 24 rem) para que la tarjeta llegue abajo sin desplazar la página; las filas fantasma de sobra se recortan. Las demostraciones del Design System lo desactivan (fillViewport: false).

3. Integración con SgdDataTable y filtrado

  • En SgdDataTable, si guidedEmpty está configurado pero el usuario realiza una búsqueda o aplica filtros que reducen los resultados a 0 (hasSearch || hasFilters), el estado guiado se desactiva automáticamente para mostrar el mensaje contextual «No se encontraron resultados» junto con los botones para limpiar la búsqueda y filtros activos.

3 bis. Dónde se aplica hoy

  • Bandejas del funcionario (tabla compartida DocumentsEmissionTable con la prop emptyGuide, sin búsqueda ni filtros activos): Emitidos («Aún no has emitido documentos»; el clic resalta el «Nuevo documento» de la cabecera), Personales («Aún no tienes documentos personales»; el clic resalta el botón de la cabecera), Recepción («No tienes documentos por recibir», marca de bandeja de entrada, sin botón) y Vistos buenos («No tienes vistos buenos pendientes», marca de verificación, sin botón). Con búsqueda o filtros se mantiene «Ningún documento coincide con los criterios» y sus botones de limpiar.
  • Interoperabilidad (PIDE): Despacho y Recepción (guidedEmpty en SgdDataTable, sin botón; SgdDataTable lo omite con búsqueda activa). La marca del círculo de la ilustración (TableEmptyIllustration glyph) puede ser plus, check, inbox o send.
  • Sin aplicar todavía: Mesa de Partes (registro, cargo, consulta y seguimiento; sus tablas son propias y el vacío llega casi siempre con filtros) y las listas de /admin (AdminDataTable).

4. Ejemplo de uso

<SgdDataTable
  sgd={sgd}
  ariaLabel="Bandeja de expedientes"
  guidedEmpty={{
    title: "Aún no tienes expedientes",
    description: "Empieza creando tu primer expediente; aquí verás su seguimiento y trazabilidad.",
    actionLabel: "Crear expediente",
    actionIcon: "add",
    onAction: () => abrirModalNuevoExpediente(),
    rowCount: 6,
  }}
/>

11. Vistas del listado: lista y cuadrícula (views, useViewMode, DataTableCardGrid)

Una misma bandeja puede ofrecer dos vistas de los mismos datos: la tabla de siempre («Lista») y una cuadrícula de tarjetas («Cuadrícula»). El orden, la búsqueda, los filtros y la paginación son los de useSgdTable (o los del servidor), así que cambiar de vista no cambia los datos.

  • Conmutador: ViewModeSwitch (SegmentedControl con iconOnly: solo íconos, el nombre accesible y el tooltip del Design System salen de la etiqueta). Va en la barra de herramientas, junto al selector de columnas. Teclado de SegmentedControl: flechas para moverse y Enter o Espacio para elegir.
  • Memoria: la vista elegida se guarda en el navegador por usuario y pantalla (sgd_view_mode_[usuario_]{viewId}, igual que los favoritos y las columnas). No se sincroniza entre dispositivos. Un valor guardado desconocido se ignora y queda la lista.
  • Con SgdDataTable: pasar views={{ viewId, userId, card: (fila) => <contenido de la tarjeta /> }}. Sin card no hay conmutador ni cuadrícula.
  • Con la barra y la tabla compuestas a mano (p. ej. DocumentsEmissionTable): useViewMode({ viewId, userId }, true), <ViewModeSwitch /> como hijo de DataTableToolbar y, cuando la vista es «grid» y hay filas, <DataTableCardGrid sgd renderCard … /> en lugar de DataTable.
  • Selección: sigue siendo solo con la casilla de cada tarjeta (nunca por clic en la tarjeta); hay una casilla «Seleccionar todos los … de esta página» sobre la cuadrícula. La barra de acciones por lote es la misma.
  • Sin filas (o con búsqueda o filtros sin resultados) se muestra siempre la tabla, con su estado vacío guiado o su mensaje de «sin resultados»: no hay un segundo estado vacío.
  • Dónde está hoy: Emisión de documentos (administrativos, personales y recepción, por DocumentsEmissionTable) y los ejemplos 2 y 5 de /design-system/componentes. Pendientes: las demás bandejas y un kanban de lectura (ver docs/estado/).