Marca: ·

Table

Table

Tabla semántica con columnas tipadas: fechas e importes formateados por idioma (importes en céntimos, alineados al final), celdas a medida y scroll horizontal accesible con teclado. Las filas alternan color por defecto; striped en false lo desactiva.

Últimos movimientos
Estado
21/10/26Nómina octubreConfirmado1850,00 €
20/10/26SupermercadoConfirmado-45,90 €
19/10/26Recibo luzPendiente-63,12 €
18/10/26Transferencia a AnaConfirmado-250,00 €
17/10/26Cargo duplicadoDevuelto-19,99 €
15/10/26Seguro hogarConfirmado-214,80 €
<flt-table [columns]="columns" [rows]="rows" rowKey="id" caption="Últimos movimientos">
  <ng-template fltTableCell="status" let-row>
    <flt-tag [variant]="variant(row['status'])" size="sm">{{ row['status'] }}</flt-tag>
  </ng-template>
</flt-table>

columns = [
  { key: 'date', header: 'Fecha', format: 'date' },
  { key: 'concept', header: 'Concepto' },
  { key: 'status', header: 'Estado' },
  { key: 'amount', header: 'Importe', format: 'currency' }, // céntimos
];
<FltTable :columns="columns" :rows="rows" row-key="id" caption="Últimos movimientos">
  <template #cell-status="{ value }">
    <FltTag :variant="statusVariant[value]" size="sm">{{ value }}</FltTag>
  </template>
</FltTable>

Selección

Casillas por fila y «seleccionar todas» con estado intermedio. La selección va por clave de fila, así que sobrevive al reordenar: ordena con algo marcado y compruébalo.

Ningún movimiento seleccionado

Movimientos para conciliar
Estado
21/10/26Nómina octubreConfirmado1850,00 €
20/10/26SupermercadoConfirmado-45,90 €
19/10/26Recibo luzPendiente-63,12 €
18/10/26Transferencia a AnaConfirmado-250,00 €
17/10/26Cargo duplicadoDevuelto-19,99 €
15/10/26Seguro hogarConfirmado-214,80 €
<flt-table [columns]="columns" [rows]="rows" rowKey="id" rowLabelKey="concept"
  caption="Movimientos para conciliar" selectable [(selection)]="selection" />
<FltTable v-model:selection="selection" :columns="columns" :rows="rows" row-key="id"
  row-label-key="concept" caption="Movimientos para conciliar" selectable />

Edición en línea

editable añade un botón Editar por fila: sus celdas con editor pasan a campos (texto, importe, fecha u opciones) y la tabla se bloquea hasta Guardar o Cancelar (Escape cancela). Al guardar se valida; si todo está bien, la tabla emite rowSave y espera a que el consumidor responda con filas nuevas o con rowErrors. Aquí el «servidor» tarda 0,7 s y rechaza importes de más de 10.000 €; los movimientos devueltos no se pueden editar.

Movimientos editables
FechaConceptoEstadoImporteEdición
21/10/26Nómina octubreConfirmado1850,00 €
20/10/26SupermercadoConfirmado-45,90 €
19/10/26Recibo luzPendiente-63,12 €
18/10/26Transferencia a AnaConfirmado-250,00 €
17/10/26Cargo duplicadoDevuelto-19,99 €
15/10/26Seguro hogarConfirmado-214,80 €

<flt-table [columns]="columns" [rows]="rows()" [canEdit]="canEdit" [rowErrors]="rowErrors()"
  rowKey="id" caption="Movimientos editables" editable (rowSave)="save($event)" />

columns = [
  { key: 'concept', header: 'Concepto', editor: 'text',
    validate: (v) => (String(v).trim() ? null : 'Escribe un concepto') },
  { key: 'amount', header: 'Importe', format: 'currency', editor: 'currency' },
];

// Sin Promises: la tabla espera a que lleguen filas nuevas o errores.
save({ key, changes }) {
  this.api.save(key, changes).subscribe({
    next: (rows) => this.rows.set(rows),
    error: (errors) => this.rowErrors.set(errors), // { campo: mensaje, _row: mensaje }
  });
}
<FltTable :columns="columns" :rows="rows" :can-edit="canEdit" :row-errors="rowErrors"
  row-key="id" caption="Movimientos editables" editable @row-save="save" />

function save({ key, changes }) {
  api.save(key, changes).then(
    (newRows) => (rows.value = newRows),
    (errors) => (rowErrors.value = errors), // { campo: mensaje, _row: mensaje }
  );
}

