@alaarab/ogrid-js 2.1.14 → 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
@@ -502,23 +502,39 @@ function processClientSideData(data, columns, filters, sortBy, sortDirection) {
502
502
  const trimmed = val.value.trim();
503
503
  if (trimmed) {
504
504
  const lower = trimmed.toLowerCase();
505
- predicates.push((r) => String(getCellValue(r, col) ?? "").toLowerCase().includes(lower));
505
+ const textCache = /* @__PURE__ */ new Map();
506
+ for (let j = 0; j < data.length; j++) {
507
+ textCache.set(data[j], String(getCellValue(data[j], col) ?? "").toLowerCase());
508
+ }
509
+ predicates.push((r) => (textCache.get(r) ?? "").includes(lower));
506
510
  }
507
511
  break;
508
512
  }
509
513
  case "people": {
510
514
  const email = val.value.email.toLowerCase();
511
- predicates.push((r) => String(getCellValue(r, col) ?? "").toLowerCase() === email);
515
+ const peopleCache = /* @__PURE__ */ new Map();
516
+ for (let j = 0; j < data.length; j++) {
517
+ peopleCache.set(data[j], String(getCellValue(data[j], col) ?? "").toLowerCase());
518
+ }
519
+ predicates.push((r) => (peopleCache.get(r) ?? "") === email);
512
520
  break;
513
521
  }
