195 lines
5.6 KiB
Svelte
195 lines
5.6 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 DataTableProps<TData, TValue> = {
|
|
columns: ColumnDef<TData, TValue>[];
|
|
data: TData[];
|
|
loading: boolean;
|
|
hasMore: boolean;
|
|
loadMore: () => void;
|
|
onRowClick?: (row: TData) => void;
|
|
selectedId?: number | null;
|
|
};
|
|
|
|
let {
|
|
data,
|
|
columns,
|
|
loading,
|
|
hasMore,
|
|
loadMore,
|
|
onRowClick,
|
|
selectedId = null
|
|
}: DataTableProps<TData, TValue> = $props();
|
|
|
|
const table = createSvelteTable({
|
|
get data() {
|
|
return data;
|
|
},
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel()
|
|
});
|
|
|
|
let scrollContainer = $state<HTMLDivElement>();
|
|
let loadingTrigger = $state<HTMLDivElement>();
|
|
let tableContainer = $state<HTMLDivElement>();
|
|
let bottomScrollbar = $state<HTMLDivElement>();
|
|
let bottomScrollbarInner = $state<HTMLDivElement>();
|
|
let isSyncingHorizontalScroll = false;
|
|
|
|
// Intersection Observer para detectar cuando el usuario llega al final
|
|
$effect(() => {
|
|
const target = loadingTrigger;
|
|
const root = scrollContainer;
|
|
if (!target) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const [entry] = entries;
|
|
if (entry.isIntersecting && hasMore && !loading) {
|
|
loadMore();
|
|
}
|
|
},
|
|
{
|
|
root: root,
|
|
threshold: 0.1
|
|
}
|
|
);
|
|
|
|
observer.observe(target);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
});
|
|
|
|
function syncBottomScrollbarWidth() {
|
|
if (!tableContainer || !bottomScrollbarInner) return;
|
|
const width = Math.max(tableContainer.scrollWidth || 0, tableContainer.clientWidth + 1);
|
|
bottomScrollbarInner.style.width = `${width}px`;
|
|
}
|
|
|
|
function handleTableHorizontalScroll() {
|
|
if (!tableContainer || !bottomScrollbar || isSyncingHorizontalScroll) return;
|
|
isSyncingHorizontalScroll = true;
|
|
bottomScrollbar.scrollLeft = tableContainer.scrollLeft;
|
|
isSyncingHorizontalScroll = false;
|
|
}
|
|
|
|
function handleBottomHorizontalScroll() {
|
|
if (!tableContainer || !bottomScrollbar || isSyncingHorizontalScroll) return;
|
|
isSyncingHorizontalScroll = true;
|
|
tableContainer.scrollLeft = bottomScrollbar.scrollLeft;
|
|
isSyncingHorizontalScroll = false;
|
|
}
|
|
|
|
onMount(() => {
|
|
tableContainer = scrollContainer?.querySelector('[data-slot="table-container"]') ?? undefined;
|
|
if (!tableContainer) return;
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
syncBottomScrollbarWidth();
|
|
});
|
|
|
|
tableContainer.addEventListener('scroll', handleTableHorizontalScroll, { passive: true });
|
|
resizeObserver.observe(tableContainer);
|
|
const tableEl = tableContainer.querySelector('[data-slot="table"]');
|
|
if (tableEl) resizeObserver.observe(tableEl);
|
|
|
|
syncBottomScrollbarWidth();
|
|
|
|
return () => {
|
|
tableContainer?.removeEventListener('scroll', handleTableHorizontalScroll);
|
|
resizeObserver.disconnect();
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
data;
|
|
columns;
|
|
queueMicrotask(() => syncBottomScrollbarWidth());
|
|
});
|
|
</script>
|
|
|
|
<div class="w-full">
|
|
<div
|
|
class="catalog-table-shell min-h-[400px] max-h-[calc(100vh-310px)] overflow-y-auto [&_[data-slot=table-container]]:w-full [&_[data-slot=table-container]]:overflow-x-auto"
|
|
bind:this={scrollContainer}
|
|
>
|
|
<Table.Root class="w-max min-w-[1500px]">
|
|
<Table.Header class="catalog-table-header">
|
|
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
|
<Table.Row>
|
|
{#each headerGroup.headers as header (header.id)}
|
|
<Table.Head class="whitespace-nowrap">
|
|
{#if !header.isPlaceholder}
|
|
<FlexRender
|
|
content={header.column.columnDef.header}
|
|
context={header.getContext()}
|
|
/>
|
|
{/if}
|
|
</Table.Head>
|
|
{/each}
|
|
</Table.Row>
|
|
{/each}
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{#each table.getRowModel().rows as row (row.id)}
|
|
<Table.Row
|
|
data-state={row.getIsSelected() && "selected"}
|
|
class="cursor-pointer transition-colors hover:bg-muted/50 {selectedId === (row.original as any).id ? 'bg-muted' : ''}"
|
|
onclick={() => onRowClick && onRowClick(row.original)}
|
|
>
|
|
{#each row.getVisibleCells() as cell (cell.id)}
|
|
<Table.Cell class="whitespace-nowrap">
|
|
<FlexRender
|
|
content={cell.column.columnDef.cell}
|
|
context={cell.getContext()}
|
|
/>
|
|
</Table.Cell>
|
|
{/each}
|
|
</Table.Row>
|
|
{:else}
|
|
<Table.Row>
|
|
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
|
No hay resultados.
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
{/each}
|
|
|
|
<!-- Loading Trigger - Se activa cuando es visible -->
|
|
{#if hasMore}
|
|
<Table.Row>
|
|
<Table.Cell colspan={columns.length} class="h-20 text-center">
|
|
<div bind:this={loadingTrigger}>
|
|
{#if loading}
|
|
<div class="flex items-center justify-center gap-2">
|
|
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
<span class="text-muted-foreground text-sm">Cargando más...</span>
|
|
</div>
|
|
{:else}
|
|
<div class="text-muted-foreground text-sm">
|
|
Desplázate para cargar más
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
{/if}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</div>
|
|
<div
|
|
class="sticky bottom-0 z-30 h-5 overflow-x-scroll overflow-y-hidden border-x border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 [scrollbar-gutter:stable]"
|
|
bind:this={bottomScrollbar}
|
|
onscroll={handleBottomHorizontalScroll}
|
|
>
|
|
<div class="h-full min-w-full" bind:this={bottomScrollbarInner}></div>
|
|
</div>
|
|
</div>
|