Vista de tarjetas

Con cards, cuando el contenedor de la tabla baja de 36rem cada fila pasa a ser una tarjeta: título con rowLabelKey, valores con su etiqueta y acciones al pie; los totales quedan como tarjeta resumen. Aquí la tabla vive en un contenedor de 30rem, así que se ve en tarjetas también en escritorio. La semántica de tabla se mantiene para el lector de pantalla.

Movimientos en tarjetas
EstadoVerDevolver
21/10/26SupermercadoConfirmado-5,00 €
20/10/26Recibo luzConfirmado-84,19 €
19/10/26GasolineraConfirmado-13,38 €
18/10/26FarmaciaPendiente-92,57 €
17/10/26RestauranteConfirmado-21,76 €
16/10/26Recibo aguaDevuelto-100,95 €
15/10/26LibreríaConfirmado-30,14 €
14/10/26TransporteConfirmado-109,33 €
13/10/26SupermercadoConfirmado-38,52 €
12/10/26Recibo luzPendiente-117,71 €
Total -4422,24 €
<flt-table [columns]="columns" [rows]="rows" [rowActions]="actions" rowKey="id"
  rowLabelKey="concept" caption="Movimientos en tarjetas" selectable paginated cards
  [(selection)]="selection" />
<FltTable v-model:selection="selection" :columns="columns" :rows="rows" :row-actions="actions"
  row-key="id" row-label-key="concept" caption="Movimientos en tarjetas" selectable paginated cards />

Detalle y totales

expandable añade un botón por fila que abre su detalle debajo (el rayado sigue contando solo las filas de datos). Las columnas con total: 'sum' cierran la tabla con una fila de totales sobre todo el conjunto filtrado, no solo la página; en modo servidor, totals los trae del backend.

Movimientos con detalle y total
DetalleEstado
21/10/26SupermercadoConfirmado-5,00 €
20/10/26Recibo luzConfirmado-84,19 €
19/10/26GasolineraConfirmado-13,38 €
18/10/26FarmaciaPendiente-92,57 €
17/10/26RestauranteConfirmado-21,76 €
16/10/26Recibo aguaDevuelto-100,95 €
15/10/26LibreríaConfirmado-30,14 €
14/10/26TransporteConfirmado-109,33 €
13/10/26SupermercadoConfirmado-38,52 €
12/10/26Recibo luzPendiente-117,71 €
Total -4422,24 €
<flt-table [columns]="columns" [rows]="rows" rowKey="id" caption="Movimientos con detalle y total"
  expandable paginated>
  <ng-template fltTableExpanded let-row>
    <flt-description-list [items]="detail(row)" />
  </ng-template>
</flt-table>

columns = [..., { key: 'amount', header: 'Importe', format: 'currency', total: 'sum' }];
<FltTable :columns="columns" :rows="rows" row-key="id" caption="Movimientos con detalle y total"
  expandable paginated>
  <template #expanded="{ row }">
    <FltDescriptionList :items="detail(row)" />
  </template>
</FltTable>

Columnas, columna fija y densidad

columnChooser añade un selector de columnas (una divulgación con casillas; Concepto no se puede ocultar y siempre queda al menos una). stickyFirstColumn deja fijas la casilla y la primera columna al desplazar en horizontal. La densidad es la prop size, aquí conectada a un FltSegmented.

Movimientos con todas sus columnas
CategoríaCanalEstado
Supermercado21/10/26AlimentaciónTarjetaConfirmado-5,00 €
Recibo luz20/10/26HogarReciboConfirmado-84,19 €
Gasolinera19/10/26TransporteTarjetaConfirmado-13,38 €
Farmacia18/10/26SaludTarjetaPendiente-92,57 €
Restaurante17/10/26OcioBizumConfirmado-21,76 €
Recibo agua16/10/26HogarReciboDevuelto-100,95 €
Librería15/10/26CulturaTarjetaConfirmado-30,14 €
Transporte14/10/26TransporteTransferenciaConfirmado-109,33 €
Supermercado13/10/26AlimentaciónTarjetaConfirmado-38,52 €
Recibo luz12/10/26HogarReciboPendiente-117,71 €
<flt-table [columns]="columns" [rows]="rows" [size]="size()" rowKey="id"
  caption="Movimientos con todas sus columnas" selectable paginated
  columnChooser stickyFirstColumn [(hiddenColumns)]="hidden">
  <flt-segmented fltTableFilters [options]="densityOptions" [(value)]="density" label="Densidad" size="sm" />
