@smartnet360/svelte-grid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ <script lang="ts" generics="T">
2
+ import { setContext, onMount, onDestroy } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import type { ColumnDefinition, CellContext, GridEvents, MenuItemDefinition, MenuContext, PopupContent, PopupContext, GroupBy, GroupInfo } from '../types.js';
5
+ import { createGridState, GRID_CONTEXT_KEY } from '../state/gridState.svelte.js';
6
+ import { themes, themeToStyle, type GridTheme, type ThemeName } from '../themes.js';
7
+ import GridHeader from './GridHeader.svelte';
8
+ import GridBody from './GridBody.svelte';
9
+ import Popup from './Popup.svelte';
10
+ import Menu from './Menu.svelte';
11
+
12
+ // ============================================
13
+ // Props
14
+ // ============================================
15
+
16
+ interface Props {
17
+ /** Array of data objects */
18
+ data: T[];
19
+ /** Column definitions */
20
+ columns: ColumnDefinition<T>[];
21
+ /** Unique key field in data for row identification */
22
+ rowKey?: string;
23
+ /** Fixed height for the grid container */
24
+ height?: string | number;
25
+ /** Fixed row height in pixels */
26
+ rowHeight?: number;
27
+ /** Placeholder text when no data */
28
+ placeholder?: string;
29
+ /** Enable row hover highlighting */
30
+ rowHover?: boolean;
31
+ /** Enable alternating row colors */
32
+ stripedRows?: boolean;
33
+ /** Enable grid borders */
34
+ bordered?: boolean;
35
+ /** Grid density */
36
+ density?: 'compact' | 'normal' | 'comfortable';
37
+ /** Enable column resizing */
38
+ resizableColumns?: boolean;
39
+ /** Number of rows to freeze at the top */
40
+ frozenRows?: number;
41
+ /** CSS class for the grid container */
42
+ class?: string;
43
+
44
+ // Events
45
+ /** Row click handler */
46
+ onrowclick?: GridEvents<T>['rowclick'];
47
+ /** Row double-click handler */
48
+ onrowdblclick?: GridEvents<T>['rowdblclick'];
49
+ /** Cell click handler */
50
+ oncellclick?: GridEvents<T>['cellclick'];
51
+ /** Header click handler */
52
+ onheaderclick?: GridEvents<T>['headerclick'];
53
+
54
+ // Snippets for custom rendering
55
+ /** Custom cell renderer */
56
+ cell?: Snippet<[CellContext<T>]>;
57
+ /** Custom header cell renderer */
58
+ headerCell?: Snippet<[ColumnDefinition<T>]>;
59
+ /** Custom empty state renderer */
60
+ empty?: Snippet<[]>;
61
+
62
+ // Menus & Popups
63
+ /** Row context menu (right-click) */
64
+ rowContextMenu?: MenuItemDefinition<T>[] | ((row: T, rowIndex: number) => MenuItemDefinition<T>[]);
65
+ /** Row click popup content */
66
+ rowClickPopup?: PopupContent<T>;
67
+
68
+ // Grouping
69
+ /** Group data by field(s) */
70
+ groupBy?: GroupBy<T>;
71
+ /** Custom group header snippet */
72
+ groupHeader?: Snippet<[GroupInfo<T>]>;
73
+ /** Height of group header rows */
74
+ groupHeaderHeight?: number;
75
+
76
+ // Theming
77
+ /** Theme name or custom theme object */
78
+ theme?: ThemeName | GridTheme;
79
+ }
80
+
81
+ let {
82
+ data,
83
+ columns,
84
+ rowKey = 'id',
85
+ height = '400px',
86
+ rowHeight = 40,
87
+ placeholder = 'No data available',
88
+ rowHover = true,
89
+ stripedRows = false,
90
+ bordered = true,
91
+ density = 'normal',
92
+ resizableColumns = false,
93
+ frozenRows = 0,
94
+ class: className = '',
95
+ onrowclick,
96
+ onrowdblclick,
97
+ oncellclick,
98
+ onheaderclick,
99
+ cell,
100
+ headerCell,
101
+ empty,
102
+ rowContextMenu,
103
+ rowClickPopup,
104
+ groupBy,
105
+ groupHeader,
106
+ groupHeaderHeight = 36,
107
+ theme
108
+ }: Props = $props();
109
+
110
+ // ============================================
111
+ // State Management
112
+ // ============================================
113
+
114
+ // Initialize state manager - typed with generic T
115
+ // Use empty defaults; $effect will sync actual values
116
+ const gridState = createGridState<T>({
117
+ data: [],
118
+ columns: [],
119
+ rowHeight: 40
120
+ });
121
+
122
+ // Provide state via context for child components
123
+ setContext(GRID_CONTEXT_KEY, gridState);
124
+
125
+ // ============================================
126
+ // Reactive Updates
127
+ // ============================================
128
+
129
+ // Sync props to state reactively
130
+ $effect(() => {
131
+ gridState.setData(data);
132
+ });
133
+
134
+ $effect(() => {
135
+ gridState.setColumns(columns);
136
+ });
137
+
138
+ $effect(() => {
139
+ gridState.rowHeight = rowHeight;
140
+ });
141
+
142
+ $effect(() => {
143
+ gridState.setFrozenRowCount(frozenRows);
144
+ });
145
+
146
+ $effect(() => {
147
+ gridState.setGroupBy(groupBy);
148
+ });
149
+
150
+ $effect(() => {
151
+ gridState.setGroupHeaderHeight(groupHeaderHeight);
152
+ });
153
+
154
+ // ============================================
155
+ // Scroll Handler
156
+ // ============================================
157
+
158
+ function handleBodyScroll(scrollTop: number, scrollLeft: number) {
159
+ gridState.setScrollPosition(scrollTop, scrollLeft);
160
+ }
161
+
162
+ // ============================================
163
+ // Context Menu State
164
+ // ============================================
165
+
166
+ let contextMenuOpen = $state(false);
167
+ let contextMenuRect = $state<DOMRect | null>(null);
168
+ let contextMenuRow = $state<T | null>(null);
169
+ let contextMenuRowIndex = $state<number>(-1);
170
+
171
+ // Get context menu items (resolve function if needed)
172
+ const contextMenuItems = $derived.by((): MenuItemDefinition<T>[] => {
173
+ if (!rowContextMenu || !contextMenuRow) return [];
174
+ if (typeof rowContextMenu === 'function') {
175
+ return rowContextMenu(contextMenuRow, contextMenuRowIndex);
176
+ }
177
+ return rowContextMenu;
178
+ });
179
+
180
+ // Context menu context
181
+ const contextMenuContext = $derived<MenuContext<T>>({
182
+ row: contextMenuRow ?? undefined,
183
+ rowIndex: contextMenuRowIndex,
184
+ closeMenu: () => {
185
+ contextMenuOpen = false;
186
+ }
187
+ });
188
+
189
+ function handleRowContextMenu(row: T, rowIndex: number, event: MouseEvent) {
190
+ if (!rowContextMenu) return;
191
+
192
+ event.preventDefault();
193
+ contextMenuRow = row;
194
+ contextMenuRowIndex = rowIndex;
195
+
196
+ // Create a rect at the mouse position
197
+ contextMenuRect = new DOMRect(event.clientX, event.clientY, 0, 0);
198
+ contextMenuOpen = true;
199
+ rowClickPopupOpen = false; // Close popup if open
200
+ }
201
+
202
+ function closeContextMenu() {
203
+ contextMenuOpen = false;
204
+ }
205
+
206
+ // ============================================
207
+ // Row Click Popup State
208
+ // ============================================
209
+
210
+ let rowClickPopupOpen = $state(false);
211
+ let rowClickPopupRect = $state<DOMRect | null>(null);
212
+ let rowClickPopupRow = $state<T | null>(null);
213
+ let rowClickPopupRowIndex = $state<number>(-1);
214
+
215
+ // Popup context
216
+ const rowPopupContext = $derived<PopupContext<T>>({
217
+ row: rowClickPopupRow ?? undefined,
218
+ rowIndex: rowClickPopupRowIndex,
219
+ closePopup: () => {
220
+ rowClickPopupOpen = false;
221
+ }
222
+ });
223
+
224
+ function handleRowClickForPopup(row: T, rowIndex: number, event: MouseEvent) {
225
+ if (!rowClickPopup) return;
226
+
227
+ const target = event.currentTarget as HTMLElement;
228
+ rowClickPopupRow = row;
229
+ rowClickPopupRowIndex = rowIndex;
230
+ rowClickPopupRect = target.getBoundingClientRect();
231
+ rowClickPopupOpen = true;
232
+ contextMenuOpen = false; // Close context menu if open
233
+ }
234
+
235
+ function closeRowPopup() {
236
+ rowClickPopupOpen = false;
237
+ }
238
+
239
+ // Combined row click handler
240
+ function handleRowClick(row: T, rowIndex: number, event: MouseEvent) {
241
+ // Call user's onrowclick if provided
242
+ onrowclick?.(row, rowIndex, event);
243
+
244
+ // Handle popup if configured
245
+ if (rowClickPopup) {
246
+ handleRowClickForPopup(row, rowIndex, event);
247
+ }
248
+ }
249
+
250
+ // ============================================
251
+ // Popup Content Rendering
252
+ // ============================================
253
+
254
+ function getRowPopupContent(): string | null {
255
+ if (!rowClickPopup || !rowClickPopupRow) return null;
256
+ if (typeof rowClickPopup === 'string') {
257
+ return rowClickPopup;
258
+ }
259
+ if (typeof rowClickPopup === 'function') {
260
+ const result = rowClickPopup(rowPopupContext);
261
+ return typeof result === 'string' ? result : null;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ const isSnippetRowPopup = $derived(
267
+ rowClickPopup !== undefined &&
268
+ typeof rowClickPopup !== 'string' &&
269
+ typeof rowClickPopup !== 'function'
270
+ );
271
+
272
+ // ============================================
273
+ // Container Reference & Resize Observer
274
+ // ============================================
275
+
276
+ let containerRef: HTMLDivElement | undefined = $state();
277
+ let resizeObserver: ResizeObserver | undefined;
278
+
279
+ onMount(() => {
280
+ if (containerRef) {
281
+ // Set initial dimensions
282
+ gridState.setContainerDimensions(containerRef.clientWidth, containerRef.clientHeight);
283
+
284
+ // Observe resize
285
+ resizeObserver = new ResizeObserver((entries) => {
286
+ for (const entry of entries) {
287
+ gridState.setContainerDimensions(
288
+ entry.contentRect.width,
289
+ entry.contentRect.height
290
+ );
291
+ }
292
+ });
293
+ resizeObserver.observe(containerRef);
294
+ }
295
+ });
296
+
297
+ onDestroy(() => {
298
+ resizeObserver?.disconnect();
299
+ gridState.destroy();
300
+ });
301
+
302
+ // ============================================
303
+ // Computed Styles
304
+ // ============================================
305
+
306
+ // Resolve theme to GridTheme object
307
+ const resolvedTheme = $derived.by((): GridTheme | undefined => {
308
+ if (!theme) return undefined;
309
+ if (typeof theme === 'string') {
310
+ return themes[theme];
311
+ }
312
+ return theme;
313
+ });
314
+
315
+ const containerStyle = $derived.by(() => {
316
+ const h = typeof height === 'number' ? `${height}px` : height;
317
+ const baseStyle = `height: ${h};`;
318
+
319
+ if (resolvedTheme) {
320
+ return `${baseStyle} ${themeToStyle(resolvedTheme)}`;
321
+ }
322
+ return baseStyle;
323
+ });
324
+
325
+ const densityClass = $derived.by(() => {
326
+ const densityMap = {
327
+ compact: 'sg-density-compact',
328
+ normal: 'sg-density-normal',
329
+ comfortable: 'sg-density-comfortable'
330
+ };
331
+ return densityMap[density];
332
+ });
333
+
334
+ const gridClasses = $derived.by(() => {
335
+ const classes = ['sg-grid', densityClass];
336
+ if (bordered) classes.push('sg-bordered');
337
+ if (stripedRows) classes.push('sg-striped');
338
+ if (rowHover) classes.push('sg-row-hover');
339
+ if (className) classes.push(className);
340
+ return classes.join(' ');
341
+ });
342
+ </script>
343
+
344
+ <div
345
+ bind:this={containerRef}
346
+ class={gridClasses}
347
+ style={containerStyle}
348
+ role="grid"
349
+ aria-rowcount={gridState.totalRows}
350
+ aria-colcount={gridState.visibleColumns.length}
351
+ >
352
+ <GridHeader
353
+ columns={gridState.visibleColumns}
354
+ {onheaderclick}
355
+ {headerCell}
356
+ resizable={resizableColumns}
357
+ />
358
+
359
+ {#if gridState.totalRows === 0}
360
+ <div class="sg-empty" role="status">
361
+ {#if empty}
362
+ {@render empty()}
363
+ {:else}
364
+ <span class="sg-empty-text">{placeholder}</span>
365
+ {/if}
366
+ </div>
367
+ {:else}
368
+ <GridBody
369
+ displayRows={gridState.isGrouped ? gridState.visibleDisplayRows : undefined}
370
+ rows={gridState.isGrouped ? undefined : gridState.visibleRows}
371
+ columns={gridState.visibleColumns}
372
+ {rowKey}
373
+ startIndex={gridState.startIndex}
374
+ totalHeight={gridState.totalHeight}
375
+ offsetY={gridState.offsetY}
376
+ {rowHeight}
377
+ {groupHeaderHeight}
378
+ {groupHeader}
379
+ onrowclick={handleRowClick}
380
+ {onrowdblclick}
381
+ {oncellclick}
382
+ onscroll={handleBodyScroll}
383
+ {cell}
384
+ frozenRows={gridState.frozenRows}
385
+ frozenRowCount={gridState.frozenRowCount}
386
+ onrowcontextmenu={rowContextMenu ? handleRowContextMenu : undefined}
387
+ />
388
+ {/if}
389
+
390
+ <!-- Row context menu -->
391
+ {#if rowContextMenu && contextMenuOpen}
392
+ <Popup open={contextMenuOpen} targetRect={contextMenuRect} position="bottom-start" onclose={closeContextMenu}>
393
+ <Menu items={contextMenuItems} context={contextMenuContext} />
394
+ </Popup>
395
+ {/if}
396
+
397
+ <!-- Row click popup -->
398
+ {#if rowClickPopup && rowClickPopupOpen}
399
+ <Popup open={rowClickPopupOpen} targetRect={rowClickPopupRect} position="bottom-start" onclose={closeRowPopup}>
400
+ <div class="sg-row-popup-content">
401
+ {#if isSnippetRowPopup && rowClickPopup}
402
+ {@render (rowClickPopup as import('svelte').Snippet<[PopupContext<T>]>)(rowPopupContext)}
403
+ {:else}
404
+ {@html getRowPopupContent() ?? ''}
405
+ {/if}
406
+ </div>
407
+ </Popup>
408
+ {/if}
409
+ </div>
410
+
411
+ <style>
412
+ /* ============================================
413
+ Grid Container - Modern Tailwind-inspired Design
414
+ ============================================ */
415
+ .sg-grid {
416
+ /* Colors - Refined slate palette */
417
+ --sg-border-color: #e2e8f0;
418
+ --sg-header-bg: linear-gradient(to bottom, #f8fafc, #f1f5f9);
419
+ --sg-header-color: #0f172a;
420
+ --sg-header-border: #cbd5e1;
421
+ --sg-row-bg: #ffffff;
422
+ --sg-row-alt-bg: #f8fafc;
423
+ --sg-row-hover-bg: #f1f5f9;
424
+ --sg-primary-color: #3b82f6;
425
+ --sg-primary-light: #dbeafe;
426
+
427
+ /* Spacing */
428
+ --sg-cell-padding-x: 14px;
429
+ --sg-cell-padding-y: 10px;
430
+
431
+ /* Typography */
432
+ --sg-font-size: 13.5px;
433
+ --sg-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
434
+ --sg-header-font-weight: 600;
435
+ --sg-header-font-size: 12px;
436
+ --sg-header-letter-spacing: 0.025em;
437
+
438
+ /* Shadows & Effects */
439
+ --sg-header-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
440
+ --sg-frozen-shadow: 4px 0 8px -2px rgba(0, 0, 0, 0.08);
441
+
442
+ /* Transitions */
443
+ --sg-transition-fast: 0.1s ease;
444
+ --sg-transition-normal: 0.15s ease;
445
+
446
+ display: flex;
447
+ flex-direction: column;
448
+ overflow: hidden;
449
+ font-family: var(--sg-font-family);
450
+ font-size: var(--sg-font-size);
451
+ background: var(--sg-row-bg);
452
+ color: #334155;
453
+ line-height: 1.5;
454
+ -webkit-font-smoothing: antialiased;
455
+ -moz-osx-font-smoothing: grayscale;
456
+ }
457
+
458
+ .sg-bordered {
459
+ border: 1px solid var(--sg-border-color);
460
+ border-radius: 8px;
461
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.03);
462
+ }
463
+
464
+ /* Density Variants */
465
+ .sg-density-compact {
466
+ --sg-cell-padding-x: 10px;
467
+ --sg-cell-padding-y: 6px;
468
+ --sg-font-size: 12.5px;
469
+ }
470
+
471
+ .sg-density-normal {
472
+ --sg-cell-padding-x: 14px;
473
+ --sg-cell-padding-y: 10px;
474
+ --sg-font-size: 13.5px;
475
+ }
476
+
477
+ .sg-density-comfortable {
478
+ --sg-cell-padding-x: 18px;
479
+ --sg-cell-padding-y: 14px;
480
+ --sg-font-size: 14px;
481
+ }
482
+
483
+ /* ============================================
484
+ Empty State
485
+ ============================================ */
486
+ .sg-empty {
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: center;
490
+ flex: 1;
491
+ padding: 48px 24px;
492
+ color: #94a3b8;
493
+ }
494
+
495
+ .sg-empty-text {
496
+ font-size: 14px;
497
+ }
498
+
499
+ /* Row popup content */
500
+ .sg-row-popup-content {
501
+ padding: 12px;
502
+ font-size: 13px;
503
+ }
504
+ </style>
@@ -0,0 +1,80 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ColumnDefinition, CellContext, GridEvents, MenuItemDefinition, PopupContent, GroupBy, GroupInfo } from '../types.js';
3
+ import { type GridTheme, type ThemeName } from '../themes.js';
4
+ declare function $$render<T>(): {
5
+ props: {
6
+ /** Array of data objects */
7
+ data: T[];
8
+ /** Column definitions */
9
+ columns: ColumnDefinition<T>[];
10
+ /** Unique key field in data for row identification */
11
+ rowKey?: string;
12
+ /** Fixed height for the grid container */
13
+ height?: string | number;
14
+ /** Fixed row height in pixels */
15
+ rowHeight?: number;
16
+ /** Placeholder text when no data */
17
+ placeholder?: string;
18
+ /** Enable row hover highlighting */
19
+ rowHover?: boolean;
20
+ /** Enable alternating row colors */
21
+ stripedRows?: boolean;
22
+ /** Enable grid borders */
23
+ bordered?: boolean;
24
+ /** Grid density */
25
+ density?: "compact" | "normal" | "comfortable";
26
+ /** Enable column resizing */
27
+ resizableColumns?: boolean;
28
+ /** Number of rows to freeze at the top */
29
+ frozenRows?: number;
30
+ /** CSS class for the grid container */
31
+ class?: string;
32
+ /** Row click handler */
33
+ onrowclick?: GridEvents<T>["rowclick"];
34
+ /** Row double-click handler */
35
+ onrowdblclick?: GridEvents<T>["rowdblclick"];
36
+ /** Cell click handler */
37
+ oncellclick?: GridEvents<T>["cellclick"];
38
+ /** Header click handler */
39
+ onheaderclick?: GridEvents<T>["headerclick"];
40
+ /** Custom cell renderer */
41
+ cell?: Snippet<[CellContext<T>]>;
42
+ /** Custom header cell renderer */
43
+ headerCell?: Snippet<[ColumnDefinition<T>]>;
44
+ /** Custom empty state renderer */
45
+ empty?: Snippet<[]>;
46
+ /** Row context menu (right-click) */
47
+ rowContextMenu?: MenuItemDefinition<T>[] | ((row: T, rowIndex: number) => MenuItemDefinition<T>[]);
48
+ /** Row click popup content */
49
+ rowClickPopup?: PopupContent<T>;
50
+ /** Group data by field(s) */
51
+ groupBy?: GroupBy<T>;
52
+ /** Custom group header snippet */
53
+ groupHeader?: Snippet<[GroupInfo<T>]>;
54
+ /** Height of group header rows */
55
+ groupHeaderHeight?: number;
56
+ /** Theme name or custom theme object */
57
+ theme?: ThemeName | GridTheme;
58
+ };
59
+ exports: {};
60
+ bindings: "";
61
+ slots: {};
62
+ events: {};
63
+ };
64
+ declare class __sveltets_Render<T> {
65
+ props(): ReturnType<typeof $$render<T>>['props'];
66
+ events(): ReturnType<typeof $$render<T>>['events'];
67
+ slots(): ReturnType<typeof $$render<T>>['slots'];
68
+ bindings(): "";
69
+ exports(): {};
70
+ }
71
+ interface $$IsomorphicComponent {
72
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
73
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
74
+ } & ReturnType<__sveltets_Render<T>['exports']>;
75
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
76
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
77
+ }
78
+ declare const Grid: $$IsomorphicComponent;
79
+ type Grid<T> = InstanceType<typeof Grid<T>>;
80
+ export default Grid;