@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,194 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from 'svelte';
3
+ import type { ColumnDefinition, CellContext, GridEvents, DisplayRow, GroupInfo } from '../types.js';
4
+ import Row from './Row.svelte';
5
+ import GroupHeader from './GroupHeader.svelte';
6
+
7
+ interface Props {
8
+ /** Display rows (grouped mode) - takes precedence if provided */
9
+ displayRows?: DisplayRow<T>[];
10
+ /** Legacy: data rows (non-grouped mode) */
11
+ rows?: T[];
12
+ columns: ColumnDefinition<T>[];
13
+ rowKey: string;
14
+ startIndex: number;
15
+ totalHeight: number;
16
+ offsetY: number;
17
+ rowHeight: number;
18
+ /** Height of group header rows */
19
+ groupHeaderHeight?: number;
20
+ onrowclick?: GridEvents<T>['rowclick'];
21
+ onrowdblclick?: GridEvents<T>['rowdblclick'];
22
+ oncellclick?: GridEvents<T>['cellclick'];
23
+ onscroll?: (scrollTop: number, scrollLeft: number) => void;
24
+ cell?: Snippet<[CellContext<T>]>;
25
+ /** Custom group header snippet */
26
+ groupHeader?: Snippet<[GroupInfo<T>]>;
27
+ frozenRows?: T[];
28
+ frozenRowCount?: number;
29
+ onrowcontextmenu?: (row: T, rowIndex: number, event: MouseEvent) => void;
30
+ }
31
+
32
+ let {
33
+ displayRows,
34
+ rows,
35
+ columns,
36
+ rowKey,
37
+ startIndex,
38
+ totalHeight,
39
+ offsetY,
40
+ rowHeight,
41
+ groupHeaderHeight = 36,
42
+ onrowclick,
43
+ onrowdblclick,
44
+ oncellclick,
45
+ onscroll,
46
+ cell,
47
+ groupHeader,
48
+ frozenRows = [],
49
+ frozenRowCount = 0,
50
+ onrowcontextmenu
51
+ }: Props = $props();
52
+
53
+ // Determine if we're in grouped mode
54
+ const isGrouped = $derived(displayRows !== undefined && displayRows.length > 0);
55
+
56
+ // Effective display rows - use displayRows if provided, otherwise convert rows
57
+ const effectiveDisplayRows = $derived.by((): DisplayRow<T>[] => {
58
+ if (displayRows) {
59
+ return displayRows;
60
+ }
61
+ if (rows) {
62
+ return rows.map((data, index) => ({
63
+ type: 'row' as const,
64
+ data,
65
+ index: startIndex + index
66
+ }));
67
+ }
68
+ return [];
69
+ });
70
+
71
+ const frozenRowsHeight = $derived(frozenRowCount * rowHeight);
72
+
73
+ // ============================================
74
+ // Scroll Handling
75
+ // ============================================
76
+
77
+ let scrollContainer: HTMLDivElement | undefined = $state();
78
+
79
+ function handleScroll(event: Event) {
80
+ const target = event.target as HTMLDivElement;
81
+ onscroll?.(target.scrollTop, target.scrollLeft);
82
+ }
83
+
84
+ // ============================================
85
+ // Helper to get row key
86
+ // ============================================
87
+
88
+ function getRowKey(row: T, index: number, isFrozen = false): string | number {
89
+ if (rowKey && typeof row === 'object' && row !== null && rowKey in row) {
90
+ return (row as Record<string, unknown>)[rowKey] as string | number;
91
+ }
92
+ return isFrozen ? `frozen-${index}` : startIndex + index;
93
+ }
94
+
95
+ function getDisplayRowKey(displayRow: DisplayRow<T>, index: number): string {
96
+ if (displayRow.type === 'group') {
97
+ return `group-${displayRow.group.field}-${displayRow.group.key}-${displayRow.group.level}`;
98
+ }
99
+ const row = displayRow.data;
100
+ if (rowKey && typeof row === 'object' && row !== null && rowKey in row) {
101
+ return `row-${(row as Record<string, unknown>)[rowKey]}`;
102
+ }
103
+ return `row-${displayRow.index}`;
104
+ }
105
+
106
+ function getDisplayRowHeight(displayRow: DisplayRow<T>): number {
107
+ return displayRow.type === 'group' ? groupHeaderHeight : rowHeight;
108
+ }
109
+ </script>
110
+
111
+ <div
112
+ bind:this={scrollContainer}
113
+ class="sg-body"
114
+ role="rowgroup"
115
+ onscroll={handleScroll}
116
+ >
117
+ <!-- Frozen rows at the top (only in non-grouped mode) -->
118
+ {#if !isGrouped && frozenRowCount > 0 && frozenRows.length > 0}
119
+ <div class="sg-frozen-rows" style="height: {frozenRowsHeight}px;">
120
+ {#each frozenRows as row, index (getRowKey(row, index, true))}
121
+ <Row
122
+ {row}
123
+ {columns}
124
+ rowIndex={index}
125
+ {rowHeight}
126
+ {onrowclick}
127
+ {onrowdblclick}
128
+ {oncellclick}
129
+ {cell}
130
+ {onrowcontextmenu}
131
+ />
132
+ {/each}
133
+ </div>
134
+ {/if}
135
+
136
+ <!-- Spacer for total scroll height -->
137
+ <div class="sg-scroll-spacer" style="height: {totalHeight}px;">
138
+ <!-- Positioned container for visible rows -->
139
+ <div class="sg-rows-container" style="transform: translateY({offsetY}px);">
140
+ {#each effectiveDisplayRows as displayRow, index (getDisplayRowKey(displayRow, index))}
141
+ {#if displayRow.type === 'group'}
142
+ <GroupHeader
143
+ group={displayRow.group}
144
+ height={groupHeaderHeight}
145
+ {groupHeader}
146
+ {rowHeight}
147
+ />
148
+ {:else}
149
+ <Row
150
+ row={displayRow.data}
151
+ {columns}
152
+ rowIndex={displayRow.index + frozenRowCount}
153
+ {rowHeight}
154
+ {onrowclick}
155
+ {onrowdblclick}
156
+ {oncellclick}
157
+ {cell}
158
+ {onrowcontextmenu}
159
+ />
160
+ {/if}
161
+ {/each}
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <style>
167
+ .sg-body {
168
+ flex: 1;
169
+ overflow: auto;
170
+ position: relative;
171
+ }
172
+
173
+ .sg-frozen-rows {
174
+ position: sticky;
175
+ top: 0;
176
+ z-index: 3;
177
+ background: var(--sg-row-bg, #ffffff);
178
+ border-bottom: 2px solid var(--sg-frozen-border-color, #cbd5e1);
179
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
180
+ }
181
+
182
+ .sg-scroll-spacer {
183
+ position: relative;
184
+ width: 100%;
185
+ }
186
+
187
+ .sg-rows-container {
188
+ position: absolute;
189
+ top: 0;
190
+ left: 0;
191
+ right: 0;
192
+ will-change: transform;
193
+ }
194
+ </style>
@@ -0,0 +1,49 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ColumnDefinition, CellContext, GridEvents, DisplayRow, GroupInfo } from '../types.js';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ /** Display rows (grouped mode) - takes precedence if provided */
6
+ displayRows?: DisplayRow<T>[];
7
+ /** Legacy: data rows (non-grouped mode) */
8
+ rows?: T[];
9
+ columns: ColumnDefinition<T>[];
10
+ rowKey: string;
11
+ startIndex: number;
12
+ totalHeight: number;
13
+ offsetY: number;
14
+ rowHeight: number;
15
+ /** Height of group header rows */
16
+ groupHeaderHeight?: number;
17
+ onrowclick?: GridEvents<T>["rowclick"];
18
+ onrowdblclick?: GridEvents<T>["rowdblclick"];
19
+ oncellclick?: GridEvents<T>["cellclick"];
20
+ onscroll?: (scrollTop: number, scrollLeft: number) => void;
21
+ cell?: Snippet<[CellContext<T>]>;
22
+ /** Custom group header snippet */
23
+ groupHeader?: Snippet<[GroupInfo<T>]>;
24
+ frozenRows?: T[];
25
+ frozenRowCount?: number;
26
+ onrowcontextmenu?: (row: T, rowIndex: number, event: MouseEvent) => void;
27
+ };
28
+ exports: {};
29
+ bindings: "";
30
+ slots: {};
31
+ events: {};
32
+ };
33
+ declare class __sveltets_Render<T> {
34
+ props(): ReturnType<typeof $$render<T>>['props'];
35
+ events(): ReturnType<typeof $$render<T>>['events'];
36
+ slots(): ReturnType<typeof $$render<T>>['slots'];
37
+ bindings(): "";
38
+ exports(): {};
39
+ }
40
+ interface $$IsomorphicComponent {
41
+ 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']>> & {
42
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
43
+ } & ReturnType<__sveltets_Render<T>['exports']>;
44
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
45
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
46
+ }
47
+ declare const GridBody: $$IsomorphicComponent;
48
+ type GridBody<T> = InstanceType<typeof GridBody<T>>;
49
+ export default GridBody;
@@ -0,0 +1,99 @@
1
+ <script lang="ts" generics="T">
2
+ import { getContext } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+ import type { ColumnDefinition, GridEvents, SortDirection } from '../types.js';
5
+ import { GRID_CONTEXT_KEY, type GridStateManager } from '../state/gridState.svelte.js';
6
+ import HeaderCell from './HeaderCell.svelte';
7
+
8
+ interface Props {
9
+ columns: ColumnDefinition<T>[];
10
+ onheaderclick?: GridEvents<T>['headerclick'];
11
+ headerCell?: Snippet<[ColumnDefinition<T>]>;
12
+ resizable?: boolean;
13
+ }
14
+
15
+ let { columns, onheaderclick, headerCell, resizable = false }: Props = $props();
16
+
17
+ // Get grid state from context
18
+ const gridState = getContext<GridStateManager<T>>(GRID_CONTEXT_KEY);
19
+
20
+ // ============================================
21
+ // Sort Handler
22
+ // ============================================
23
+
24
+ function handleSort(field: string, currentDirection: SortDirection, multiSort: boolean) {
25
+ gridState.toggleSort(field, multiSort);
26
+ }
27
+
28
+ // ============================================
29
+ // Resize State & Handlers
30
+ // ============================================
31
+
32
+ let resizing = $state<{
33
+ field: string;
34
+ startX: number;
35
+ startWidth: number;
36
+ } | null>(null);
37
+
38
+ function handleResizeStart(field: string, startX: number, startWidth: number) {
39
+ resizing = { field, startX, startWidth };
40
+
41
+ // Add global listeners for resize
42
+ document.addEventListener('mousemove', handleResizeMove);
43
+ document.addEventListener('mouseup', handleResizeEnd);
44
+ }
45
+
46
+ function handleResizeMove(event: MouseEvent) {
47
+ if (!resizing) return;
48
+
49
+ const delta = event.clientX - resizing.startX;
50
+ const newWidth = Math.max(50, resizing.startWidth + delta);
51
+ gridState.setColumnWidth(resizing.field, newWidth);
52
+ }
53
+
54
+ function handleResizeEnd() {
55
+ resizing = null;
56
+ document.removeEventListener('mousemove', handleResizeMove);
57
+ document.removeEventListener('mouseup', handleResizeEnd);
58
+ }
59
+ </script>
60
+
61
+ <div class="sg-header" role="rowgroup">
62
+ <div class="sg-header-row" role="row">
63
+ {#each columns as column, index (column.field)}
64
+ {@const frozenLeft = column.frozen === true || column.frozen === 'left'}
65
+ {@const frozenRight = column.frozen === 'right'}
66
+ {@const frozenPosition = frozenLeft
67
+ ? { side: 'left' as const, offset: gridState.getFrozenLeftPosition(column.field) }
68
+ : frozenRight
69
+ ? { side: 'right' as const, offset: gridState.getFrozenRightPosition(column.field) }
70
+ : undefined}
71
+ <HeaderCell
72
+ {column}
73
+ {index}
74
+ columnWidth={gridState.getColumnWidth(column.field)}
75
+ {onheaderclick}
76
+ {headerCell}
77
+ onsort={column.sortable ? handleSort : undefined}
78
+ sortDirection={gridState.getSortDirection(column.field)}
79
+ sortIndex={gridState.getSortIndex(column.field)}
80
+ onresizestart={resizable ? handleResizeStart : undefined}
81
+ {frozenPosition}
82
+ />
83
+ {/each}
84
+ </div>
85
+ </div>
86
+
87
+ <style>
88
+ .sg-header {
89
+ flex-shrink: 0;
90
+ background: var(--sg-header-bg, linear-gradient(to bottom, #f8fafc, #f1f5f9));
91
+ border-bottom: 1px solid var(--sg-header-border, #cbd5e1);
92
+ box-shadow: var(--sg-header-shadow, 0 1px 2px rgba(0, 0, 0, 0.05));
93
+ }
94
+
95
+ .sg-header-row {
96
+ display: flex;
97
+ min-width: 100%;
98
+ }
99
+ </style>
@@ -0,0 +1,31 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ColumnDefinition, GridEvents } from '../types.js';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ columns: ColumnDefinition<T>[];
6
+ onheaderclick?: GridEvents<T>["headerclick"];
7
+ headerCell?: Snippet<[ColumnDefinition<T>]>;
8
+ resizable?: boolean;
9
+ };
10
+ exports: {};
11
+ bindings: "";
12
+ slots: {};
13
+ events: {};
14
+ };
15
+ declare class __sveltets_Render<T> {
16
+ props(): ReturnType<typeof $$render<T>>['props'];
17
+ events(): ReturnType<typeof $$render<T>>['events'];
18
+ slots(): ReturnType<typeof $$render<T>>['slots'];
19
+ bindings(): "";
20
+ exports(): {};
21
+ }
22
+ interface $$IsomorphicComponent {
23
+ 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']>> & {
24
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
25
+ } & ReturnType<__sveltets_Render<T>['exports']>;
26
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
27
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
28
+ }
29
+ declare const GridHeader: $$IsomorphicComponent;
30
+ type GridHeader<T> = InstanceType<typeof GridHeader<T>>;
31
+ export default GridHeader;
@@ -0,0 +1,192 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from 'svelte';
3
+ import type { GroupInfo } from '../types.js';
4
+
5
+ interface Props {
6
+ /** Group information */
7
+ group: GroupInfo<T>;
8
+ /** Height of the group header row */
9
+ height: number;
10
+ /** Custom header content snippet */
11
+ groupHeader?: Snippet<[GroupInfo<T>]>;
12
+ /** Row height (for calculating indent) */
13
+ rowHeight?: number;
14
+ }
15
+
16
+ let { group, height, groupHeader, rowHeight = 40 }: Props = $props();
17
+
18
+ // ============================================
19
+ // Event Handlers
20
+ // ============================================
21
+
22
+ function handleClick(event: MouseEvent) {
23
+ event.stopPropagation();
24
+ group.toggle();
25
+ }
26
+
27
+ function handleKeydown(event: KeyboardEvent) {
28
+ if (event.key === 'Enter' || event.key === ' ') {
29
+ event.preventDefault();
30
+ group.toggle();
31
+ } else if (event.key === 'ArrowRight' && !group.isOpen) {
32
+ event.preventDefault();
33
+ group.toggle();
34
+ } else if (event.key === 'ArrowLeft' && group.isOpen) {
35
+ event.preventDefault();
36
+ group.toggle();
37
+ }
38
+ }
39
+
40
+ // ============================================
41
+ // Computed
42
+ // ============================================
43
+
44
+ const indentPx = $derived(group.level * 20);
45
+
46
+ const toggleIcon = $derived(group.isOpen ? '▼' : '▶');
47
+
48
+ // Default header text
49
+ const defaultHeaderText = $derived(`${group.key} (${group.count})`);
50
+ </script>
51
+
52
+ <div
53
+ class="sg-group-header"
54
+ class:sg-group-header-open={group.isOpen}
55
+ class:sg-group-header-collapsed={!group.isOpen}
56
+ role="row"
57
+ aria-expanded={group.isOpen}
58
+ tabindex="0"
59
+ style="height: {height}px; padding-left: {indentPx}px;"
60
+ onclick={handleClick}
61
+ onkeydown={handleKeydown}
62
+ >
63
+ <button
64
+ class="sg-group-toggle"
65
+ type="button"
66
+ aria-label={group.isOpen ? 'Collapse group' : 'Expand group'}
67
+ tabindex="-1"
68
+ >
69
+ <span class="sg-group-toggle-icon">{toggleIcon}</span>
70
+ </button>
71
+
72
+ <div class="sg-group-header-content">
73
+ {#if groupHeader}
74
+ {@render groupHeader(group)}
75
+ {:else}
76
+ <span class="sg-group-field">{group.field}:</span>
77
+ <span class="sg-group-key">{group.key}</span>
78
+ <span class="sg-group-count">({group.count})</span>
79
+ {/if}
80
+ </div>
81
+
82
+ {#if group.aggregates && Object.keys(group.aggregates).length > 0}
83
+ <div class="sg-group-aggregates">
84
+ {#each Object.entries(group.aggregates) as [field, agg]}
85
+ {#if agg.sum !== undefined}
86
+ <span class="sg-group-agg" title="{field}: sum">
87
+ Σ {agg.sum.toLocaleString()}
88
+ </span>
89
+ {/if}
90
+ {/each}
91
+ </div>
92
+ {/if}
93
+ </div>
94
+
95
+ <style>
96
+ .sg-group-header {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 8px;
100
+ background: var(--sg-group-header-bg, #f1f5f9);
101
+ border-bottom: 1px solid var(--sg-border-color, #e2e8f0);
102
+ font-weight: 600;
103
+ font-size: 13px;
104
+ color: var(--sg-group-header-text, #334155);
105
+ cursor: pointer;
106
+ user-select: none;
107
+ transition: background-color 0.15s ease;
108
+ box-sizing: border-box;
109
+ min-width: 100%;
110
+ }
111
+
112
+ .sg-group-header:hover {
113
+ background: var(--sg-group-header-hover-bg, #e2e8f0);
114
+ }
115
+
116
+ .sg-group-header:focus {
117
+ outline: 2px solid var(--sg-primary-color, #3b82f6);
118
+ outline-offset: -2px;
119
+ }
120
+
121
+ .sg-group-header-collapsed {
122
+ background: var(--sg-group-header-collapsed-bg, #f8fafc);
123
+ }
124
+
125
+ .sg-group-toggle {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ width: 20px;
130
+ height: 20px;
131
+ padding: 0;
132
+ margin-left: 8px;
133
+ border: none;
134
+ background: transparent;
135
+ cursor: pointer;
136
+ color: var(--sg-group-toggle-color, #64748b);
137
+ font-size: 10px;
138
+ transition: transform 0.15s ease, color 0.15s ease;
139
+ flex-shrink: 0;
140
+ }
141
+
142
+ .sg-group-toggle:hover {
143
+ color: var(--sg-group-toggle-hover-color, #334155);
144
+ }
145
+
146
+ .sg-group-toggle-icon {
147
+ transition: transform 0.15s ease;
148
+ }
149
+
150
+ .sg-group-header-content {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 6px;
154
+ flex: 1;
155
+ overflow: hidden;
156
+ }
157
+
158
+ .sg-group-field {
159
+ color: var(--sg-group-field-color, #64748b);
160
+ font-weight: 500;
161
+ font-size: 12px;
162
+ }
163
+
164
+ .sg-group-key {
165
+ font-weight: 600;
166
+ color: var(--sg-group-key-color, #1e293b);
167
+ white-space: nowrap;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ }
171
+
172
+ .sg-group-count {
173
+ color: var(--sg-group-count-color, #94a3b8);
174
+ font-weight: 400;
175
+ font-size: 12px;
176
+ flex-shrink: 0;
177
+ }
178
+
179
+ .sg-group-aggregates {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 12px;
183
+ margin-right: 12px;
184
+ font-size: 12px;
185
+ color: var(--sg-group-agg-color, #64748b);
186
+ }
187
+
188
+ .sg-group-agg {
189
+ font-weight: 500;
190
+ font-variant-numeric: tabular-nums;
191
+ }
192
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { GroupInfo } from '../types.js';
3
+ declare function $$render<T>(): {
4
+ props: {
5
+ /** Group information */
6
+ group: GroupInfo<T>;
7
+ /** Height of the group header row */
8
+ height: number;
9
+ /** Custom header content snippet */
10
+ groupHeader?: Snippet<[GroupInfo<T>]>;
11
+ /** Row height (for calculating indent) */
12
+ rowHeight?: number;
13
+ };
14
+ exports: {};
15
+ bindings: "";
16
+ slots: {};
17
+ events: {};
18
+ };
19
+ declare class __sveltets_Render<T> {
20
+ props(): ReturnType<typeof $$render<T>>['props'];
21
+ events(): ReturnType<typeof $$render<T>>['events'];
22
+ slots(): ReturnType<typeof $$render<T>>['slots'];
23
+ bindings(): "";
24
+ exports(): {};
25
+ }
26
+ interface $$IsomorphicComponent {
27
+ 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']>> & {
28
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
29
+ } & ReturnType<__sveltets_Render<T>['exports']>;
30
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
31
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
32
+ }
33
+ declare const GroupHeader: $$IsomorphicComponent;
34
+ type GroupHeader<T> = InstanceType<typeof GroupHeader<T>>;
35
+ export default GroupHeader;