</flt-table>

columns = [
  { key: 'concept', header: 'Concepto', hideable: false },
  ...
];
<FltTable v-model:hidden-columns="hidden" :columns="columns" :rows="rows" :size="density"
  row-key="id" caption="Movimientos con todas sus columnas" selectable paginated
  column-chooser sticky-first-column>
  <template #filters>
    <FltSegmented v-model="density" :options="densityOptions" label="Densidad" size="sm" />
  </template>
</FltTable>

Búsqueda y filtros

searchable añade un buscador que no distingue mayúsculas ni tildes («libreria» encuentra «Librería») y mira el texto tal como se ve. Los controles de filtro los pone el consumidor y la tabla aplica filters (texto, opciones o rango). Cada cambio vuelve a la primera página y el número de resultados se anuncia al lector de pantalla.

Buscar en el histórico
Estado
21/10/26SupermercadoConfirmado-5,00 €
20/10/26Recibo luzConfirmado-84,19 €
19/10/26GasolineraConfirmado-13,38 €
18/10/26FarmaciaPendiente-92,57 €
17/10/26RestauranteConfirmado-21,76 €
16/10/26Recibo aguaDevuelto-100,95 €
15/10/26LibreríaConfirmado-30,14 €
14/10/26TransporteConfirmado-109,33 €
13/10/26SupermercadoConfirmado-38,52 €
12/10/26Recibo luzPendiente-117,71 €
<flt-table [columns]="columns" [rows]="rows" [filters]="filters()" rowKey="id"
  caption="Buscar en el histórico" searchable paginated>
  <flt-segmented fltTableFilters [options]="statusOptions" [(value)]="status" label="Estado" size="sm" />
</flt-table>

filters = computed(() =>
  this.status() === 'all' ? {} : { status: { type: 'options', values: [this.status()] } },
);
<FltTable :columns="columns" :rows="rows" :filters="filters" row-key="id"
  caption="Buscar en el histórico" searchable paginated>
  <template #filters>
    <FltSegmented v-model="status" :options="statusOptions" label="Estado" size="sm" />
  </template>
</FltTable>

const filters = computed(() =>
  status.value === 'all' ? {} : { status: { type: 'options', values: [status.value] } },
);

Acciones por fila y en bloque

rowActions añade columnas finales con un botón por fila (Devolver se desactiva en abonos y en lo ya devuelto). Con acciones en bloque, la barra queda siempre visible y cuenta la selección, para que la tabla no salte al marcar.

Ninguna acción todavía

Ninguna fila seleccionada
Movimientos con acciones
EstadoVerDevolver
21/10/26Nómina octubreConfirmado1850,00 €
20/10/26SupermercadoConfirmado-45,90 €
19/10/26Recibo luzPendiente-63,12 €
18/10/26Transferencia a AnaConfirmado-250,00 €
17/10/26Cargo duplicadoDevuelto-19,99 €
15/10/26Seguro hogarConfirmado-214,80 €
<flt-table [columns]="columns" [rows]="rows" rowKey="id" caption="Movimientos con acciones"
  selectable [rowActions]="actions" [(selection)]="selection" (rowAction)="run($event)">
  <flt-button fltTableBulk variant="outline" size="sm" (pressed)="markReviewed()">
    Marcar como revisados
  </flt-button>
</flt-table>

actions = [
  { id: 'view', label: 'Ver' },
  { id: 'refund', label: 'Devolver', disabled: (row) => row.amount > 0 },
];
<FltTable v-model:selection="selection" :columns="columns" :rows="rows" row-key="id"
  caption="Movimientos con acciones" selectable :row-actions="actions" @row-action="run">
  <template #bulk-actions="{ selection }">
    <FltButton variant="outline" size="sm" @pressed="markReviewed(selection)">
      Marcar como revisados
    </FltButton>
  </template>
</FltTable>

Paginación

Con paginated, la tabla trocea las filas en cliente: selector de filas por página, rango visible y controles de página. Cambiar el orden o el tamaño de página vuelve a la primera. «Seleccionar todas» actúa sobre la página visible.

