mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 21:19:06 +00:00
Initial commit
This commit is contained in:
850
lib/components/data-grid/DataGrid.svelte
Normal file
850
lib/components/data-grid/DataGrid.svelte
Normal 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>
|
||||
28
lib/components/data-grid/context.ts
Normal file
28
lib/components/data-grid/context.ts
Normal 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;
|
||||
}
|
||||
16
lib/components/data-grid/index.ts
Normal file
16
lib/components/data-grid/index.ts
Normal 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';
|
||||
112
lib/components/data-grid/types.ts
Normal file
112
lib/components/data-grid/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user