248 lines
7.4 KiB
Svelte
248 lines
7.4 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[];
|
|
onSelectedIdsChange?: (ids: number[]) => void;
|
|
onRowClick?: (row: TData) => void;
|
|
onRowDoubleClick?: (row: TData) => void;
|
|
};
|
|
|
|
let {
|
|
data,
|
|
columns,
|
|
loading,
|
|
hasMore,
|
|
loadMore,
|
|
emptyMessage = 'No hay resultados.',
|
|
selectedIds = [],
|
|
onSelectedIdsChange,
|
|
onRowClick,
|
|
onRowDoubleClick,
|
|
}: InfiniteDataTableProps<TData, TValue> = $props();
|
|
|
|
const table = createSvelteTable({
|
|
get data() {
|
|
return data;
|
|
},
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getRowId: (row: any) => row.id?.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]) => Number(id))
|
|
.filter((id) => Number.isFinite(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)}
|
|
>
|
|
{#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>
|