@alaarab/ogrid-angular 2.0.2 → 2.0.4

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.
@@ -41,6 +41,7 @@ let MarchingAntsOverlayComponent = class MarchingAntsOverlayComponent {
41
41
  this.copyRange = input(null);
42
42
  this.cutRange = input(null);
43
43
  this.colOffset = input(0);
44
+ this.columnSizingVersion = input(0);
44
45
  this.selRect = signal(null);
45
46
  this.clipRect = signal(null);
46
47
  this.rafId = 0;
@@ -51,6 +52,7 @@ let MarchingAntsOverlayComponent = class MarchingAntsOverlayComponent {
51
52
  const selRange = this.selectionRange();
52
53
  const clipRange = this.copyRange() ?? this.cutRange();
53
54
  const colOff = this.colOffset();
55
+ const _version = this.columnSizingVersion(); // Track column resize changes
54
56
  if (this.resizeObserver) {
55
57
  this.resizeObserver.disconnect();
56
58
  this.resizeObserver = null;
package/dist/esm/index.js CHANGED
@@ -4,6 +4,8 @@ export { toUserLike, isInSelectionRange, normalizeSelectionRange } from './types
4
4
  // Services
5
5
  export { OGridService } from './services/ogrid.service';
6
6
  export { DataGridStateService } from './services/datagrid-state.service';
7
+ export { ColumnReorderService } from './services/column-reorder.service';
8
+ export { VirtualScrollService } from './services/virtual-scroll.service';
7
9
  // Components
8
10
  export { OGridLayoutComponent } from './components/ogrid-layout.component';
9
11
  export { StatusBarComponent } from './components/status-bar.component';
@@ -11,3 +13,4 @@ export { GridContextMenuComponent } from './components/grid-context-menu.compone
11
13
  export { SideBarComponent } from './components/sidebar.component';
12
14
  export { MarchingAntsOverlayComponent } from './components/marching-ants-overlay.component';
13
15
  export { EmptyStateComponent } from './components/empty-state.component';
16
+ export { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, createDebouncedSignal, createDebouncedCallback, debounce, createLatestRef, createLatestCallback, } from './utils';
@@ -0,0 +1,180 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Injectable, signal, DestroyRef, inject } from '@angular/core';
8
+ import { reorderColumnArray } from '@alaarab/ogrid-core';
9
+ /** Width of the resize handle zone on the right edge of each header cell. */
10
+ const RESIZE_HANDLE_ZONE = 8;
11
+ /**
12
+ * Manages column reorder drag interactions with RAF-throttled updates.
13
+ * Angular signals-based port of React's useColumnReorder hook.
14
+ */
15
+ let ColumnReorderService = class ColumnReorderService {
16
+ constructor() {
17
+ this.destroyRef = inject(DestroyRef);
18
+ // --- Input signals (set by consuming component) ---
19
+ this.columns = signal([]);
20
+ this.columnOrder = signal(undefined);
21
+ this.onColumnOrderChange = signal(undefined);
22
+ this.enabled = signal(true);
23
+ this.wrapperEl = signal(null);
24
+ // --- Internal state ---
25
+ this.isDragging = signal(false);
26
+ this.dropIndicatorX = signal(null);
27
+ // Imperative drag tracking (not reactive)
28
+ this.rafId = 0;
29
+ this.cleanupFn = null;
30
+ // Refs for latest values (captured in closure)
31
+ this.latestDropTargetIndex = null;
32
+ this.destroyRef.onDestroy(() => {
33
+ if (this.cleanupFn) {
34
+ this.cleanupFn();
35
+ this.cleanupFn = null;
36
+ }
37
+ if (this.rafId) {
38
+ cancelAnimationFrame(this.rafId);
39
+ this.rafId = 0;
40
+ }
41
+ });
42
+ }
43
+ /**
44
+ * Call this from the header cell's mousedown handler.
45
+ * @param columnId - The column being dragged
46
+ * @param event - The native MouseEvent
47
+ */
48
+ handleHeaderMouseDown(columnId, event) {
49
+ if (!this.enabled())
50
+ return;
51
+ if (!this.onColumnOrderChange())
52
+ return;
53
+ // Gate on left-click only
54
+ if (event.button !== 0)
55
+ return;
56
+ // Skip if in resize handle zone (right 8px of the header cell)
57
+ const target = event.currentTarget;
58
+ const rect = target.getBoundingClientRect();
59
+ if (event.clientX > rect.right - RESIZE_HANDLE_ZONE)
60
+ return;
61
+ // Skip column groups — only reorder leaf columns
62
+ const cols = this.columns();
63
+ const colIndex = cols.findIndex((c) => c.columnId === columnId);
64
+ if (colIndex === -1)
65
+ return;
66
+ event.preventDefault();
67
+ const startX = event.clientX;
68
+ let hasMoved = false;
69
+ this.latestDropTargetIndex = null;
70
+ // Lock text selection during drag
71
+ const prevUserSelect = document.body.style.userSelect;
72
+ document.body.style.userSelect = 'none';
73
+ const onMove = (moveEvent) => {
74
+ // Require a small minimum drag distance before activating
75
+ if (!hasMoved && Math.abs(moveEvent.clientX - startX) < 5)
76
+ return;
77
+ if (!hasMoved) {
78
+ hasMoved = true;
79
+ this.isDragging.set(true);
80
+ }
81
+ if (this.rafId)
82
+ cancelAnimationFrame(this.rafId);
83
+ this.rafId = requestAnimationFrame(() => {
84
+ this.rafId = 0;
85
+ const wrapper = this.wrapperEl();
86
+ if (!wrapper)
87
+ return;
88
+ const headerCells = wrapper.querySelectorAll('th[data-column-id]');
89
+ const rects = [];
90
+ for (let i = 0; i < headerCells.length; i++) {
91
+ const th = headerCells[i];
92
+ const id = th.getAttribute('data-column-id');
93
+ if (!id)
94
+ continue;
95
+ const thRect = th.getBoundingClientRect();
96
+ rects.push({
97
+ columnId: id,
98
+ left: thRect.left,
99
+ right: thRect.right,
100
+ centerX: thRect.left + thRect.width / 2,
101
+ });
102
+ }
103
+ const result = this.calculateDrop(columnId, moveEvent.clientX, rects);
104
+ this.latestDropTargetIndex = result.dropIndex;
105
+ this.dropIndicatorX.set(result.indicatorX);
106
+ });
107
+ };
108
+ const cleanup = () => {
109
+ window.removeEventListener('mousemove', onMove, true);
110
+ window.removeEventListener('mouseup', onUp, true);
111
+ this.cleanupFn = null;
112
+ // Restore user-select
113
+ document.body.style.userSelect = prevUserSelect;
114
+ // Cancel pending RAF
115
+ if (this.rafId) {
116
+ cancelAnimationFrame(this.rafId);
117
+ this.rafId = 0;
118
+ }
119
+ };
120
+ const onUp = () => {
121
+ cleanup();
122
+ if (hasMoved && this.latestDropTargetIndex != null) {
123
+ const currentOrder = this.columnOrder() ?? this.columns().map((c) => c.columnId);
124
+ const newOrder = reorderColumnArray(currentOrder, columnId, this.latestDropTargetIndex);
125
+ this.onColumnOrderChange()?.(newOrder);
126
+ }
127
+ this.isDragging.set(false);
128
+ this.dropIndicatorX.set(null);
129
+ };
130
+ window.addEventListener('mousemove', onMove, true);
131
+ window.addEventListener('mouseup', onUp, true);
132
+ this.cleanupFn = cleanup;
133
+ }
134
+ /**
135
+ * Calculate drop target from mouse position and header cell rects.
136
+ * Same logic as React's useColumnReorder inline calculation.
137
+ */
138
+ calculateDrop(draggedColumnId, mouseX, rects) {
139
+ if (rects.length === 0) {
140
+ return { dropIndex: null, indicatorX: null };
141
+ }
142
+ const order = this.columnOrder() ?? this.columns().map((c) => c.columnId);
143
+ const currentIndex = order.indexOf(draggedColumnId);
144
+ // Find which column the mouse is closest to
145
+ let bestIndex = 0;
146
+ let indicatorX = null;
147
+ if (mouseX <= rects[0].centerX) {
148
+ // Before the first column
149
+ bestIndex = 0;
150
+ indicatorX = rects[0].left;
151
+ }
152
+ else if (mouseX >= rects[rects.length - 1].centerX) {
153
+ // After the last column
154
+ bestIndex = rects.length;
155
+ indicatorX = rects[rects.length - 1].right;
156
+ }
157
+ else {
158
+ for (let i = 0; i < rects.length - 1; i++) {
159
+ if (mouseX >= rects[i].centerX && mouseX < rects[i + 1].centerX) {
160
+ bestIndex = i + 1;
161
+ indicatorX = rects[i].right;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ // Map visual index back to order array index
167
+ const targetOrderIndex = bestIndex < rects.length
168
+ ? order.indexOf(rects[bestIndex]?.columnId ?? '')
169
+ : order.length;
170
+ // Check if this is a no-op (dropping at same position)
171
+ if (currentIndex === targetOrderIndex || currentIndex + 1 === targetOrderIndex) {
172
+ return { dropIndex: targetOrderIndex, indicatorX: null };
173
+ }
174
+ return { dropIndex: targetOrderIndex, indicatorX };
175
+ }
176
+ };
177
+ ColumnReorderService = __decorate([
178
+ Injectable()
179
+ ], ColumnReorderService);
180
+ export { ColumnReorderService };
@@ -51,8 +51,6 @@ let DataGridStateService = class DataGridStateService {
51
51
  this.rafId = 0;
52
52
  this.lastMousePos = null;
53
53
  this.autoScrollInterval = null;
54
- // Window event listeners cleanup
55
- this.windowCleanups = [];
56
54
  // ResizeObserver
57
55
  this.resizeObserver = null;
58
56
  // --- Derived computed ---
@@ -124,8 +122,10 @@ let DataGridStateService = class DataGridStateService {
124
122
  });
125
123
  this.visibleColumnCount = computed(() => this.visibleCols().length);
126
124
  this.hasCheckboxCol = computed(() => (this.props()?.rowSelection ?? 'none') === 'multiple');
127
- this.totalColCount = computed(() => this.visibleColumnCount() + (this.hasCheckboxCol() ? 1 : 0));
128
- this.colOffset = computed(() => this.hasCheckboxCol() ? 1 : 0);
125
+ this.hasRowNumbersCol = computed(() => !!this.props()?.showRowNumbers);
126
+ this.specialColsCount = computed(() => (this.hasCheckboxCol() ? 1 : 0) + (this.hasRowNumbersCol() ? 1 : 0));
127
+ this.totalColCount = computed(() => this.visibleColumnCount() + this.specialColsCount());
128
+ this.colOffset = computed(() => this.specialColsCount());
129
129
  this.rowIndexByRowId = computed(() => {
130
130
  const p = this.props();
131
131
  if (!p)
@@ -199,13 +199,16 @@ let DataGridStateService = class DataGridStateService {
199
199
  return p.items.length === 0 && !!p.emptyState && !p.isLoading;
200
200
  });
201
201
  // Setup window event listeners for cell selection drag
202
- const onMove = (e) => this.onWindowMouseMove(e);
203
- const onUp = () => this.onWindowMouseUp();
204
- window.addEventListener('mousemove', onMove, true);
205
- window.addEventListener('mouseup', onUp, true);
206
- this.windowCleanups.push(() => {
207
- window.removeEventListener('mousemove', onMove, true);
208
- window.removeEventListener('mouseup', onUp, true);
202
+ // Using effect with cleanup return to ensure proper removal on destroy
203
+ effect((onCleanup) => {
204
+ const onMove = (e) => this.onWindowMouseMove(e);
205
+ const onUp = () => this.onWindowMouseUp();
206
+ window.addEventListener('mousemove', onMove, true);
207
+ window.addEventListener('mouseup', onUp, true);
208
+ onCleanup(() => {
209
+ window.removeEventListener('mousemove', onMove, true);
210
+ window.removeEventListener('mouseup', onUp, true);
211
+ });
209
212
  });
210
213
  // Initialize column sizing overrides from initial widths
211
214
  effect(() => {
@@ -240,7 +243,6 @@ let DataGridStateService = class DataGridStateService {
240
243
  });
241
244
  // Cleanup on destroy
242
245
  this.destroyRef.onDestroy(() => {
243
- this.windowCleanups.forEach((fn) => fn());
244
246
  if (this.rafId)
245
247
  cancelAnimationFrame(this.rafId);
246
248
  if (this.autoScrollInterval)
@@ -947,6 +949,7 @@ let DataGridStateService = class DataGridStateService {
947
949
  totalColCount: this.totalColCount(),
948
950
  colOffset: this.colOffset(),
949
951
  hasCheckboxCol: this.hasCheckboxCol(),
952
+ hasRowNumbersCol: this.hasRowNumbersCol(),
950
953
  rowIndexByRowId: this.rowIndexByRowId(),
951
954
  containerWidth: this.containerWidthSig(),
952
955
  minTableWidth: this.minTableWidth(),
@@ -1257,6 +1260,9 @@ let DataGridStateService = class DataGridStateService {
1257
1260
  this.endBatch();
1258
1261
  }
1259
1262
  this.fillDragStart = null;
1263
+ // Remove event listeners after mouseup completes
1264
+ window.removeEventListener('mousemove', onMove, true);
1265
+ window.removeEventListener('mouseup', onUp, true);
1260
1266
  };
1261
1267
  window.addEventListener('mousemove', onMove, true);
1262
1268
  window.addEventListener('mouseup', onUp, true);
@@ -72,10 +72,10 @@ let OGridService = class OGridService {
72
72
  this.internalData = signal([]);
73
73
  this.internalLoading = signal(false);
74
74
  this.internalPage = signal(1);
75
- this.internalPageSize = signal(DEFAULT_PAGE_SIZE);
76
- this.internalSort = signal({ field: '', direction: 'asc' });
75
+ this.internalPageSizeOverride = signal(null);
76
+ this.internalSortOverride = signal(null);
77
77
  this.internalFilters = signal({});
78
- this.internalVisibleColumns = signal(new Set());
78
+ this.internalVisibleColumnsOverride = signal(null);
79
79
  this.internalSelectedRows = signal(new Set());
80
80
  this.columnWidthOverrides = signal({});
81
81
  this.pinnedOverrides = signal({});
@@ -91,10 +91,6 @@ let OGridService = class OGridService {
91
91
  // Filter options state
92
92
  this.serverFilterOptions = signal({});
93
93
  this.loadingFilterOptions = signal({});
94
- // --- Initialization ---
95
- this.sortInitialized = false;
96
- this.visibleColumnsInitialized = false;
97
- this.pageSizeInitialized = false;
98
94
  // --- Derived computed signals ---
99
95
  this.columns = computed(() => flattenColumns(this.columnsProp()));
100
96
  this.isServerSide = computed(() => this.dataSource() != null);
@@ -103,10 +99,23 @@ let OGridService = class OGridService {
103
99
  this.displayLoading = computed(() => this.controlledLoading() ?? this.internalLoading());
104
100
  this.defaultSortField = computed(() => this.defaultSortBy() ?? this.columns()[0]?.columnId ?? '');
105
101
  this.page = computed(() => this.controlledPage() ?? this.internalPage());
106
- this.pageSize = computed(() => this.controlledPageSize() ?? this.internalPageSize());
107
- this.sort = computed(() => this.controlledSort() ?? this.internalSort());
102
+ this.pageSize = computed(() => this.controlledPageSize() ?? this.internalPageSizeOverride() ?? this.defaultPageSize());
103
+ this.sort = computed(() => this.controlledSort() ?? this.internalSortOverride() ?? {
104
+ field: this.defaultSortField(),
105
+ direction: this.defaultSortDirection(),
106
+ });
108
107
  this.filters = computed(() => this.controlledFilters() ?? this.internalFilters());
109
- this.visibleColumns = computed(() => this.controlledVisibleColumns() ?? this.internalVisibleColumns());
108
+ this.visibleColumns = computed(() => {
109
+ if (this.controlledVisibleColumns())
110
+ return this.controlledVisibleColumns();
111
+ if (this.internalVisibleColumnsOverride())
112
+ return this.internalVisibleColumnsOverride();
113
+ const cols = this.columns();
114
+ if (cols.length === 0)
115
+ return new Set();
116
+ const visible = cols.filter((c) => c.defaultVisible !== false).map((c) => c.columnId);
117
+ return new Set(visible.length > 0 ? visible : cols.map((c) => c.columnId));
118
+ });
110
119
  this.effectiveSelectedRows = computed(() => this.selectedRows() ?? this.internalSelectedRows());
111
120
  this.columnChooserPlacement = computed(() => {
112
121
  const prop = this.columnChooserProp();
@@ -281,30 +290,6 @@ let OGridService = class OGridService {
281
290
  filterOptions: this.clientFilterOptions(),
282
291
  };
283
292
  });
284
- // Initialize internal default values based on config
285
- effect(() => {
286
- if (!this.sortInitialized) {
287
- this.sortInitialized = true;
288
- this.internalSort.set({
289
- field: this.defaultSortField(),
290
- direction: this.defaultSortDirection(),
291
- });
292
- }
293
- });
294
- effect(() => {
295
- if (!this.pageSizeInitialized) {
296
- this.pageSizeInitialized = true;
297
- this.internalPageSize.set(this.defaultPageSize());
298
- }
299
- });
300
- effect(() => {
301
- if (!this.visibleColumnsInitialized && this.columns().length > 0) {
302
- this.visibleColumnsInitialized = true;
303
- const cols = this.columns();
304
- const visible = cols.filter((c) => c.defaultVisible !== false).map((c) => c.columnId);
305
- this.internalVisibleColumns.set(new Set(visible.length > 0 ? visible : cols.map((c) => c.columnId)));
306
- }
307
- });
308
293
  // Server-side data fetching effect
309
294
  effect(() => {
310
295
  const ds = this.dataSource();
@@ -391,13 +376,13 @@ let OGridService = class OGridService {
391
376
  }
392
377
  setPageSize(size) {
393
378
  if (this.controlledPageSize() === undefined)
394
- this.internalPageSize.set(size);
379
+ this.internalPageSizeOverride.set(size);
395
380
  this.onPageSizeChange()?.(size);
396
381
  this.setPage(1);
397
382
  }
398
383
  setSort(s) {
399
384
  if (this.controlledSort() === undefined)
400
- this.internalSort.set(s);
385
+ this.internalSortOverride.set(s);
401
386
  this.onSortChange()?.(s);
402
387
  this.setPage(1);
403
388
  }
@@ -409,7 +394,7 @@ let OGridService = class OGridService {
409
394
  }
410
395
  setVisibleColumns(cols) {
411
396
  if (this.controlledVisibleColumns() === undefined)
412
- this.internalVisibleColumns.set(cols);
397
+ this.internalVisibleColumnsOverride.set(cols);
413
398
  this.onVisibleColumnsChange()?.(cols);
414
399
  }
415
400
  handleSort(columnKey) {
@@ -544,6 +529,64 @@ let OGridService = class OGridService {
544
529
  this.ariaLabelledBy.set(props['aria-labelledby']);
545
530
  }
546
531
  // --- API ---
532
+ // --- Column Pinning Methods ---
533
+ /**
534
+ * Pin a column to the left or right edge.
535
+ */
536
+ pinColumn(columnId, side) {
537
+ this.pinnedOverrides.update((prev) => ({ ...prev, [columnId]: side }));
538
+ this.onColumnPinned()?.(columnId, side);
539
+ }
540
+ /**
541
+ * Unpin a column (remove sticky positioning).
542
+ */
543
+ unpinColumn(columnId) {
544
+ this.pinnedOverrides.update((prev) => {
545
+ const next = { ...prev };
546
+ delete next[columnId];
547
+ return next;
548
+ });
549
+ this.onColumnPinned()?.(columnId, null);
550
+ }
551
+ /**
552
+ * Check if a column is pinned and which side.
553
+ */
554
+ isPinned(columnId) {
555
+ return this.pinnedOverrides()[columnId];
556
+ }
557
+ /**
558
+ * Compute sticky left offsets for left-pinned columns.
559
+ * Returns a map of columnId -> left offset in pixels.
560
+ */
561
+ computeLeftOffsets(visibleCols, columnWidths, defaultWidth, hasCheckboxColumn, checkboxColumnWidth) {
562
+ const offsets = {};
563
+ const pinned = this.pinnedOverrides();
564
+ let left = hasCheckboxColumn ? checkboxColumnWidth : 0;
565
+ for (const col of visibleCols) {
566
+ if (pinned[col.columnId] === 'left') {
567
+ offsets[col.columnId] = left;
568
+ left += columnWidths[col.columnId] ?? defaultWidth;
569
+ }
570
+ }
571
+ return offsets;
572
+ }
573
+ /**
574
+ * Compute sticky right offsets for right-pinned columns.
575
+ * Returns a map of columnId -> right offset in pixels.
576
+ */
577
+ computeRightOffsets(visibleCols, columnWidths, defaultWidth) {
578
+ const offsets = {};
579
+ const pinned = this.pinnedOverrides();
580
+ let right = 0;
581
+ for (let i = visibleCols.length - 1; i >= 0; i--) {
582
+ const col = visibleCols[i];
583
+ if (pinned[col.columnId] === 'right') {
584
+ offsets[col.columnId] = right;
585
+ right += columnWidths[col.columnId] ?? defaultWidth;
586
+ }
587
+ }
588
+ return offsets;
589
+ }
547
590
  getApi() {
548
591
  return {
549
592
  setRowData: (d) => {
@@ -610,6 +653,14 @@ let OGridService = class OGridService {
610
653
  this.refreshCounter.update((c) => c + 1);
611
654
  }
612
655
  },
656
+ getColumnOrder: () => this.columnOrder() ?? this.columns().map((c) => c.columnId),
657
+ setColumnOrder: (order) => {
658
+ this.onColumnOrderChange()?.(order);
659
+ },
660
+ scrollToRow: (_index, _options) => {
661
+ // Scrolling is handled by VirtualScrollService at the UI layer.
662
+ // The UI component should wire this to VirtualScrollService.scrollToRow().
663
+ },
613
664
  };
614
665
  }
615
666
  };
@@ -0,0 +1,91 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Injectable, signal, computed, DestroyRef, inject } from '@angular/core';
8
+ import { computeVisibleRange, computeTotalHeight, getScrollTopForRow, } from '@alaarab/ogrid-core';
9
+ /** Threshold below which virtual scrolling is a no-op (all rows rendered). */
10
+ const PASSTHROUGH_THRESHOLD = 100;
11
+ /**
12
+ * Manages virtual scrolling state using Angular signals.
13
+ * Port of React's useVirtualScroll hook.
14
+ *
15
+ * Uses core's pure-TS `computeVisibleRange` and `getScrollTopForRow` utilities.
16
+ * The UI layer (Angular Material / PrimeNG) provides the scrollable container
17
+ * and calls `onScroll()` / sets `containerHeight`.
18
+ */
19
+ let VirtualScrollService = class VirtualScrollService {
20
+ constructor() {
21
+ this.destroyRef = inject(DestroyRef);
22
+ // --- Input signals (set by consuming component) ---
23
+ this.totalRows = signal(0);
24
+ this.config = signal({ rowHeight: 36 });
25
+ this.containerHeight = signal(0);
26
+ // --- Internal state ---
27
+ this.scrollTop = signal(0);
28
+ // Scrollable container reference for programmatic scrolling
29
+ this.containerEl = null;
30
+ // --- Derived computed signals ---
31
+ this.rowHeight = computed(() => this.config().rowHeight);
32
+ this.overscan = computed(() => this.config().overscan ?? 5);
33
+ this.enabled = computed(() => this.config().enabled !== false);
34
+ /** Whether virtual scrolling is actually active (enabled + enough rows). */
35
+ this.isActive = computed(() => this.enabled() && this.totalRows() >= PASSTHROUGH_THRESHOLD);
36
+ /** The visible range of rows with spacer offsets. */
37
+ this.visibleRange = computed(() => {
38
+ if (!this.isActive()) {
39
+ // Passthrough: render all rows
40
+ return {
41
+ startIndex: 0,
42
+ endIndex: Math.max(0, this.totalRows() - 1),
43
+ offsetTop: 0,
44
+ offsetBottom: 0,
45
+ };
46
+ }
47
+ return computeVisibleRange(this.scrollTop(), this.rowHeight(), this.containerHeight(), this.totalRows(), this.overscan());
48
+ });
49
+ /** Total scrollable height in pixels. */
50
+ this.totalHeight = computed(() => computeTotalHeight(this.totalRows(), this.rowHeight()));
51
+ this.destroyRef.onDestroy(() => {
52
+ this.containerEl = null;
53
+ });
54
+ }
55
+ /**
56
+ * Set the scrollable container element.
57
+ * Used for programmatic scrolling (scrollToRow).
58
+ */
59
+ setContainer(el) {
60
+ this.containerEl = el;
61
+ }
62
+ /**
63
+ * Call this from the container's scroll event handler.
64
+ */
65
+ onScroll(event) {
66
+ const target = event.target;
67
+ this.scrollTop.set(target.scrollTop);
68
+ }
69
+ /**
70
+ * Scroll to a specific row index.
71
+ * @param index - The row index to scroll to.
72
+ * @param align - Where to position the row: 'start' (top), 'center', or 'end' (bottom). Default: 'start'.
73
+ */
74
+ scrollToRow(index, align = 'start') {
75
+ const container = this.containerEl;
76
+ if (!container)
77
+ return;
78
+ const targetScrollTop = getScrollTopForRow(index, this.rowHeight(), this.containerHeight(), align);
79
+ container.scrollTo({ top: targetScrollTop, behavior: 'auto' });
80
+ }
81
+ /**
82
+ * Update the virtual scroll configuration.
83
+ */
84
+ updateConfig(updates) {
85
+ this.config.update((prev) => ({ ...prev, ...updates }));
86
+ }
87
+ };
88
+ VirtualScrollService = __decorate([
89
+ Injectable()
90
+ ], VirtualScrollService);
91
+ export { VirtualScrollService };