Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,850 @@
<script lang="ts" generics="T">
import { onMount, onDestroy } from 'svelte';
import type { Snippet } from 'svelte';
import { CheckSquare, Square as SquareIcon, ArrowUp, ArrowDown, ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-svelte';
import { columnResize } from '$lib/actions/column-resize';
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
import { getAllColumnConfigs } from '$lib/config/grid-columns';
import ColumnSettingsPopover from '$lib/components/ColumnSettingsPopover.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types';
import type { DataGridSortState, DataGridRowState } from './types';
import { setDataGridContext } from './context';
// Props
interface Props {
// Required
data: T[];
keyField: keyof T;
gridId: GridId;
// Virtual Scroll Mode (OFF by default)
virtualScroll?: boolean;
rowHeight?: number;
bufferRows?: number;
// Selection
selectable?: boolean;
selectedKeys?: Set<unknown>;
onSelectionChange?: (keys: Set<unknown>) => void;
// Sorting
sortState?: DataGridSortState;
onSortChange?: (state: DataGridSortState) => void;
// Infinite scroll (virtual mode)
hasMore?: boolean;
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Visible range callback (for virtual scroll)
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
// Row interaction
onRowClick?: (item: T, event: MouseEvent) => void;
highlightedKey?: unknown;
rowClass?: (item: T) => string;
// Selection filter - return false to make an item non-selectable
selectableFilter?: (item: T) => boolean;
// Expandable rows
expandable?: boolean;
expandedKeys?: Set<unknown>;
onExpandChange?: (key: unknown, expanded: boolean) => void;
expandedRow?: Snippet<[T, DataGridRowState]>;
// State
loading?: boolean;
skeletonRows?: number;
// CSS
class?: string;
wrapperClass?: string;
// Snippets for customization
headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>;
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
emptyState?: Snippet;
loadingState?: Snippet;
}
let {
data,
keyField,
gridId,
virtualScroll = false,
rowHeight = 33,
bufferRows = 10,
selectable = false,
selectedKeys = $bindable(new Set<unknown>()),
onSelectionChange,
sortState,
onSortChange,
hasMore = false,
onLoadMore,
loadMoreThreshold = 200,
onVisibleRangeChange,
onRowClick,
highlightedKey,
rowClass,
selectableFilter,
expandable = false,
expandedKeys = $bindable(new Set<unknown>()),
onExpandChange,
expandedRow,
loading = false,
skeletonRows = 8,
class: className = '',
wrapperClass = '',
headerCell,
cell,
emptyState,
loadingState
}: Props = $props();
// Column configuration
const columnConfigs = getAllColumnConfigs(gridId);
const columnConfigMap = new Map(columnConfigs.map((c) => [c.id, c]));
const fixedStartCols = columnConfigs.filter((c) => c.fixed === 'start').map((c) => c.id);
const fixedEndCols = columnConfigs.filter((c) => c.fixed === 'end').map((c) => c.id);
// Grid preferences (reactive)
const gridPrefs = $derived($gridPreferencesStore);
// Get ordered visible columns from preferences
const orderedColumns = $derived.by(() => {
const prefs = gridPrefs[gridId];
if (!prefs?.columns?.length) {
// Default: all configurable columns visible
return columnConfigs.filter((c) => !c.fixed).map((c) => c.id);
}
return prefs.columns.filter((c) => c.visible).map((c) => c.id);
});
// Identify visible grow columns (columns with grow: true that are currently visible)
const visibleGrowCols = $derived(
orderedColumns.filter((id) => columnConfigMap.get(id)?.grow)
);
// Helper to check if column is a grow column
function isGrowColumn(colId: string): boolean {
return visibleGrowCols.includes(colId);
}
// Saved column widths from preferences
const savedWidths = $derived.by(() => {
const prefs = gridPrefs[gridId];
const widths = new Map<string, number>();
if (prefs?.columns) {
for (const col of prefs.columns) {
if (col.width !== undefined) {
widths.set(col.id, col.width);
}
}
}
return widths;
});
// Local widths for smooth resize feedback (not persisted until mouseup)
let localWidths = $state<Map<string, number>>(new Map());
// RAF throttling for performance
let resizeRAF: number | null = null;
let scrollRAF: number | null = null;
// Helper to get base width for a column (without grow calculation)
function getBaseWidth(colId: string): number {
if (localWidths.has(colId)) return localWidths.get(colId)!;
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
return columnConfigMap.get(colId)?.width ?? 100;
}
// Calculate width for grow columns (distributes remaining space equally)
const growColumnWidth = $derived.by(() => {
if (!scrollContainerWidth || visibleGrowCols.length === 0) return null;
// Sum of all fixed-width columns (non-grow)
let fixedTotal = 0;
// Fixed start columns (select, expand)
for (const colId of fixedStartCols) {
fixedTotal += getBaseWidth(colId);
}
// Visible non-grow columns
for (const colId of orderedColumns) {
if (!visibleGrowCols.includes(colId)) {
fixedTotal += getBaseWidth(colId);
}
}
// Fixed end columns (actions)
for (const colId of fixedEndCols) {
fixedTotal += getBaseWidth(colId);
}
// Distribute remaining space equally among grow columns
// No buffer - grow columns absorb all remaining space
const remaining = Math.max(0, scrollContainerWidth - fixedTotal);
const perGrowCol = remaining / visibleGrowCols.length;
// Respect minimum widths
const minWidth = Math.max(
...visibleGrowCols.map((id) => columnConfigMap.get(id)?.minWidth ?? 60)
);
return Math.max(perGrowCol, minWidth);
});
// Calculate total table width (sum of all column widths)
const totalTableWidth = $derived.by(() => {
let total = 0;
for (const colId of fixedStartCols) {
total += getBaseWidth(colId);
}
for (const colId of orderedColumns) {
total += getDisplayWidth(colId);
}
for (const colId of fixedEndCols) {
total += getBaseWidth(colId);
}
return total;
});
// Get display width for a column (priority: local > saved > grow-calculated > default)
function getDisplayWidth(colId: string): number {
// For non-grow columns, use base width
if (!isGrowColumn(colId)) {
return getBaseWidth(colId);
}
// For grow columns: if user has resized, use their width
if (localWidths.has(colId)) return localWidths.get(colId)!;
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
// Otherwise use calculated grow width
if (growColumnWidth) {
return growColumnWidth;
}
return columnConfigMap.get(colId)?.width ?? 100;
}
// Get column config by ID
function getColumnConfig(colId: string): ColumnConfig | undefined {
return columnConfigMap.get(colId);
}
// Handle resize during drag (RAF throttled for performance)
function handleResize(colId: string, width: number) {
if (resizeRAF) return; // Skip if already pending
resizeRAF = requestAnimationFrame(() => {
resizeRAF = null;
localWidths.set(colId, width);
localWidths = new Map(localWidths); // Trigger reactivity
});
}
// Handle resize end - persist to store
async function handleResizeEnd(colId: string, width: number) {
await gridPreferencesStore.setColumnWidth(gridId, colId, width);
localWidths.delete(colId);
localWidths = new Map(localWidths);
}
// Selection helpers
function isItemSelectable(item: T): boolean {
return selectableFilter ? selectableFilter(item) : true;
}
const selectableData = $derived(data.filter(isItemSelectable));
const allSelected = $derived(selectableData.length > 0 && selectableData.every((item) => selectedKeys.has(item[keyField])));
const someSelected = $derived(selectableData.some((item) => selectedKeys.has(item[keyField])) && !allSelected);
function isSelected(key: unknown): boolean {
return selectedKeys.has(key);
}
function toggleSelection(key: unknown) {
const newKeys = new Set(selectedKeys);
if (newKeys.has(key)) {
newKeys.delete(key);
} else {
newKeys.add(key);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function selectAll() {
// Add all selectable items to existing selection (preserves filtered-out selections)
const newKeys = new Set(selectedKeys);
for (const item of selectableData) {
newKeys.add(item[keyField]);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function selectNone() {
// Remove only selectable items from selection (preserves filtered-out selections)
const newKeys = new Set(selectedKeys);
for (const item of selectableData) {
newKeys.delete(item[keyField]);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function toggleSelectAll() {
if (allSelected) {
selectNone();
} else {
selectAll();
}
}
// Expand helpers
function isExpanded(key: unknown): boolean {
return expandedKeys.has(key);
}
function toggleExpand(key: unknown) {
const newKeys = new Set(expandedKeys);
const nowExpanded = !newKeys.has(key);
if (nowExpanded) {
newKeys.add(key);
} else {
newKeys.delete(key);
}
expandedKeys = newKeys;
onExpandChange?.(key, nowExpanded);
}
// Sort helpers
function toggleSort(field: string) {
if (!onSortChange) return;
if (sortState?.field === field) {
onSortChange({
field,
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
});
} else {
onSortChange({ field, direction: 'asc' });
}
}
// Virtual scroll state
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
let containerHeight = $state(600);
// Container width for grow column calculation
let scrollContainerWidth = $state(0);
// Virtual scroll calculations
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
const endIndex = $derived(
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
);
const visibleData = $derived(virtualScroll ? data.slice(startIndex, endIndex) : data);
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
// Notify parent of visible range changes
$effect(() => {
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
// Calculate actual visible range (without buffer)
const visibleStart = Math.max(1, Math.floor(scrollTop / rowHeight) + 1);
const visibleEnd = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight));
onVisibleRangeChange(visibleStart, Math.max(visibleEnd, visibleStart), data.length);
}
});
// Handle scroll for virtual mode (RAF throttled for performance)
function handleScroll(event: Event) {
if (!virtualScroll) return;
if (scrollRAF) return; // Skip if already pending
scrollRAF = requestAnimationFrame(() => {
scrollRAF = null;
const target = event.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Update container height on scroll (in case of resize)
containerHeight = target.clientHeight;
// Infinite scroll trigger
if (hasMore && onLoadMore) {
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < loadMoreThreshold) {
onLoadMore();
}
}
});
}
// Update container dimensions on mount and resize
onMount(() => {
if (scrollContainer) {
// Track width for grow column calculation (always needed)
scrollContainerWidth = scrollContainer.clientWidth;
// Track height for virtual scroll
if (virtualScroll) {
containerHeight = scrollContainer.clientHeight;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
scrollContainerWidth = entry.contentRect.width;
if (virtualScroll) {
containerHeight = entry.contentRect.height;
}
}
});
resizeObserver.observe(scrollContainer);
return () => {
resizeObserver.disconnect();
};
}
});
// Cleanup RAF handles on destroy
onDestroy(() => {
if (resizeRAF) cancelAnimationFrame(resizeRAF);
if (scrollRAF) cancelAnimationFrame(scrollRAF);
});
// Set context for child components
setDataGridContext({
gridId,
keyField: keyField as keyof unknown,
orderedColumns,
getDisplayWidth,
getColumnConfig,
selectable,
isSelected,
toggleSelection,
selectAll,
selectNone,
allSelected,
someSelected,
sortState,
toggleSort,
handleResize,
handleResizeEnd,
highlightedKey
});
// Helper to get row state
function getRowState(item: T, index: number): DataGridRowState {
return {
isSelected: isSelected(item[keyField]),
isHighlighted: highlightedKey === item[keyField],
isSelectable: isItemSelectable(item),
isExpanded: isExpanded(item[keyField]),
index: virtualScroll ? startIndex + index : index
};
}
// Helper to check if column is resizable
function isResizable(colId: string): boolean {
const config = columnConfigMap.get(colId);
// Fixed columns are not resizable by default, but can be made resizable explicitly
if (config?.fixed) {
return config.resizable === true;
}
return config?.resizable !== false;
}
// Helper to check if column is sortable
function isSortable(colId: string): boolean {
const config = columnConfigMap.get(colId);
return config?.sortable === true;
}
// Helper to get sort field
function getSortField(colId: string): string {
const config = columnConfigMap.get(colId);
return config?.sortField ?? colId;
}
// Generate skeleton row indices
const skeletonIndices = $derived(Array.from({ length: skeletonRows }, (_, i) => i));
</script>
{#snippet skeletonContent()}
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
<thead class="bg-muted sticky top-0 z-10">
<tr>
<!-- Fixed start columns -->
{#each fixedStartCols as colId (colId)}
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px"></th>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<th class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium" style="width: {getDisplayWidth(colId)}px">
{colConfig.label}
</th>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'actions'}
<div class="flex items-center justify-end gap-1">
<span>Actions</span>
<ColumnSettingsPopover {gridId} />
</div>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each skeletonIndices as i (i)}
<tr class="border-b border-muted">
<!-- Fixed start columns -->
{#each fixedStartCols as colId (colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
<Skeleton class="h-4 w-4" />
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
{@const width = getDisplayWidth(colId)}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {width}px">
<Skeleton class="h-4" style="width: {Math.max(30, Math.min(width - 16, width * 0.7))}px" />
</td>
{/if}
{/each}
<!-- Fixed end columns -->
{#each fixedEndCols as colId (colId)}
<td class="py-1.5 px-2 actions-col" style="width: {getDisplayWidth(colId)}px">
<Skeleton class="h-4 w-12" />
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/snippet}
{#snippet tableHeader()}
<thead class="bg-muted sticky top-0 z-10">
<tr>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
<button
type="button"
onclick={toggleSelectAll}
class="flex items-center justify-center transition-colors opacity-40 hover:opacity-100 cursor-pointer"
title={allSelected ? 'Deselect all' : 'Select all'}
>
{#if allSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else if someSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{:else if colId === 'expand' && expandable}
<!-- Expand column header is empty -->
{:else if headerCell}
{@render headerCell(colConfig!, sortState)}
{:else}
{colConfig?.label ?? ''}
{/if}
</th>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<th
class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium"
style="width: {getDisplayWidth(colId)}px"
>
{#if headerCell}
{@render headerCell(colConfig, sortState)}
{:else if isSortable(colId)}
<button
type="button"
onclick={() => toggleSort(getSortField(colId))}
class="flex items-center gap-1 hover:text-foreground transition-colors w-full {colConfig.align === 'right' ? 'justify-end' : colConfig.align === 'center' ? 'justify-center' : ''}"
>
{colConfig.label}
{#if sortState?.field === getSortField(colId)}
{#if sortState.direction === 'asc'}
<ArrowUp class="w-3 h-3" />
{:else}
<ArrowDown class="w-3 h-3" />
{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-30" />
{/if}
</button>
{:else}
{colConfig.label}
{/if}
<!-- Resize handle -->
{#if isResizable(colId)}
<div
class="resize-handle"
use:columnResize={{
onResize: (w) => handleResize(colId, w),
onResizeEnd: (w) => handleResizeEnd(colId, w),
minWidth: colConfig.minWidth
}}
></div>
{/if}
</th>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'actions'}
<div class="flex items-center justify-end gap-1">
<span>Actions</span>
<ColumnSettingsPopover {gridId} />
</div>
{:else if headerCell}
{@render headerCell(colConfig!, sortState)}
{:else}
{colConfig?.label ?? ''}
{/if}
<!-- Resize handle for fixed end columns -->
{#if isResizable(colId)}
<div
class="resize-handle resize-handle-left"
use:columnResize={{
onResize: (w) => handleResize(colId, w),
onResizeEnd: (w) => handleResizeEnd(colId, w),
minWidth: colConfig?.minWidth
}}
></div>
{/if}
</th>
{/each}
</tr>
</thead>
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/each}
</tbody>
{/snippet}
{#snippet tableContent()}
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
{@render tableHeader()}
{@render tableBody()}
</table>
{/snippet}
<div class="flex-1 min-h-0 overflow-auto rounded-lg data-grid-wrapper {wrapperClass}" bind:this={scrollContainer} onscroll={handleScroll}>
{#if loading && data.length === 0}
{#if loadingState}
{@render loadingState()}
{:else}
{@render skeletonContent()}
{/if}
{:else if data.length === 0 && emptyState}
{@render emptyState()}
{:else if virtualScroll}
<!-- Virtual scroll mode with spacer rows for sticky header support -->
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
{@render tableHeader()}
<tbody>
<!-- Top spacer -->
{#if offsetY > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Visible rows -->
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/each}
<!-- Bottom spacer -->
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
</tbody>
</table>
{:else}
<!-- Standard mode -->
{@render tableContent()}
{/if}
</div>

View File

@@ -0,0 +1,28 @@
/**
* DataGrid Context
*
* Provides shared state to child components via Svelte context.
*/
import { getContext, setContext } from 'svelte';
import type { DataGridContext } from './types';
const DATA_GRID_CONTEXT_KEY = Symbol('data-grid');
/**
* Set the DataGrid context (called by DataGrid.svelte)
*/
export function setDataGridContext<T>(ctx: DataGridContext<T>): void {
setContext(DATA_GRID_CONTEXT_KEY, ctx);
}
/**
* Get the DataGrid context (called by child components)
*/
export function getDataGridContext<T = unknown>(): DataGridContext<T> {
const ctx = getContext<DataGridContext<T>>(DATA_GRID_CONTEXT_KEY);
if (!ctx) {
throw new Error('DataGrid context not found. Ensure component is used within a DataGrid.');
}
return ctx;
}

View File

@@ -0,0 +1,16 @@
/**
* DataGrid Component
*
* A reusable, feature-rich data grid with:
* - Column resizing, hiding, reordering
* - Sticky first/last columns
* - Multi-row selection
* - Sortable headers
* - Virtual scrolling (optional)
* - Preference persistence
*/
import DataGrid from './DataGrid.svelte';
export { DataGrid };
export * from './types';
export * from './context';

View File

@@ -0,0 +1,112 @@
/**
* DataGrid Component Types
*
* Extends the base grid types with component-specific interfaces
* for the reusable DataGrid component.
*/
import type { Snippet } from 'svelte';
import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types';
// Re-export base types for convenience
export type { GridId, ColumnConfig, ColumnPreference };
/**
* Sort state for the grid
*/
export interface DataGridSortState {
field: string;
direction: 'asc' | 'desc';
}
/**
* Row state passed to cell snippets
*/
export interface DataGridRowState {
isSelected: boolean;
isHighlighted: boolean;
isSelectable: boolean;
isExpanded: boolean;
index: number;
}
/**
* Main DataGrid component props
*/
export interface DataGridProps<T> {
// Required
data: T[];
keyField: keyof T;
gridId: GridId;
// Virtual Scroll Mode (OFF by default)
virtualScroll?: boolean;
rowHeight?: number;
bufferRows?: number;
// Selection
selectable?: boolean;
selectedKeys?: Set<unknown>;
onSelectionChange?: (keys: Set<unknown>) => void;
// Sorting
sortState?: DataGridSortState;
onSortChange?: (state: DataGridSortState) => void;
// Infinite scroll (virtual mode)
hasMore?: boolean;
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Row interaction
onRowClick?: (item: T, event: MouseEvent) => void;
highlightedKey?: unknown;
rowClass?: (item: T) => string;
// State
loading?: boolean;
// CSS
class?: string;
wrapperClass?: string;
// Snippets for customization
headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>;
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
emptyState?: Snippet;
loadingState?: Snippet;
}
/**
* Context provided to child components
*/
export interface DataGridContext<T = unknown> {
// Grid configuration
gridId: GridId;
keyField: keyof T;
// Column state
orderedColumns: string[];
getDisplayWidth: (colId: string) => number;
getColumnConfig: (colId: string) => ColumnConfig | undefined;
// Selection helpers
selectable: boolean;
isSelected: (key: unknown) => boolean;
toggleSelection: (key: unknown) => void;
selectAll: () => void;
selectNone: () => void;
allSelected: boolean;
someSelected: boolean;
// Sort helpers
sortState: DataGridSortState | undefined;
toggleSort: (field: string) => void;
// Resize helpers
handleResize: (colId: string, width: number) => void;
handleResizeEnd: (colId: string, width: number) => void;
// Row state
highlightedKey: unknown;
}