514
522
  case "date": {
515
523
  const dv = val.value;
516
524
  const fromTs = dv.from ? (/* @__PURE__ */ new Date(dv.from + "T00:00:00")).getTime() : NaN;
517
525
  const toTs = dv.to ? (/* @__PURE__ */ new Date(dv.to + "T23:59:59.999")).getTime() : NaN;
526
+ const dateCache = /* @__PURE__ */ new Map();
527
+ for (let j = 0; j < data.length; j++) {
528
+ const cellVal = getCellValue(data[j], col);
529
+ if (cellVal == null) {
530
+ dateCache.set(data[j], NaN);
531
+ } else {
532
+ const t = new Date(String(cellVal)).getTime();
533
+ dateCache.set(data[j], Number.isNaN(t) ? NaN : t);
534
+ }
535
+ }
518
536
  predicates.push((r) => {
519
- const cellVal = getCellValue(r, col);
520
- if (cellVal == null) return false;
521
- const cellTs = new Date(String(cellVal)).getTime();
537
+ const cellTs = dateCache.get(r) ?? NaN;
522
538
  if (Number.isNaN(cellTs)) return false;
523
539
  if (!Number.isNaN(fromTs) && cellTs < fromTs) return false;
524
540
  if (!Number.isNaN(toTs) && cellTs > toTs) return false;
@@ -694,6 +710,66 @@ function calculateDropTarget(params) {
694
710
  }
695
711
  return { targetIndex, indicatorX };
696
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
+ }
697
773
  function computeVisibleRange(scrollTop, rowHeight, containerHeight, totalRows, overscan = 5) {
698
774
  if (totalRows <= 0 || rowHeight <= 0 || containerHeight <= 0) {
699
775
  return { startIndex: 0, endIndex: 0, offsetTop: 0, offsetBottom: 0 };
@@ -721,6 +797,235 @@ function getScrollTopForRow(rowIndex, rowHeight, containerHeight, align = "start
721
797
  return Math.max(0, rowTop - containerHeight + rowHeight);
722
798
  }
723
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
+ }
724
1029
  function getHeaderFilterConfig(col, input) {
725
1030
  const filterable = isFilterConfig(col.filterable) ? col.filterable : null;
726
1031
  const filterType = filterable?.type ?? "none";
@@ -1261,7 +1566,7 @@ function computeRowSelectionState(selectedIds, items, getRowId) {
1261
1566
  if (selectedIds.size === 0 || items.length === 0) {
1262
1567
  return { allSelected: false, someSelected: false };
1263
1568
  }
1264
- const allSelected = items.every((item) => selectedIds.has(getRowId(item)));
1569
+ const allSelected = selectedIds.size >= items.length && items.every((item) => selectedIds.has(getRowId(item)));
1265
1570
  const someSelected = !allSelected && selectedIds.size > 0;
1266
1571
  return { allSelected, someSelected };
1267
1572
  }
@@ -1651,6 +1956,7 @@ var GridState = class {
1651
1956
  this._ariaLabel = options.ariaLabel;
1652
1957
  this._stickyHeader = options.stickyHeader ?? true;
1653
1958
  this._fullScreen = options.fullScreen ?? false;
1959
+ this._workerSort = options.workerSort ?? false;
1654
1960
  if (!this._dataSource) {
1655
1961
  this._filterOptions = deriveFilterOptionsFromData(
1656
1962
  this._data,
@@ -1754,6 +2060,31 @@ var GridState = class {
1754
2060
  const items = filtered.slice(startIdx, endIdx);
1755
2061
  return { items, totalCount };
1756
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
+ }
1757
2088
  // --- Server-side fetch ---
1758
2089
  fetchServerData() {
1759
2090
  if (!this._dataSource) return;
@@ -1958,6 +2289,17 @@ var GridState = class {
1958
2289
  };
1959
2290
 
1960
2291
  // src/renderer/TableRenderer.ts
2292
+ function rangeBounds(r) {
2293
+ return {
2294
+ minR: Math.min(r.startRow, r.endRow),
2295
+ maxR: Math.max(r.startRow, r.endRow),
2296
+ minC: Math.min(r.startCol, r.endCol),
2297
+ maxC: Math.max(r.startCol, r.endCol)
2298
+ };
2299
+ }
2300
+ function inBounds(b, row, col) {
2301
+ return row >= b.minR && row <= b.maxR && col >= b.minC && col <= b.maxC;
2302
+ }
1961
2303
  var TableRenderer = class {
1962
2304
  constructor(container, state) {
1963
2305
  this.table = null;
@@ -2132,6 +2474,9 @@ var TableRenderer = class {
2132
2474
  this.table = document.createElement("table");
2133
2475
  this.table.className = "ogrid-table";
2134
2476
  this.table.setAttribute("role", "grid");
2477
+ if (this.virtualScrollState) {
2478
+ this.table.setAttribute("data-virtual-scroll", "");
2479
+ }
2135
2480
  this.thead = document.createElement("thead");
2136
2481
  if (this.state.stickyHeader) {
2137
2482
  this.thead.classList.add("ogrid-sticky-header");
@@ -2227,6 +2572,13 @@ var TableRenderer = class {
2227
2572
  const lastSelection = this.lastSelectionRange;
2228
2573
  const lastCopy = this.lastCopyRange;
2229
2574
  const lastCut = this.lastCutRange;
2575
+ const selBounds = selectionRange ? rangeBounds(selectionRange) : null;
2576
+ const lastSelBounds = lastSelection ? rangeBounds(lastSelection) : null;
2577
+ const copyBounds = copyRange ? rangeBounds(copyRange) : null;
2578
+ const lastCopyBounds = lastCopy ? rangeBounds(lastCopy) : null;
2579
+ const cutBounds = cutRange ? rangeBounds(cutRange) : null;
2580
+ const lastCutBounds = lastCut ? rangeBounds(lastCut) : null;
2581
+ const colOffset = this.getColOffset();
2230
2582
  const cells = this.tbody.querySelectorAll("td[data-row-index][data-col-index]");
2231
2583
  for (let i = 0; i < cells.length; i++) {
2232
2584
  const el = cells[i];
@@ -2234,7 +2586,6 @@ var TableRenderer = class {
2234
2586
  if (!coords) continue;
2235
2587
  const rowIndex = coords.rowIndex;
2236
2588
  const globalColIndex = coords.colIndex;
2237
- const colOffset = this.getColOffset();
2238
2589
  const colIndex = globalColIndex - colOffset;
2239
2590
  const wasActive = lastActive && lastActive.rowIndex === rowIndex && lastActive.columnIndex === globalColIndex;
2240
2591
  const isActive = activeCell && activeCell.rowIndex === rowIndex && activeCell.columnIndex === globalColIndex;
@@ -2245,28 +2596,30 @@ var TableRenderer = class {
2245
2596
  el.setAttribute("data-active-cell", "true");
2246
2597
  el.style.outline = "2px solid var(--ogrid-accent, #0078d4)";
2247
2598
  }
2248
- const wasInRange = lastSelection && isInSelectionRange(lastSelection, rowIndex, colIndex);
2249
- const isInRange = selectionRange && isInSelectionRange(selectionRange, rowIndex, colIndex);
2250
- if (wasInRange && !isInRange) {
2599
+ const wasInRange = lastSelBounds && inBounds(lastSelBounds, rowIndex, colIndex);
2600
+ const isInRange = selBounds && inBounds(selBounds, rowIndex, colIndex);
2601
+ const showRange = isInRange && !isActive;
2602
+ const showedRange = wasInRange && !(lastActive && lastActive.rowIndex === rowIndex && lastActive.columnIndex === globalColIndex);
2603
+ if (showedRange && !showRange) {
2251
2604
  el.removeAttribute("data-in-range");
2252
2605
  el.style.backgroundColor = "";
2253
- } else if (isInRange && !wasInRange) {
2606
+ } else if (showRange && !showedRange) {
2254
2607
  el.setAttribute("data-in-range", "true");
2255
2608
  el.style.backgroundColor = "var(--ogrid-range-bg, rgba(33, 115, 70, 0.12))";
2256
2609
  }
2257
- const wasInCopy = lastCopy && isInSelectionRange(lastCopy, rowIndex, colIndex);
2258
- const isInCopy = copyRange && isInSelectionRange(copyRange, rowIndex, colIndex);
2610
+ const wasInCopy = lastCopyBounds && inBounds(lastCopyBounds, rowIndex, colIndex);
2611
+ const isInCopy = copyBounds && inBounds(copyBounds, rowIndex, colIndex);
2259
2612
  if (wasInCopy && !isInCopy) {
2260
- if (!isActive && !(cutRange && isInSelectionRange(cutRange, rowIndex, colIndex))) {
2613
+ if (!isActive && !(cutBounds && inBounds(cutBounds, rowIndex, colIndex))) {
2261
2614
  el.style.outline = "";
2262
2615
  }
2263
2616
  } else if (isInCopy && !wasInCopy) {
2264
2617
  el.style.outline = "1px dashed var(--ogrid-fg-muted, rgba(0, 0, 0, 0.5))";
2265
2618
  }
2266
- const wasInCut = lastCut && isInSelectionRange(lastCut, rowIndex, colIndex);
2267
- const isInCut = cutRange && isInSelectionRange(cutRange, rowIndex, colIndex);
2619
+ const wasInCut = lastCutBounds && inBounds(lastCutBounds, rowIndex, colIndex);
2620
+ const isInCut = cutBounds && inBounds(cutBounds, rowIndex, colIndex);
2268
2621
  if (wasInCut && !isInCut) {
2269
- if (!isActive && !(copyRange && isInSelectionRange(copyRange, rowIndex, colIndex))) {
2622
+ if (!isActive && !(copyBounds && inBounds(copyBounds, rowIndex, colIndex))) {
2270
2623
  el.style.outline = "";
2271
2624
  }
2272
2625
  } else if (isInCut && !wasInCut) {
@@ -2536,6 +2889,23 @@ var TableRenderer = class {
2536
2889
  this.tbody.appendChild(topSpacer);
2537
2890
  }
2538
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
+ }
2539
2909
  for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
2540
2910
  const item = items[rowIndex];
2541
2911
  if (!item) continue;
@@ -2575,9 +2945,18 @@ var TableRenderer = class {
2575
2945
  td.textContent = String(rowNumberOffset + rowIndex + 1);
2576
2946
  tr.appendChild(td);
2577
2947
  }
2578
- for (let colIndex = 0; colIndex < visibleCols.length; colIndex++) {
2579
- const col = visibleCols[colIndex];
2580
- 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;
2581
2960
  const td = document.createElement("td");
2582
2961
  td.className = "ogrid-cell";
2583
2962
  td.setAttribute("data-column-id", col.columnId);
@@ -2645,6 +3024,15 @@ var TableRenderer = class {
2645
3024
  }
2646
3025
  tr.appendChild(td);
2647
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
+ }
2648
3036
  this.tbody.appendChild(tr);
2649
3037
  }
2650
3038
  if (isVirtual && vs) {
@@ -3989,7 +4377,16 @@ var VirtualScrollState = class {
3989
4377
  this._totalRows = 0;
3990
4378
  this.rafId = 0;
3991
4379
  this._ro = null;
4380
+ this._resizeRafId = 0;
3992
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;
3993
4390
  this._config = config ?? { enabled: false };
3994
4391
  validateVirtualScrollConfig(this._config);
3995
4392
  }
@@ -4018,6 +4415,71 @@ var VirtualScrollState = class {
4018
4415
  get totalHeight() {
4019
4416
  return computeTotalHeight(this._totalRows, this._config.rowHeight ?? DEFAULT_ROW_HEIGHT);
4020
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
+ }
4021
4483
  /** Handle scroll events from the table container. RAF-throttled. */
4022
4484
  handleScroll(scrollTop) {
4023
4485
  if (this.rafId) cancelAnimationFrame(this.rafId);
@@ -4052,11 +4514,13 @@ var VirtualScrollState = class {
4052
4514
  this.disconnectObserver();
4053
4515
  if (typeof ResizeObserver !== "undefined") {
4054
4516
  this._ro = new ResizeObserver((entries) => {
4055
- for (const entry of entries) {
4056
- const rect = entry.contentRect;
4057
- this._containerHeight = rect.height;
4517
+ if (entries.length === 0) return;
4518
+ this._containerHeight = entries[0].contentRect.height;
4519
+ if (this._resizeRafId) cancelAnimationFrame(this._resizeRafId);
4520
+ this._resizeRafId = requestAnimationFrame(() => {
4521
+ this._resizeRafId = 0;
4058
4522
  this.recompute();
4059
- }
4523
+ });
4060
4524
  });
4061
4525
  this._ro.observe(el);
4062
4526
  }
@@ -4092,13 +4556,21 @@ var VirtualScrollState = class {
4092
4556
  this.emitter.on("rangeChanged", handler);
4093
4557
  return () => this.emitter.off("rangeChanged", handler);
4094
4558
  }
4559
+ onColumnRangeChanged(handler) {
4560
+ this.emitter.on("columnRangeChanged", handler);
4561
+ return () => this.emitter.off("columnRangeChanged", handler);
4562
+ }
4095
4563
  onConfigChanged(handler) {
4096
4564
  this.emitter.on("configChanged", handler);
4097
4565
  return () => this.emitter.off("configChanged", handler);
4098
4566
  }
4099
4567
  destroy() {
4100
4568
  if (this.rafId) cancelAnimationFrame(this.rafId);
4569
+ if (this._resizeRafId) cancelAnimationFrame(this._resizeRafId);
4570
+ if (this._scrollLeftRafId) cancelAnimationFrame(this._scrollLeftRafId);
4571
+ if (this._resizeWidthRafId) cancelAnimationFrame(this._resizeWidthRafId);
4101
4572
  this.disconnectObserver();
4573
+ this.disconnectWidthObserver();
4102
4574
  this.emitter.removeAllListeners();
4103
4575
  }
4104
4576
  };
@@ -4744,7 +5216,7 @@ var FillHandleState = class {
4744
5216
  endCol: end.endCol
4745
5217
  });
4746
5218
  this.setSelectionRange(norm);
4747
- this.setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + this.params.colOffset });
5219
+ this.setActiveCell({ rowIndex: start.startRow, columnIndex: start.startCol + this.params.colOffset });
4748
5220
  this.applyFillValuesFromCore(norm, start);
4749
5221
  this._isFillDragging = false;
4750
5222
  this.fillDragStart = null;
@@ -4996,7 +5468,8 @@ var MarchingAntsOverlay = class {
4996
5468
  const clipRange = this.copyRange ?? this.cutRange;
4997
5469
  const selRect = this.selectionRange ? measureRange2(this.container, this.selectionRange, this.colOffset) : null;
4998
5470
  const clipRangeMatchesSel = this.selectionRange != null && clipRange != null && rangesEqual(this.selectionRange, clipRange);
4999
- if (selRect && !clipRangeMatchesSel) {
5471
+ const isSingleCell = this.selectionRange != null && this.selectionRange.startRow === this.selectionRange.endRow && this.selectionRange.startCol === this.selectionRange.endCol;
5472
+ if (selRect && !clipRangeMatchesSel && !isSingleCell) {
5000
5473
  if (!this.selSvg) {
5001
5474
  this.selSvg = this.createSvg(4);
5002
5475
  this.container.appendChild(this.selSvg);
@@ -6387,16 +6860,25 @@ var OGrid = class {
6387
6860
  this.renderer.setVirtualScrollState(this.virtualScrollState);
6388
6861
  const handleScroll = () => {
6389
6862
  this.virtualScrollState?.handleScroll(this.tableContainer.scrollTop);
6863
+ this.virtualScrollState?.handleHorizontalScroll(this.tableContainer.scrollLeft);
6390
6864
  };
6391
6865
  this.tableContainer.addEventListener("scroll", handleScroll, { passive: true });
6392
6866
  this.unsubscribes.push(() => {
6393
6867
  this.tableContainer.removeEventListener("scroll", handleScroll);
6394
6868
  });
6869
+ if (options.virtualScroll?.columns) {
6870
+ this.virtualScrollState.observeContainerWidth(this.tableContainer);
6871
+ }
6395
6872
  this.unsubscribes.push(
6396
6873
  this.virtualScrollState.onRangeChanged(() => {
6397
6874
  this.renderingHelper.updateRendererInteractionState();
6398
6875
  })
6399
6876
  );
6877
+ this.unsubscribes.push(
6878
+ this.virtualScrollState.onColumnRangeChanged(() => {
6879
+ this.renderingHelper.updateRendererInteractionState();
6880
+ })
6881
+ );
6400
6882
  this.api.scrollToRow = (index, opts) => {
6401
6883
  this.virtualScrollState?.scrollToRow(index, this.tableContainer, opts?.align);
6402
6884
  };
@@ -6639,4 +7121,4 @@ var OGrid = class {
6639
7121
  }
6640
7122
  };
6641
7123
 
6642
- 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
  }
@@ -494,10 +502,11 @@
494
502
  padding: 6px 12px;
495
503
  box-sizing: border-box;
496
504
  font-size: 12px;
505
+ line-height: 20px;
497
506
  color: var(--ogrid-muted, #616161);
498
507
  background: var(--ogrid-bg-subtle, #f3f2f1);
499
508
  border-top: 1px solid var(--ogrid-border, #e0e0e0);
500
- min-height: 28px;
509
+ min-height: 33px;
501
510
  }
502
511
 
503
512
  .ogrid-status-part {
@@ -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
  };
@@ -19,7 +22,15 @@ export declare class VirtualScrollState {
19
22
  private _totalRows;
20
23
  private rafId;
21
24
  private _ro;
25
+ private _resizeRafId;
22
26
  private _cachedRange;
27
+ private _scrollLeft;
28
+ private _scrollLeftRafId;
29
+ private _containerWidth;
30
+ private _columnWidths;
31
+ private _cachedColumnRange;
32
+ private _roWidth;
33
+ private _resizeWidthRafId;
23
34
  constructor(config?: IVirtualScrollConfig);
24
35
  /** Whether virtual scrolling is active (enabled + meets the row threshold). */
25
36
  get enabled(): boolean;
@@ -31,6 +42,19 @@ export declare class VirtualScrollState {
31
42
  get visibleRange(): IVisibleRange;
32
43
  /** Get the total scrollable height for all rows. */
33
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;
34
58
  /** Handle scroll events from the table container. RAF-throttled. */
35
59
  handleScroll(scrollTop: number): void;
36
60
  /** Scroll the container to bring a specific row into view. */
@@ -45,6 +69,7 @@ export declare class VirtualScrollState {
45
69
  /** Recompute visible range and emit if changed. */
46
70
  private recompute;
47
71
  onRangeChanged(handler: (data: VirtualScrollEvents['rangeChanged']) => void): () => void;
72
+ onColumnRangeChanged(handler: (data: VirtualScrollEvents['columnRangeChanged']) => void): () => void;
48
73
  onConfigChanged(handler: (data: VirtualScrollEvents['configChanged']) => void): () => void;
49
74
  destroy(): void;
50
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.14",
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.14"
39
+ "@alaarab/ogrid-core": "2.2.0"
40
40
  },
41
41
  "sideEffects": [
42
42
  "**/*.css"