Files
plantillas-proyectos/frontend/src/lib/components/dashboard/common/infinite-data-table.svelte
2026-04-21 14:48:30 -06:00

250 lines
7.7 KiB
Svelte

<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type InfiniteDataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
loading: boolean;
hasMore: boolean;
loadMore: () => void;
emptyMessage?: string;
selectedIds?: (number | string)[];
onSelectedIdsChange?: (ids: (number | string)[]) => void;
getRowId?: (row: TData) => string | number;
onRowClick?: (row: TData) => void;
onRowDoubleClick?: (row: TData) => void;
};
let {
data,
columns,
loading,
hasMore,
loadMore,
emptyMessage = 'No hay resultados.',
selectedIds = [],
onSelectedIdsChange,
getRowId,
onRowClick,
onRowDoubleClick,
}: InfiniteDataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => (getRowId ? getRowId(row).toString() : (row.id || row.transporter_key || row.driver_id || row.trailer_number || row.vehicle_key || '').toString()),
state: {
get rowSelection() {
const selection: Record<string, boolean> = {};
selectedIds.forEach((id) => {
selection[id.toString()] = true;
});
return selection;
}
},
onStateChange: (updater: any) => {
if (!onSelectedIdsChange) return;
const currentState = table.getState();
const nextState = typeof updater === 'function' ? updater(currentState) : updater;
const rowSelection = nextState?.rowSelection;
if (!rowSelection) {
onSelectedIdsChange([]);
return;
}
const nextSelectedIds = Object.entries(rowSelection)
.filter(([, selected]) => Boolean(selected))
.map(([id]) => (isNaN(Number(id)) ? id : Number(id)));
onSelectedIdsChange(nextSelectedIds);
},
enableRowSelection: true,
enableMultiRowSelection: true
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
function findRowActionTrigger(tr: HTMLTableRowElement): HTMLElement | null {
const lastTd = tr.querySelector('td:last-of-type');
if (!lastTd) return null;
return lastTd.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-trigger"], button[aria-haspopup="menu"], button[aria-haspopup="dialog"]'
);
}
function handleCatalogTableKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const target = event.target as HTMLElement | null;
if (!target || !scrollContainer?.contains(target)) return;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
const row = target.closest<HTMLTableRowElement>('tr[data-slot="table-row"]');
if (!row || row.closest('[role="dialog"]')) return;
const lastCell = row.querySelector('td:last-of-type');
if (!lastCell || !lastCell.contains(target)) return;
const tbody = row.closest('tbody');
if (!tbody || !scrollContainer.contains(tbody)) return;
const rows = Array.from(
tbody.querySelectorAll<HTMLTableRowElement>('tr[data-slot="table-row"]')
);
const idx = rows.indexOf(row);
if (idx < 0) return;
const delta = event.key === 'ArrowDown' ? 1 : -1;
const nextIdx = idx + delta;
if (nextIdx < 0 || nextIdx >= rows.length) return;
const nextTrigger = findRowActionTrigger(rows[nextIdx]);
if (!nextTrigger) return;
event.preventDefault();
event.stopPropagation();
nextTrigger.focus();
rows[nextIdx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
if (loadingTrigger) {
observer.observe(loadingTrigger);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="flex h-full w-full flex-col overflow-hidden">
<div
class="catalog-table-scroll min-h-[320px] max-h-[calc(100svh-280px)]"
bind:this={scrollContainer}
onkeydown={handleCatalogTableKeydown}
>
<Table.Root>
<Table.Header class="catalog-table-header">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
{@const headerList = headerGroup.headers}
{@const lastHeaderColId = headerList[headerList.length - 1]?.column.id}
<Table.Row inTabOrder={false}>
{#each headerList as header (header.id)}
{@const colId = header.column.id}
<Table.Head
class={[
'catalog-table-head-cell',
colId === 'select' && 'catalog-table-sticky-left z-40 min-w-[2.75rem]',
colId === lastHeaderColId &&
'catalog-table-sticky-right z-30'
]
.filter(Boolean)
.join(' ')}
>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#if table.getRowModel().rows.length}
{#each table.getRowModel().rows as row (row.id)}
{@const visibleCells = row.getVisibleCells()}
{@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id}
<Table.Row
inTabOrder={false}
data-state={row.getIsSelected() ? 'selected' : undefined}
class={[
row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row',
onRowClick && 'cursor-pointer'
]
.filter(Boolean)
.join(' ')}
onclick={() => onRowClick?.(row.original)}
ondblclick={() => onRowDoubleClick?.(row.original)}
>
{#each visibleCells as cell (cell.id)}
{@const colId = cell.column.id}
<Table.Cell
class={[
'whitespace-nowrap',
colId === 'select' && 'catalog-table-sticky-left z-30 min-w-[2.75rem]',
colId === lastCellColId &&
'catalog-table-sticky-right z-10',
row.getIsSelected()
? 'catalog-table-sticky-row-selected'
: 'catalog-table-sticky-row-hover'
]
.filter(Boolean)
.join(' ')}
>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
</Table.Cell>
{/each}
</Table.Row>
{/each}
{:else}
<Table.Row inTabOrder={false}>
<Table.Cell colspan={columns.length} class="h-24 text-center text-sm text-muted-foreground">
{emptyMessage}
</Table.Cell>
</Table.Row>
{/if}
{#if hasMore}
<Table.Row inTabOrder={false}>
<Table.Cell colspan={columns.length} class="h-24 text-center p-0">
<div bind:this={loadingTrigger} class="flex h-full w-full items-center justify-center">
{#if loading}
<div class="flex items-center justify-center gap-3 rounded-full border bg-muted/30 px-6 py-2 shadow-sm">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent text-primary"></div>
<span class="text-sm font-medium text-foreground">Cargando más registros...</span>
</div>
{:else}
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="h-px w-8 bg-border"></span>
<span>Desplázate para cargar más</span>
<span class="h-px w-8 bg-border"></span>
</div>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>
</div>