Acciones y selección
Menús y barras rápidas de acciones por fila, selección de filas, acciones masivas y seleccionar todos los resultados que coinciden con la consulta actual.
Acciones de fila
RowActions renderiza las acciones de una fila de las dos formas:
{
key: 'actions', header: '', sticky: 'right', width: '7rem', exportable: false,
render: (item, i) => (
<RowActions
item={item}
index={i}
variant='inline' // botones de icono en la celda; 'menu' (default) los colapsa tras •••
maxInline={3} // pasado esto el excedente se pliega en un ••• al final
actions={[
{ label: 'Ver', icon: <Eye size={16} />, onClick: open },
{ label: 'Descargar', icon: <FileDown size={16} />, onClick: download },
{ label: 'Cancelar', icon: <X size={16} />, danger: true, onClick: cancel,
disabled: item => item.canceled && 'Ya está cancelada' },
]}
/>
),
}'inline' es un clic en vez de dos, para las acciones que un operador usa en
cada fila. Cada botón es sólo icono, con su label como nombre accesible y
como tooltip, así que cuesta un ancho fijo sin importar qué tan largo sea el
label — una acción sin icon cae de vuelta a renderizar el label, lo que
ensancha la columna y normalmente significa que pertenece al menú. disabled
devuelve la razón, que se vuelve el tooltip.
loading cubre la acción que hace un round trip al servidor — preparar una
descarga, mandar un correo. Cambia el icono por un spinner y bloquea un segundo
clic, que es lo que separa "no pasó nada" de "va en camino":
{ label: 'Descargar', icon: <FileDown size={16} />, onClick: download,
loading: item => downloading.has(item.id) }Quick actions. Marca una acción como quick (necesita un icon) y, en
dispositivos con hover, pasar el cursor por el ••• la desliza hacia la
izquierda como botón de icono, en la misma línea de la fila — un clic para la
acción que un operador usa en cada fila. Es un atajo, no el único camino: la
acción sigue apareciendo en el menú •••, así que en touch — donde el hover no
existe — no se pierde nada, solo vive un tap más adentro.
La barra perdona por diseño: sigue interactiva durante un breve periodo de
gracia después de que el cursor sale, así apuntar cruzando el hueco entre el
••• y un botón nunca pierde el objetivo; se oculta en cuanto una de sus
acciones corre, así una acción que abre un diálogo no deja la barra flotando; y
revelar la barra de una fila despide la de la anterior al instante, así barrer
la columna no deja estela.
Por defecto la barra se revela cuando el cursor llega al grupo del •••. Pon
rowActionsQuickReveal: 'row' en la config (o quickReveal='row' en un
RowActions propio) para revelarla desde cualquier punto de la fila — un paso
menos de puntería:
defineListConfig({ rowActions, rowActionsQuickReveal: 'row' /* … */ })Tanto el menú ••• de fila como el overflow del toolbar siguen el patrón de
teclado de menú de WAI-ARIA: ArrowUp/ArrowDown recorren los items (con wrap),
Home/End saltan a los extremos, Escape cierra.
Menú agrupado. Dale un group a las acciones y el menú ••• las agrupa
bajo ese título, separadas por divisores — el menú seccionado estilo Stripe.
Las acciones sin grupo van primero, sin título; los grupos siguen en orden de
primera aparición. El título es texto para el usuario: pásalo ya localizado:
rowActions: [
{
label: 'Descargar PDF',
icon: <FileDown size={16} />,
quick: true,
group: 'Acciones',
onClick: download,
},
{
label: 'Editar factura',
icon: <Pencil size={16} />,
quick: true,
group: 'Acciones',
onClick: edit,
},
{ label: 'Copiar ID', group: 'Acciones', onClick: copyId },
{ label: 'Ver cliente', group: 'Conexiones', onClick: viewCustomer },
]El mismo campo group existe en las acciones del toolbar: en pantallas chicas,
donde se pliegan al ••• del toolbar, el menú renderiza las mismas secciones
tituladas.
Variantes de paginación. 'sticky' flota dentro del flujo y no necesita
offset de viewport — por eso es el nuevo default. 'fixed' se clava al
viewport y, en una app con sidebar fijo, necesita paginationOffsetLeft para
librarlo:
<ListView
config={config}
paginationVariant='fixed'
paginationOffsetLeft='var(--app-sidebar-w)'
/>'inline' la renderiza estática al final de su contenedor. Dentro de una card
flex-column se asienta abajo aunque la lista quede corta o vacía — el caso que
antes obligaba a position: static !important.
Columnas fijadas. Dale a una columna sticky: 'left' | 'right' más un
width y se queda visible mientras la tabla scrollea en horizontal. Usa
'right' para la columna de acciones, para poder actuar sobre una fila sin
scrollear hasta el final:
{ key: 'actions', header: '', sticky: 'right', width: '64px', exportable: false, render: rowActions }Varias columnas pueden fijarse al mismo borde; sus offsets se apilan en orden
de columna. Una columna fijada sin width se deja sin fijar en vez de
colocarse en un offset equivocado.
Selección de filas y acciones masivas
Habilita checkboxes y una barra de selección con selection. La selección es por clave, sobrevive a la paginación y se limpia cuando cambia el dataset (búsqueda/filtros/orden/refresh) para que una selección obsoleta no se filtre:
import { Star, Trash2 } from 'lucide-react'
defineListConfig<Product>({
getItemKey: p => p.id, // requerido para una selección estable
selection: {
actions: [
{
label: 'Destacar',
icon: <Star size={16} />,
onClick: rows => featureMany(rows),
},
{
label: 'Eliminar',
icon: <Trash2 size={16} />,
variant: 'danger',
// Cada acción recibe las filas seleccionadas + helpers: { selectedKeys, clear }.
onClick: async (rows, { selectedKeys, clear }) => {
await deleteMany(selectedKeys) // ids, de getItemKey
clear() // limpia la selección tras una acción masiva exitosa
},
},
],
onSelectionChange: rows => setSelected(rows),
},
})Qué te da la selección. La selección se indexa por getItemKey, así que cada entrada tiene un id (la clave) y el objeto completo de la fila:
- El
onClick(selected, { selectedKeys, clear })de una acción masiva recibeselected(las filasT[]— incluso de otras páginas) yselectedKeys(sus ids degetItemKey). Usa los ids para unDELETE … WHERE id IN (…)yclear()para reiniciar después. onSelectionChange(selected, details)se dispara cuando cambia el conjunto:selectedes el mismoT[], ydetailstraemode,keys,excludedKeysy elcountresuelto — el modo all-matching que un arreglo pelón no puede expresar.- Para control total, usa el hook exportado
useRowSelectiondirectamente (selectedKeys,selectedItems,isSelected,toggle,toggleMany,clear).
Otras notas:
- La tabla gana una columna de checkbox separada con un encabezado seleccionar-toda-la-página (indeterminado cuando solo algunas están seleccionadas).
- Las filas se rastrean por clave, así que seleccionar entre páginas conserva los objetos completos para tu handler masivo — sin necesidad de React Query.
clearOnDataChange: falseconserva la selección al cambiar filtros/orden (por defecto se limpia).- Cuando
exportestá habilitado, la barra de selección también muestra Exportar selección (desactívalo conshowExport: false). - En vista de tarjetas,
ctx.selection(isSelected/toggle) permite que una tarjeta personalizada renderice su propio checkbox.
Bloquear o restringir los checkboxes. disabled: true conserva la columna pero rechaza todo toggle, así queda como indicador de solo lectura — úsalo cuando elegir filas no tiene sentido en el modo actual, porque una columna que desaparece y vuelve mueve la tabla bajo el lector, y esconderla pierde el estado que mostraba. La barra masiva, "exportar selección" y los atajos de selección se van con ella. selectableRow(item, key) restringe fila por fila; el checkbox del encabezado cubre entonces solo las filas seleccionables.
selection: {
disabled: mode === 'review', // indicador de solo lectura
selectableRow: row => row.status !== 'locked',
}Ambas compuertas se respetan en todo camino de escritura — el checkbox de fila, el del encabezado, el de la tarjeta, los atajos de teclado y el controller publicado. Lo único que selectableRow no alcanza es seleccionar las N coincidentes: esa selección es virtual y se resuelve en el servidor desde la query, así que las filas rechazadas van incluidas. Combínalos solo si la compuerta es indicativa, o pon allowSelectAllMatching: false.
Manejar la selección desde tu propia UI. controllerRef publica la API viva de selección — mode, selectedKeys, excludedKeys, selectedItems, selectedCount, query, pageEntries, más toggle / setSelected / toggleMany / selectAllMatching / clear — así un botón fuera de la lista puede leer y manejar el conjunto marcado. Pasa un objeto ref plano; listkit lo pone en null al desmontar.
const controllerRef = useRef<SelectionController<Invoice> | null>(null)
selection: {
controllerRef
}
// en cualquier otro lado:
controllerRef.current?.toggleMany(controllerRef.current.pageEntries, true)preselectLoadedRows: true invierte el default: cada fila recién cargada llega marcada, así que el alcance al que filtró el usuario es la selección y desmarcar es la excepción. Una clave que el usuario desmarca sigue desmarcada — solo se automarcan las nunca vistas — y el conjunto de vistas se reinicia con el dataset. Combínalo con un adapter cuyo key incluya todo alcance externo, para que un cambio de alcance nunca premarque las filas del anterior en el nuevo.
Seleccionar todos los resultados
Al seleccionar la página completa se ofrece escalar a los N resultados que coinciden — el patrón de Gmail/Stripe. Es una selección virtual: listkit no carga las demás páginas, registra la búsqueda y los filtros actuales más lo que destildes después.
selection: {
allowSelectAllMatching: false, // desactivar; por defecto true
}Lo que recibe una acción masiva depende del modo, y la diferencia importa:
helpers.mode | selected / selectedKeys | Resolver contra |
|---|---|---|
'explicit' | cada fila seleccionada | las claves |
'all-matching' | solo las filas que el cliente cargó | helpers.query menos helpers.excludedKeys |
Una acción en 'all-matching' debe correr contra la query, no contra el arreglo de filas — las filas de otras páginas nunca se pidieron, así que usar selectedKeys tocaría la página actual y dejaría intactas todas las demás sin avisar:
onClick: async (rows, { selectedKeys, mode, query, excludedKeys, clear }) => {
if (mode === 'all-matching') await archiveByQuery(query, excludedKeys)
else await archiveMany(selectedKeys)
clear()
}El backend resuelve esa query con los mismos builders que usa la lista — buildMongoFilter / buildSqlFilter — así que las filas que toca la acción son exactamente las que vio el usuario. La exportación funciona igual: el alcance all del modal le entrega al resolver la query y las exclusiones.
Mandar una selección al servidor. Para una mutación masiva, manda un SelectionDescriptor en vez de una lista plana de ids — la contraparte de mutación del request de exportación. toSelectionDescriptor lo arma desde cualquier snapshot de selección (el argumento details, un controller, o los helpers de una acción masiva), selectionDescriptorToBody lo serializa para un POST, parseSelectionDescriptor (desde /query) lo valida en el servidor, y resolveSelectionFilter (desde /mongoose) lo convierte en el filtro sobre el que corre la escritura:
// cliente
const body = selectionDescriptorToBody(
toSelectionDescriptor(controllerRef.current!)
)
await fetch('/api/invoices/archive', {
method: 'POST',
body: JSON.stringify(body),
})
// servidor
const descriptor = parseSelectionDescriptor(req.body)
if (!descriptor) return res.status(400).end()
const filter = await resolveSelectionFilter({
descriptor,
fields: maps.main,
baseFilter: { tenant: req.tenant }, // alcance de auth — siempre
})
if (filter) await Invoice.updateMany(filter, { $set: { archived: true } })Las dos mitades fallan cerrado. parseSelectionDescriptor devuelve null ante cualquier cosa mal formada — incluido un body sin query anidada, que bajo el alcance 'all' resolvería a todas las filas que permita el base filter — y resolveSelectionFilter devuelve null para una selección vacía, así que quien llama no hace nada en vez de entregarle a updateMany un filtro que matchea todo.
Exportar esa selección necesita una forma de alcanzar las filas. Con datos in-memory o un resolve de exportación, el alcance Selección del modal cubre los 12,000; sin eso, queda deshabilitado con una explicación en vez de escondido, y el "Exportar selección" de un clic se retira — un archivo con la página cargada mientras la barra dice "12,000 seleccionados" es peor que ningún archivo.
Tabla y layout
Encabezados fijos, densidad, reordenar y redimensionar columnas, tamaño y truncado por columna, indicadores de scroll y la barra de paginación.
Tarjetas
Obtén una vista de tarjetas sin escribir una tarjeta, luego toma el control con tarjetas personalizadas o baja a una tarjeta totalmente libre.