Histórico de movimientos
Estado
21/10/26SupermercadoConfirmado-5,00 €
20/10/26Recibo luzConfirmado-84,19 €
19/10/26GasolineraConfirmado-13,38 €
18/10/26FarmaciaPendiente-92,57 €
17/10/26RestauranteConfirmado-21,76 €
16/10/26Recibo aguaDevuelto-100,95 €
15/10/26LibreríaConfirmado-30,14 €
14/10/26TransporteConfirmado-109,33 €
13/10/26SupermercadoConfirmado-38,52 €
12/10/26Recibo luzPendiente-117,71 €
<flt-table [columns]="columns" [rows]="rows" rowKey="id" caption="Histórico de movimientos"
  paginated [(page)]="page" [(pageSize)]="pageSize" />
<FltTable v-model:page="page" v-model:page-size="pageSize" :columns="columns" :rows="rows"
  row-key="id" caption="Histórico de movimientos" paginated />

Rejilla con teclado

Con grid, la tabla es una rejilla con un solo punto de tabulación: las flechas van de celda en celda, Inicio y Fin al principio y fin de la fila, Ctrl+Inicio y Ctrl+Fin a las esquinas, y RePág y AvPág saltan 5 filas sin cambiar de página. En una celda con un solo control (casilla, orden, enlace) el foco va directo a él. Las celdas con varios controles no están soportadas todavía.

Movimientos para revisar
EstadoDetalle
21/10/26SupermercadoConfirmado-5,00 €Ver detalle
20/10/26Recibo luzConfirmado-84,19 €Ver detalle
19/10/26GasolineraConfirmado-13,38 €Ver detalle
18/10/26FarmaciaPendiente-92,57 €Ver detalle
17/10/26RestauranteConfirmado-21,76 €Ver detalle
16/10/26Recibo aguaDevuelto-100,95 €Ver detalle
15/10/26LibreríaConfirmado-30,14 €Ver detalle
14/10/26TransporteConfirmado-109,33 €Ver detalle
13/10/26SupermercadoConfirmado-38,52 €Ver detalle
12/10/26Recibo luzPendiente-117,71 €Ver detalle
<flt-table [columns]="columns" [rows]="rows" rowKey="id" caption="Movimientos para revisar"
  selectable paginated grid [(selection)]="selection">
  <ng-template fltTableCell="detail" let-row>
    <a class="flt-link" [href]="'#movimiento-' + row['id']">Ver detalle</a>
  </ng-template>
</flt-table>
<FltTable v-model:selection="selection" :columns="columns" :rows="rows" row-key="id"
  caption="Movimientos para revisar" selectable paginated grid>
  <template #cell-detail="{ row }">
    <a class="flt-link" :href="'#movimiento-' + row.id">Ver detalle</a>
  </template>
</FltTable>

Modo servidor

En modo servidor (mode «server») la tabla no ordena ni trocea: emite queryChange con orden, página, tamaño, búsqueda y filtros, y pinta la página que recibe; la búsqueda espera a que dejes de teclear; totalRows da el total para el rango y los controles. Aquí el «servidor» tiene 245 movimientos y tarda 0,7 s, y mientras tanto la tabla muestra el estado de carga.

Movimientos (paginados en el servidor)
Estado
<flt-table [columns]="columns" [rows]="rows()" rowKey="id" caption="Movimientos"
  mode="server" paginated searchable [totalRows]="total()" [loading]="loading()" (queryChange)="load($event)" />

async load(query: FltTableQuery) {
  this.loading.set(true);
  const page = await this.api.movements(query); // { sort, page, pageSize, search, filters }
  this.rows.set(page.rows);
  this.total.set(page.total);
  this.loading.set(false);
}
<FltTable :columns="columns" :rows="rows" row-key="id" caption="Movimientos"
  mode="server" paginated searchable :total-rows="total" :loading="loading" @query-change="load" />

async function load(query: FltTableQuery) {
  loading.value = true;
  const page = await api.movements(query); // { sort, page, pageSize, search, filters }
  rows.value = page.rows;
  total.value = page.total;
  loading.value = false;
}

Cargando y vacío

Mientras carga, filas de esqueleto y aria-busy; sin filas, un mensaje que ocupa todas las columnas.

Cargando movimientos
Estado
Movimientos de noviembre
Estado
No hay movimientos en este periodo.
<flt-table [columns]="columns" rowKey="id" caption="Cargando movimientos" loading />
<flt-table [columns]="columns" [rows]="[]" rowKey="id" caption="Movimientos de noviembre"
  emptyText="No hay movimientos en este periodo." />
<FltTable :columns="columns" row-key="id" caption="Cargando movimientos" loading />
<FltTable :columns="columns" :rows="[]" row-key="id" caption="Movimientos de noviembre"
  empty-text="No hay movimientos en este periodo." />