@alaarab/ogrid-js 2.1.15 → 2.2.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.
package/dist/esm/index.js CHANGED
@@ -710,6 +710,66 @@ function calculateDropTarget(params) {
710
710
  }
711
711
  return { targetIndex, indicatorX };
712
712
  }
713
+ function computeVisibleColumnRange(scrollLeft, columnWidths, containerWidth, overscan = 2) {
714
+ if (columnWidths.length === 0 || containerWidth <= 0) {
715
+ return { startIndex: 0, endIndex: -1, leftOffset: 0, rightOffset: 0 };
716
+ }
717
+ let cumWidth = 0;
718
+ let rawStart = columnWidths.length;
719
+ let rawEnd = -1;
720
+ for (let i = 0; i < columnWidths.length; i++) {
721
+ const colStart = cumWidth;
722
+ cumWidth += columnWidths[i];
723
+ if (cumWidth > scrollLeft && rawStart === columnWidths.length) {
724
+ rawStart = i;
725
+ }
726
+ if (colStart < scrollLeft + containerWidth) {
727
+ rawEnd = i;
728
+ }
729
+ }
730
+ if (rawStart > rawEnd) {
731
+ return { startIndex: 0, endIndex: -1, leftOffset: 0, rightOffset: 0 };
732
+ }
733
+ const startIndex = Math.max(0, rawStart - overscan);
734
+ const endIndex = Math.min(columnWidths.length - 1, rawEnd + overscan);
735
+ let leftOffset = 0;
736
+ for (let i = 0; i < startIndex; i++) {
737
+ leftOffset += columnWidths[i];
738
+ }
739
+ let rightOffset = 0;
740
+ for (let i = endIndex + 1; i < columnWidths.length; i++) {
741
+ rightOffset += columnWidths[i];
742
+ }
743
+ return { startIndex, endIndex, leftOffset, rightOffset };
744
+ }
745
+ function partitionColumnsForVirtualization(visibleCols, columnRange, pinnedColumns) {
746
+ const pinnedLeft = [];
747
+ const pinnedRight = [];
748
+ const unpinned = [];
749
+ for (const col of visibleCols) {
750
+ const pin = pinnedColumns?.[col.columnId];
751
+ if (pin === "left") pinnedLeft.push(col);
752
+ else if (pin === "right") pinnedRight.push(col);
753
+ else unpinned.push(col);
754
+ }
755
+ if (!columnRange || columnRange.endIndex < 0) {
756
+ return {
757
+ pinnedLeft,
758
+ virtualizedUnpinned: unpinned,
759
+ pinnedRight,
760
+ leftSpacerWidth: 0,
761
+ rightSpacerWidth: 0
762
+ };
763
+ }
764
+ const virtualizedUnpinned = unpinned.slice(columnRange.startIndex, columnRange.endIndex + 1);
765
+ return {
766
+ pinnedLeft,
767
+ virtualizedUnpinned,
768
+ pinnedRight,
769
+ leftSpacerWidth: columnRange.leftOffset,
770
+ rightSpacerWidth: columnRange.rightOffset
771
+ };
772
+ }
713
773
  function computeVisibleRange(scrollTop, rowHeight, containerHeight, totalRows, overscan = 5) {
714
774
  if (totalRows <= 0 || rowHeight <= 0 || containerHeight <= 0) {
715
775
  return { startIndex: 0, endIndex: 0, offsetTop: 0, offsetBottom: 0 };
@@ -737,6 +797,235 @@ function getScrollTopForRow(rowIndex, rowHeight, containerHeight, align = "start
737
797
  return Math.max(0, rowTop - containerHeight + rowHeight);
738
798
  }
739
799
  }
800
+ function workerBody() {
801
+ const ctx = self;
802
+ ctx.onmessage = (e) => {
803
+ const msg = e.data;
804
+ if (msg.type !== "sort-filter") return;
805
+ const { requestId, values, filters, sort } = msg;
806
+ const rowCount = values.length;
807
+ let indices = [];
808
+ const filterEntries = Object.entries(filters);
809
+ if (filterEntries.length === 0) {
810
+ indices = new Array(rowCount);
811
+ for (let i = 0; i < rowCount; i++) indices[i] = i;
812
+ } else {
813
+ for (let r = 0; r < rowCount; r++) {
814
+ let pass = true;
815
+ for (let f = 0; f < filterEntries.length; f++) {
816
+ const colIdx = Number(filterEntries[f][0]);
817
+ const filter = filterEntries[f][1];
818
+ const cellVal = values[r][colIdx];
819
+ switch (filter.type) {
820
+ case "text": {
821
+ const trimmed = filter.value.trim().toLowerCase();
822
+ if (trimmed && !String(cellVal ?? "").toLowerCase().includes(trimmed)) {
823
+ pass = false;
824
+ }
825
+ break;
826
+ }
827
+ case "multiSelect": {
828
+ if (filter.value.length > 0) {
829
+ const set = new Set(filter.value);
830
+ if (!set.has(String(cellVal ?? ""))) {
831
+ pass = false;
832
+ }
833
+ }
834
+ break;
835
+ }
836
+ case "date": {
837
+ if (cellVal == null) {
838
+ pass = false;
839
+ break;
840
+ }
841
+ const ts = new Date(String(cellVal)).getTime();
842
+ if (isNaN(ts)) {
843
+ pass = false;
844
+ break;
845
+ }
846
+ if (filter.value.from) {
847
+ const fromTs = (/* @__PURE__ */ new Date(filter.value.from + "T00:00:00")).getTime();
848
+ if (ts < fromTs) {
849
+ pass = false;
850
+ break;
851
+ }
852
+ }
853
+ if (filter.value.to) {
854
+ const toTs = (/* @__PURE__ */ new Date(filter.value.to + "T23:59:59.999")).getTime();
855
+ if (ts > toTs) {
856
+ pass = false;
857
+ break;
858
+ }
859
+ }
860
+ break;
861
+ }
862
+ }
863
+ if (!pass) break;
864
+ }
865
+ if (pass) indices.push(r);
866
+ }
867
+ }
868
+ if (sort) {
869
+ const { columnIndex, direction } = sort;
870
+ const dir = direction === "asc" ? 1 : -1;
871
+ indices.sort((a, b) => {
872
+ const av = values[a][columnIndex];
873
+ const bv = values[b][columnIndex];
874
+ if (av == null && bv == null) return 0;
875
+ if (av == null) return -1 * dir;
876
+ if (bv == null) return 1 * dir;
877
+ if (typeof av === "number" && typeof bv === "number") {
878
+ return av === bv ? 0 : av > bv ? dir : -dir;
879
+ }
880
+ const sa = String(av).toLowerCase();
881
+ const sb = String(bv).toLowerCase();
882
+ return sa === sb ? 0 : sa > sb ? dir : -dir;
883
+ });
884
+ }
885
+ const response = {
886
+ type: "sort-filter-result",
887
+ requestId,
888
+ indices
889
+ };
890
+ ctx.postMessage(response);
891
+ };
892
+ }
893
+ var workerInstance = null;
894
+ var requestCounter = 0;
895
+ var pendingRequests = /* @__PURE__ */ new Map();
896
+ function createSortFilterWorker() {
897
+ if (workerInstance) return workerInstance;
898
+ if (typeof Worker === "undefined" || typeof Blob === "undefined" || typeof URL === "undefined") {
899
+ return null;
900
+ }
901
+ try {
902
+ const fnStr = workerBody.toString();
903
+ const blob = new Blob(
904
+ [`(${fnStr})()`],
905
+ { type: "application/javascript" }
906
+ );
907
+ const url = URL.createObjectURL(blob);
908
+ workerInstance = new Worker(url);
909
+ URL.revokeObjectURL(url);
910
+ workerInstance.onmessage = (e) => {
911
+ const { requestId, indices } = e.data;
912
+ const pending = pendingRequests.get(requestId);
913
+ if (pending) {
914
+ pendingRequests.delete(requestId);
915
+ pending.resolve(indices);
916
+ }
917
+ };
918
+ workerInstance.onerror = (err) => {
919
+ for (const [id, pending] of pendingRequests) {
920
+ pending.reject(new Error(err.message || "Worker error"));
921
+ pendingRequests.delete(id);
922
+ }
923
+ };
924
+ return workerInstance;
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+ function terminateSortFilterWorker() {
930
+ if (workerInstance) {
931
+ workerInstance.terminate();
932
+ workerInstance = null;
933
+ }
934
+ for (const [id, pending] of pendingRequests) {
935
+ pending.reject(new Error("Worker terminated"));
936
+ pendingRequests.delete(id);
937
+ }
938
+ }
939
+ function extractValueMatrix(data, columns) {
940
+ const matrix = new Array(data.length);
941
+ for (let r = 0; r < data.length; r++) {
942
+ const row = new Array(columns.length);
943
+ for (let c = 0; c < columns.length; c++) {
944
+ const val = getCellValue(data[r], columns[c]);
945
+ if (val == null) {
946
+ row[c] = null;
947
+ } else if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
948
+ row[c] = val;
949
+ } else {
950
+ row[c] = String(val);
951
+ }
952
+ }
953
+ matrix[r] = row;
954
+ }
955
+ return matrix;
956
+ }
957
+ function processClientSideDataAsync(data, columns, filters, sortBy, sortDirection) {
958
+ if (sortBy) {
959
+ const sortCol = columns.find((c) => c.columnId === sortBy);
960
+ if (sortCol?.compare) {
961
+ return Promise.resolve(processClientSideData(data, columns, filters, sortBy, sortDirection));
962
+ }
963
+ }
964
+ const worker = createSortFilterWorker();
965
+ if (!worker) {
966
+ return Promise.resolve(processClientSideData(data, columns, filters, sortBy, sortDirection));
967
+ }
968
+ const columnIndexMap = /* @__PURE__ */ new Map();
969
+ for (let i = 0; i < columns.length; i++) {
970
+ columnIndexMap.set(columns[i].columnId, i);
971
+ }
972
+ const values = extractValueMatrix(data, columns);
973
+ const columnMeta = columns.map((col, idx) => ({
974
+ type: col.type ?? "text",
975
+ index: idx
976
+ }));
977
+ const workerFilters = {};
978
+ for (const col of columns) {
979
+ const filterKey = getFilterField(col);
980
+ const val = filters[filterKey];
981
+ if (!val) continue;
982
+ const colIdx = columnIndexMap.get(col.columnId);
983
+ if (colIdx === void 0) continue;
984
+ switch (val.type) {
985
+ case "text":
986
+ workerFilters[colIdx] = { type: "text", value: val.value };
987
+ break;
988
+ case "multiSelect":
989
+ workerFilters[colIdx] = { type: "multiSelect", value: val.value };
990
+ break;
991
+ case "date":
992
+ workerFilters[colIdx] = { type: "date", value: { from: val.value.from, to: val.value.to } };
993
+ break;
994
+ // 'people' filter has a UserLike object — fall back to sync
995
+ case "people":
996
+ return Promise.resolve(processClientSideData(data, columns, filters, sortBy, sortDirection));
997
+ }
998
+ }
999
+ let sort;
1000
+ if (sortBy) {
1001
+ const sortColIdx = columnIndexMap.get(sortBy);
1002
+ if (sortColIdx !== void 0) {
1003
+ sort = { columnIndex: sortColIdx, direction: sortDirection ?? "asc" };
1004
+ }
1005
+ }
1006
+ const requestId = ++requestCounter;
1007
+ return new Promise((resolve, reject) => {
1008
+ pendingRequests.set(requestId, {
1009
+ resolve: (indices) => {
1010
+ const result = new Array(indices.length);
1011
+ for (let i = 0; i < indices.length; i++) {
1012
+ result[i] = data[indices[i]];
1013
+ }
1014
+ resolve(result);
1015
+ },
1016
+ reject
1017
+ });
1018
+ const request = {
1019
+ type: "sort-filter",
1020
+ requestId,
1021
+ values,
1022
+ columnMeta,
1023
+ filters: workerFilters,
1024
+ sort
1025
+ };
1026
+ worker.postMessage(request);
1027
+ });
1028
+ }
740
1029
  function getHeaderFilterConfig(col, input) {
741
1030
  const filterable = isFilterConfig(col.filterable) ? col.filterable : null;
742
1031
  const filterType = filterable?.type ?? "none";
@@ -1667,6 +1956,7 @@ var GridState = class {
1667
1956
  this._ariaLabel = options.ariaLabel;
1668
1957
  this._stickyHeader = options.stickyHeader ?? true;
1669
1958
  this._fullScreen = options.fullScreen ?? false;
1959
+ this._workerSort = options.workerSort ?? false;
1670
1960
  if (!this._dataSource) {
1671
1961
  this._filterOptions = deriveFilterOptionsFromData(
1672
1962
  this._data,
@@ -1770,6 +2060,31 @@ var GridState = class {
1770
2060
  const items = filtered.slice(startIdx, endIdx);
1771
2061
  return { items, totalCount };
1772
2062
  }
2063
+ /** Whether worker sort should be used for the current data set. */
2064
+ get useWorkerSort() {
2065
+ return this._workerSort === true || this._workerSort === "auto" && this._data.length > 5e3;
2066
+ }
2067
+ /**
2068
+ * Async version of getProcessedItems that offloads sort/filter to a Web Worker.
2069
+ * Falls back to sync when worker sort is not active.
2070
+ */
2071
+ async getProcessedItemsAsync() {
2072
+ if (this.isServerSide || !this.useWorkerSort) {
2073
+ return this.getProcessedItems();
2074
+ }
2075
+ const filtered = await processClientSideDataAsync(
2076
+ this._data,
2077
+ this._columns,
2078
+ this._filters,
2079
+ this._sort?.field,
2080
+ this._sort?.direction
2081
+ );
2082
+ const totalCount = filtered.length;
2083
+ const startIdx = (this._page - 1) * this._pageSize;
2084
+ const endIdx = startIdx + this._pageSize;
2085
+ const items = filtered.slice(startIdx, endIdx);
2086
+ return { items, totalCount };
2087
+ }
1773
2088
  // --- Server-side fetch ---
1774
2089
  fetchServerData() {
1775
2090
  if (!this._dataSource) return;
@@ -2159,6 +2474,9 @@ var TableRenderer = class {
2159
2474
  this.table = document.createElement("table");
2160
2475
  this.table.className = "ogrid-table";
2161
2476
  this.table.setAttribute("role", "grid");
2477
+ if (this.virtualScrollState) {
2478
+ this.table.setAttribute("data-virtual-scroll", "");
2479
+ }
2162
2480
  this.thead = document.createElement("thead");
2163
2481
  if (this.state.stickyHeader) {
2164
2482
  this.thead.classList.add("ogrid-sticky-header");
@@ -2571,6 +2889,23 @@ var TableRenderer = class {
2571
2889
  this.tbody.appendChild(topSpacer);
2572
2890
  }
2573
2891
  }
2892
+ const colVirtActive = vs?.columnVirtualizationEnabled === true && vs.columnRange != null;
2893
+ let renderCols = visibleCols;
2894
+ let colGlobalIndexMap = null;
2895
+ let colLeftSpacerWidth = 0;
2896
+ let colRightSpacerWidth = 0;
2897
+ if (colVirtActive && vs) {
2898
+ const partition = partitionColumnsForVirtualization(
2899
+ visibleCols,
2900
+ vs.columnRange,
2901
+ this.interactionState?.pinnedColumns
2902
+ );
2903
+ const combined = [...partition.pinnedLeft, ...partition.virtualizedUnpinned, ...partition.pinnedRight];
2904
+ colGlobalIndexMap = combined.map((c) => visibleCols.indexOf(c));
2905
+ renderCols = combined;
2906
+ colLeftSpacerWidth = partition.leftSpacerWidth;
2907
+ colRightSpacerWidth = partition.rightSpacerWidth;
2908
+ }
2574
2909
  for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
2575
2910
  const item = items[rowIndex];
2576
2911
  if (!item) continue;
@@ -2610,9 +2945,18 @@ var TableRenderer = class {
2610
2945
  td.textContent = String(rowNumberOffset + rowIndex + 1);
2611
2946
  tr.appendChild(td);
2612
2947
  }
2613
- for (let colIndex = 0; colIndex < visibleCols.length; colIndex++) {
2614
- const col = visibleCols[colIndex];
2615
- const globalColIndex = colIndex + colOffset;
2948
+ if (colLeftSpacerWidth > 0) {
2949
+ const spacerTd = document.createElement("td");
2950
+ spacerTd.style.width = `${colLeftSpacerWidth}px`;
2951
+ spacerTd.style.minWidth = `${colLeftSpacerWidth}px`;
2952
+ spacerTd.style.padding = "0";
2953
+ spacerTd.style.border = "none";
2954
+ spacerTd.setAttribute("aria-hidden", "true");
2955
+ tr.appendChild(spacerTd);
2956
+ }
2957
+ for (let colIndex = 0; colIndex < renderCols.length; colIndex++) {
2958
+ const col = renderCols[colIndex];
2959
+ const globalColIndex = (colGlobalIndexMap ? colGlobalIndexMap[colIndex] : colIndex) + colOffset;
2616
2960
  const td = document.createElement("td");
2617
2961
  td.className = "ogrid-cell";
2618
2962
  td.setAttribute("data-column-id", col.columnId);
@@ -2680,6 +3024,15 @@ var TableRenderer = class {
2680
3024
  }
2681
3025
  tr.appendChild(td);
2682
3026
  }
3027
+ if (colRightSpacerWidth > 0) {
3028
+ const spacerTd = document.createElement("td");
3029
+ spacerTd.style.width = `${colRightSpacerWidth}px`;
3030
+ spacerTd.style.minWidth = `${colRightSpacerWidth}px`;
3031
+ spacerTd.style.padding = "0";
3032
+ spacerTd.style.border = "none";
3033
+ spacerTd.setAttribute("aria-hidden", "true");
3034
+ tr.appendChild(spacerTd);
3035
+ }
2683
3036
  this.tbody.appendChild(tr);
2684
3037
  }
2685
3038
  if (isVirtual && vs) {
@@ -4026,6 +4379,14 @@ var VirtualScrollState = class {
4026
4379
  this._ro = null;
4027
4380
  this._resizeRafId = 0;
4028
4381
  this._cachedRange = { startIndex: 0, endIndex: -1, offsetTop: 0, offsetBottom: 0 };
4382
+ // Column virtualization
4383
+ this._scrollLeft = 0;
4384
+ this._scrollLeftRafId = 0;
4385
+ this._containerWidth = 0;
4386
+ this._columnWidths = [];
4387
+ this._cachedColumnRange = null;
4388
+ this._roWidth = null;
4389
+ this._resizeWidthRafId = 0;
4029
4390
  this._config = config ?? { enabled: false };
4030
4391
  validateVirtualScrollConfig(this._config);
4031
4392
  }
@@ -4054,6 +4415,71 @@ var VirtualScrollState = class {
4054
4415
  get totalHeight() {
4055
4416
  return computeTotalHeight(this._totalRows, this._config.rowHeight ?? DEFAULT_ROW_HEIGHT);
4056
4417
  }
4418
+ /** Whether column virtualization is active. */
4419
+ get columnVirtualizationEnabled() {
4420
+ return this._config.columns === true;
4421
+ }
4422
+ /** Get the current visible column range (null when column virtualization is disabled). */
4423
+ get columnRange() {
4424
+ return this._cachedColumnRange;
4425
+ }
4426
+ /** Set the unpinned column widths for horizontal virtualization. */
4427
+ setColumnWidths(widths) {
4428
+ this._columnWidths = widths;
4429
+ this.recomputeColumnRange();
4430
+ }
4431
+ /** Handle horizontal scroll events. RAF-throttled. */
4432
+ handleHorizontalScroll(scrollLeft) {
4433
+ if (!this.columnVirtualizationEnabled) return;
4434
+ if (this._scrollLeftRafId) cancelAnimationFrame(this._scrollLeftRafId);
4435
+ this._scrollLeftRafId = requestAnimationFrame(() => {
4436
+ this._scrollLeftRafId = 0;
4437
+ this._scrollLeft = scrollLeft;
4438
+ this.recomputeColumnRange();
4439
+ });
4440
+ }
4441
+ /** Observe a container element for width changes (column virtualization). */
4442
+ observeContainerWidth(el) {
4443
+ this.disconnectWidthObserver();
4444
+ if (typeof ResizeObserver !== "undefined") {
4445
+ this._roWidth = new ResizeObserver((entries) => {
4446
+ if (entries.length === 0) return;
4447
+ this._containerWidth = entries[0].contentRect.width;
4448
+ if (this._resizeWidthRafId) cancelAnimationFrame(this._resizeWidthRafId);
4449
+ this._resizeWidthRafId = requestAnimationFrame(() => {
4450
+ this._resizeWidthRafId = 0;
4451
+ this.recomputeColumnRange();
4452
+ });
4453
+ });
4454
+ this._roWidth.observe(el);
4455
+ }
4456
+ this._containerWidth = el.clientWidth;
4457
+ }
4458
+ disconnectWidthObserver() {
4459
+ if (this._roWidth) {
4460
+ this._roWidth.disconnect();
4461
+ this._roWidth = null;
4462
+ }
4463
+ }
4464
+ /** Recompute visible column range and emit if changed. */
4465
+ recomputeColumnRange() {
4466
+ if (!this.columnVirtualizationEnabled || this._columnWidths.length === 0 || this._containerWidth <= 0) {
4467
+ if (this._cachedColumnRange !== null) {
4468
+ this._cachedColumnRange = null;
4469
+ this.emitter.emit("columnRangeChanged", { columnRange: null });
4470
+ }
4471
+ return;
4472
+ }
4473
+ const overscan = this._config.columnOverscan ?? 2;
4474
+ const newRange = computeVisibleColumnRange(this._scrollLeft, this._columnWidths, this._containerWidth, overscan);
4475
+ const prev = this._cachedColumnRange;
4476
+ if (!prev || prev.startIndex !== newRange.startIndex || prev.endIndex !== newRange.endIndex) {
4477
+ this._cachedColumnRange = newRange;
4478
+ this.emitter.emit("columnRangeChanged", { columnRange: newRange });
4479
+ } else {
4480
+ this._cachedColumnRange = newRange;
4481
+ }
4482
+ }
4057
4483
  /** Handle scroll events from the table container. RAF-throttled. */
4058
4484
  handleScroll(scrollTop) {
4059
4485
  if (this.rafId) cancelAnimationFrame(this.rafId);
@@ -4130,6 +4556,10 @@ var VirtualScrollState = class {
4130
4556
  this.emitter.on("rangeChanged", handler);
4131
4557
  return () => this.emitter.off("rangeChanged", handler);
4132
4558
  }
4559
+ onColumnRangeChanged(handler) {
4560
+ this.emitter.on("columnRangeChanged", handler);
4561
+ return () => this.emitter.off("columnRangeChanged", handler);
4562
+ }
4133
4563
  onConfigChanged(handler) {
4134
4564
  this.emitter.on("configChanged", handler);
4135
4565
  return () => this.emitter.off("configChanged", handler);
@@ -4137,7 +4567,10 @@ var VirtualScrollState = class {
4137
4567
  destroy() {
4138
4568
  if (this.rafId) cancelAnimationFrame(this.rafId);
4139
4569
  if (this._resizeRafId) cancelAnimationFrame(this._resizeRafId);
4570
+ if (this._scrollLeftRafId) cancelAnimationFrame(this._scrollLeftRafId);
4571
+ if (this._resizeWidthRafId) cancelAnimationFrame(this._resizeWidthRafId);
4140
4572
  this.disconnectObserver();
4573
+ this.disconnectWidthObserver();
4141
4574
  this.emitter.removeAllListeners();
4142
4575
  }
4143
4576
  };
@@ -6427,16 +6860,25 @@ var OGrid = class {
6427
6860
  this.renderer.setVirtualScrollState(this.virtualScrollState);
6428
6861
  const handleScroll = () => {
6429
6862
  this.virtualScrollState?.handleScroll(this.tableContainer.scrollTop);
6863
+ this.virtualScrollState?.handleHorizontalScroll(this.tableContainer.scrollLeft);
6430
6864
  };
6431
6865
  this.tableContainer.addEventListener("scroll", handleScroll, { passive: true });
6432
6866
  this.unsubscribes.push(() => {
6433
6867
  this.tableContainer.removeEventListener("scroll", handleScroll);
6434
6868
  });
6869
+ if (options.virtualScroll?.columns) {
6870
+ this.virtualScrollState.observeContainerWidth(this.tableContainer);
6871
+ }
6435
6872
  this.unsubscribes.push(
6436
6873
  this.virtualScrollState.onRangeChanged(() => {
6437
6874
  this.renderingHelper.updateRendererInteractionState();
6438
6875
  })
6439
6876
  );
6877
+ this.unsubscribes.push(
6878
+ this.virtualScrollState.onColumnRangeChanged(() => {
6879
+ this.renderingHelper.updateRendererInteractionState();
6880
+ })
6881
+ );
6440
6882
  this.api.scrollToRow = (index, opts) => {
6441
6883
  this.virtualScrollState?.scrollToRow(index, this.tableContainer, opts?.align);
6442
6884
  };
@@ -6679,4 +7121,4 @@ var OGrid = class {
6679
7121
  }
6680
7122
  };
6681
7123
 
6682
- export { AUTOSIZE_EXTRA_PX, AUTOSIZE_MAX_PX, CELL_PADDING, CHECKBOX_COLUMN_WIDTH, COLUMN_HEADER_MENU_ITEMS, CellDescriptorCache, ClipboardState, ColumnChooser, ColumnPinningState, ColumnReorderState, ColumnResizeState, ContextMenu, DEFAULT_DEBOUNCE_MS, DEFAULT_MIN_COLUMN_WIDTH, EventEmitter, FillHandleState, GRID_BORDER_RADIUS, GRID_CONTEXT_MENU_ITEMS, GridState, HeaderFilter, HeaderFilterState, InlineCellEditor, KeyboardNavState, MAX_PAGE_BUTTONS, MarchingAntsOverlay, OGrid, OGridEventWiring, OGridRendering, PAGE_SIZE_OPTIONS, PEOPLE_SEARCH_DEBOUNCE_MS, PaginationControls, ROW_NUMBER_COLUMN_WIDTH, RowSelectionState, SIDEBAR_TRANSITION_MS, SelectionState, SideBar, SideBarState, StatusBar, TableLayoutState, TableRenderer, UndoRedoStack, UndoRedoState, VirtualScrollState, Z_INDEX, applyCellDeletion, applyCutClear, applyFillValues, applyPastedValues, applyRangeRowSelection, areGridRowPropsEqual, booleanParser, buildCellIndex, buildCsvHeader, buildCsvRows, buildHeaderRows, buildInlineEditorProps, buildPopoverEditorProps, calculateDropTarget, clampSelectionToBounds, computeAggregations, computeArrowNavigation, computeAutoScrollSpeed, computeNextSortState, computeRowSelectionState, computeTabNavigation, computeTotalHeight, computeVisibleRange, currencyParser, dateParser, debounce, deriveFilterOptionsFromData, emailParser, escapeCsvValue, exportToCsv, findCtrlArrowTarget, flattenColumns, formatCellValueForTsv, formatSelectionAsTsv, formatShortcut, getCellRenderDescriptor, getCellValue, getColumnHeaderMenuItems, getContextMenuHandlers, getDataGridStatusBarConfig, getFilterField, getHeaderFilterConfig, getMultiSelectFilterFields, getPaginationViewModel, getPinStateForColumn, getScrollTopForRow, getStatusBarParts, injectGlobalStyles, isColumnEditable, isFilterConfig, isInSelectionRange, isRowInRange, measureColumnContentWidth, measureRange, mergeFilter, normalizeSelectionRange, numberParser, parseTsvClipboard, parseValue, processClientSideData, rangesEqual, reorderColumnArray, resolveCellDisplayContent, resolveCellStyle, toUserLike, triggerCsvDownload, validateColumns, validateRowIds, validateVirtualScrollConfig };
7124
+ export { AUTOSIZE_EXTRA_PX, AUTOSIZE_MAX_PX, CELL_PADDING, CHECKBOX_COLUMN_WIDTH, COLUMN_HEADER_MENU_ITEMS, CellDescriptorCache, ClipboardState, ColumnChooser, ColumnPinningState, ColumnReorderState, ColumnResizeState, ContextMenu, DEFAULT_DEBOUNCE_MS, DEFAULT_MIN_COLUMN_WIDTH, EventEmitter, FillHandleState, GRID_BORDER_RADIUS, GRID_CONTEXT_MENU_ITEMS, GridState, HeaderFilter, HeaderFilterState, InlineCellEditor, KeyboardNavState, MAX_PAGE_BUTTONS, MarchingAntsOverlay, OGrid, OGridEventWiring, OGridRendering, PAGE_SIZE_OPTIONS, PEOPLE_SEARCH_DEBOUNCE_MS, PaginationControls, ROW_NUMBER_COLUMN_WIDTH, RowSelectionState, SIDEBAR_TRANSITION_MS, SelectionState, SideBar, SideBarState, StatusBar, TableLayoutState, TableRenderer, UndoRedoStack, UndoRedoState, VirtualScrollState, Z_INDEX, applyCellDeletion, applyCutClear, applyFillValues, applyPastedValues, applyRangeRowSelection, areGridRowPropsEqual, booleanParser, buildCellIndex, buildCsvHeader, buildCsvRows, buildHeaderRows, buildInlineEditorProps, buildPopoverEditorProps, calculateDropTarget, clampSelectionToBounds, computeAggregations, computeArrowNavigation, computeAutoScrollSpeed, computeNextSortState, computeRowSelectionState, computeTabNavigation, computeTotalHeight, computeVisibleColumnRange, computeVisibleRange, createSortFilterWorker, currencyParser, dateParser, debounce, deriveFilterOptionsFromData, emailParser, escapeCsvValue, exportToCsv, extractValueMatrix, findCtrlArrowTarget, flattenColumns, formatCellValueForTsv, formatSelectionAsTsv, formatShortcut, getCellRenderDescriptor, getCellValue, getColumnHeaderMenuItems, getContextMenuHandlers, getDataGridStatusBarConfig, getFilterField, getHeaderFilterConfig, getMultiSelectFilterFields, getPaginationViewModel, getPinStateForColumn, getScrollTopForRow, getStatusBarParts, injectGlobalStyles, isColumnEditable, isFilterConfig, isInSelectionRange, isRowInRange, measureColumnContentWidth, measureRange, mergeFilter, normalizeSelectionRange, numberParser, parseTsvClipboard, parseValue, partitionColumnsForVirtualization, processClientSideData, processClientSideDataAsync, rangesEqual, reorderColumnArray, resolveCellDisplayContent, resolveCellStyle, terminateSortFilterWorker, toUserLike, triggerCsvDownload, validateColumns, validateRowIds, validateVirtualScrollConfig };
@@ -329,8 +329,16 @@
329
329
  border-bottom: 1px solid var(--ogrid-border, #e8e8e8);
330
330
  position: relative;
331
331
  color: var(--ogrid-fg, #242424);
332
+ contain: content;
332
333
  }
333
334
 
335
+ /* Pinned columns need contain: none because contain breaks position: sticky */
336
+ .ogrid-table td[data-pinned='left'],
337
+ .ogrid-table td[data-pinned='right'] { contain: none; }
338
+
339
+ /* content-visibility: auto on rows for non-virtualized grids */
340
+ .ogrid-table:not([data-virtual-scroll]) tbody tr { content-visibility: auto; }
341
+
334
342
  .ogrid-table tbody tr:last-child td {
335
343
  border-bottom: none;
336
344
  }
@@ -28,6 +28,7 @@ export declare class GridState<T> {
28
28
  private _ariaLabel?;
29
29
  private _stickyHeader;
30
30
  private _fullScreen;
31
+ private _workerSort;
31
32
  private _filterOptions;
32
33
  private _columnOrder;
33
34
  private _visibleColsCache;
@@ -60,6 +61,16 @@ export declare class GridState<T> {
60
61
  items: T[];
61
62
  totalCount: number;
62
63
  };
64
+ /** Whether worker sort should be used for the current data set. */
65
+ get useWorkerSort(): boolean;
66
+ /**
67
+ * Async version of getProcessedItems that offloads sort/filter to a Web Worker.
68
+ * Falls back to sync when worker sort is not active.
69
+ */
70
+ getProcessedItemsAsync(): Promise<{
71
+ items: T[];
72
+ totalCount: number;
73
+ }>;
63
74
  private fetchServerData;
64
75
  setData(data: T[]): void;
65
76
  setPage(page: number): void;
@@ -1,8 +1,11 @@
1
- import type { IVirtualScrollConfig, IVisibleRange } from '@alaarab/ogrid-core';
1
+ import type { IVirtualScrollConfig, IVisibleRange, IVisibleColumnRange } from '@alaarab/ogrid-core';
2
2
  interface VirtualScrollEvents extends Record<string, unknown> {
3
3
  rangeChanged: {
4
4
  visibleRange: IVisibleRange;
5
5
  };
6
+ columnRangeChanged: {
7
+ columnRange: IVisibleColumnRange | null;
8
+ };
6
9
  configChanged: {
7
10
  config: IVirtualScrollConfig;
8
11
  };
@@ -21,6 +24,13 @@ export declare class VirtualScrollState {
21
24
  private _ro;
22
25
  private _resizeRafId;
23
26
  private _cachedRange;
27
+ private _scrollLeft;
28
+ private _scrollLeftRafId;
29
+ private _containerWidth;
30
+ private _columnWidths;
31
+ private _cachedColumnRange;
32
+ private _roWidth;
33
+ private _resizeWidthRafId;
24
34
  constructor(config?: IVirtualScrollConfig);
25
35
  /** Whether virtual scrolling is active (enabled + meets the row threshold). */
26
36
  get enabled(): boolean;
@@ -32,6 +42,19 @@ export declare class VirtualScrollState {
32
42
  get visibleRange(): IVisibleRange;
33
43
  /** Get the total scrollable height for all rows. */
34
44
  get totalHeight(): number;
45
+ /** Whether column virtualization is active. */
46
+ get columnVirtualizationEnabled(): boolean;
47
+ /** Get the current visible column range (null when column virtualization is disabled). */
48
+ get columnRange(): IVisibleColumnRange | null;
49
+ /** Set the unpinned column widths for horizontal virtualization. */
50
+ setColumnWidths(widths: number[]): void;
51
+ /** Handle horizontal scroll events. RAF-throttled. */
52
+ handleHorizontalScroll(scrollLeft: number): void;
53
+ /** Observe a container element for width changes (column virtualization). */
54
+ observeContainerWidth(el: HTMLElement): void;
55
+ private disconnectWidthObserver;
56
+ /** Recompute visible column range and emit if changed. */
57
+ private recomputeColumnRange;
35
58
  /** Handle scroll events from the table container. RAF-throttled. */
36
59
  handleScroll(scrollTop: number): void;
37
60
  /** Scroll the container to bring a specific row into view. */
@@ -46,6 +69,7 @@ export declare class VirtualScrollState {
46
69
  /** Recompute visible range and emit if changed. */
47
70
  private recompute;
48
71
  onRangeChanged(handler: (data: VirtualScrollEvents['rangeChanged']) => void): () => void;
72
+ onColumnRangeChanged(handler: (data: VirtualScrollEvents['columnRangeChanged']) => void): () => void;
49
73
  onConfigChanged(handler: (data: VirtualScrollEvents['configChanged']) => void): () => void;
50
74
  destroy(): void;
51
75
  }
@@ -100,6 +100,13 @@ export interface OGridOptions<T> {
100
100
  onFirstDataRendered?: () => void;
101
101
  /** Virtual scrolling configuration. */
102
102
  virtualScroll?: IVirtualScrollConfig;
103
+ /**
104
+ * Offload sorting to a Web Worker to avoid blocking the main thread.
105
+ * - `true`: always use worker sort
106
+ * - `'auto'`: use worker sort when data.length > 5000
107
+ * - `false` (default): use synchronous sort
108
+ */
109
+ workerSort?: boolean | 'auto';
103
110
  /** Fixed row height in pixels. Overrides default row height (36px). */
104
111
  rowHeight?: number;
105
112
  /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-js",
3
- "version": "2.1.15",
3
+ "version": "2.2.0",
4
4
  "description": "OGrid vanilla JS – framework-free data grid with sorting, filtering, pagination, and spreadsheet-style editing.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -36,7 +36,7 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
- "@alaarab/ogrid-core": "2.1.15"
39
+ "@alaarab/ogrid-core": "2.2.0"
40
40
  },
41
41
  "sideEffects": [
42
42
  "**/*.css"