vue2-excel-grid 2.8.0 → 2.8.1

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.
@@ -9974,6 +9974,7 @@ var ExcelGridComponent = {
9974
9974
  // iterating all 50,000 rows each time. With 1200 cells per render that's
9975
9975
  // 60 million iterations per render (~600ms). _dataRaw bypasses this.
9976
9976
  this._dataRaw = this.data || [];
9977
+ this._hoveredRi = -1;
9977
9978
 
9978
9979
  // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes
9979
9980
  this._cellStyleCache = Object.create(null);
@@ -11263,6 +11264,39 @@ var ExcelGridComponent = {
11263
11264
  this.hideSortMenu();
11264
11265
  },
11265
11266
  // =========================================================
11267
+ // HOVER HIGHLIGHT
11268
+ // =========================================================
11269
+ onGridMouseMove: function onGridMouseMove(e) {
11270
+ var rowNode = e.target.closest('[data-row]');
11271
+ var newRi = rowNode ? parseInt(rowNode.getAttribute('data-row'), 10) : -1;
11272
+ if (this._hoveredRi !== newRi) {
11273
+ if (this._hoveredRi >= 0) {
11274
+ var oldEls = this.$refs.wrapper.querySelectorAll("[data-row=\"".concat(this._hoveredRi, "\"]"));
11275
+ for (var i = 0; i < oldEls.length; i++) {
11276
+ oldEls[i].classList.remove('eg-row--hover');
11277
+ }
11278
+ }
11279
+ this._hoveredRi = newRi;
11280
+ if (this._hoveredRi >= 0) {
11281
+ var newEls = this.$refs.wrapper.querySelectorAll("[data-row=\"".concat(this._hoveredRi, "\"]"));
11282
+ for (var _i3 = 0; _i3 < newEls.length; _i3++) {
11283
+ newEls[_i3].classList.add('eg-row--hover');
11284
+ }
11285
+ }
11286
+ }
11287
+ },
11288
+ onGridMouseLeave: function onGridMouseLeave() {
11289
+ if (this._hoveredRi >= 0) {
11290
+ if (this.$refs.wrapper) {
11291
+ var oldEls = this.$refs.wrapper.querySelectorAll("[data-row=\"".concat(this._hoveredRi, "\"]"));
11292
+ for (var i = 0; i < oldEls.length; i++) {
11293
+ oldEls[i].classList.remove('eg-row--hover');
11294
+ }
11295
+ }
11296
+ this._hoveredRi = -1;
11297
+ }
11298
+ },
11299
+ // =========================================================
11266
11300
  // KEYBOARD NAVIGATION (3.2)
11267
11301
  // =========================================================
11268
11302
  onKeyDown: function onKeyDown(e) {
@@ -13062,7 +13096,7 @@ var ExcelGridComponent = {
13062
13096
  _fillAxis: function _fillAxis(direction) {
13063
13097
  var _this25 = this;
13064
13098
  return _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
13065
- var oldData, newData, skipped, appliedRanges, _iterator0, _step1, range, r, srcRow, firstTargetRow, lastTargetRow, ci, col, srcVal, ri, v, srcCol, firstTargetCol, lastTargetCol, srcColDef, _ri5, _srcVal, _ci3, _col5, _v2, _i3, _appliedRanges, ar, _i4, _appliedRanges2, _ar, _t;
13099
+ var oldData, newData, skipped, appliedRanges, _iterator0, _step1, range, r, srcRow, firstTargetRow, lastTargetRow, ci, col, srcVal, ri, v, srcCol, firstTargetCol, lastTargetCol, srcColDef, _ri5, _srcVal, _ci3, _col5, _v2, _i4, _appliedRanges, ar, _i5, _appliedRanges2, _ar, _t;
13066
13100
  return _regenerator().w(function (_context2) {
13067
13101
  while (1) switch (_context2.p = _context2.n) {
13068
13102
  case 0:
@@ -13293,8 +13327,8 @@ var ExcelGridComponent = {
13293
13327
  return _context2.a(2);
13294
13328
  case 33:
13295
13329
  _this25._emitDataChange(oldData, newData);
13296
- for (_i3 = 0, _appliedRanges = appliedRanges; _i3 < _appliedRanges.length; _i3++) {
13297
- ar = _appliedRanges[_i3];
13330
+ for (_i4 = 0, _appliedRanges = appliedRanges; _i4 < _appliedRanges.length; _i4++) {
13331
+ ar = _appliedRanges[_i4];
13298
13332
  _this25.$emit('fill', {
13299
13333
  sourceRange: ar.sourceRange,
13300
13334
  targetRange: ar.targetRange,
@@ -13308,8 +13342,8 @@ var ExcelGridComponent = {
13308
13342
  _context2.n = 34;
13309
13343
  return _this25.$nextTick();
13310
13344
  case 34:
13311
- for (_i4 = 0, _appliedRanges2 = appliedRanges; _i4 < _appliedRanges2.length; _i4++) {
13312
- _ar = _appliedRanges2[_i4];
13345
+ for (_i5 = 0, _appliedRanges2 = appliedRanges; _i5 < _appliedRanges2.length; _i5++) {
13346
+ _ar = _appliedRanges2[_i5];
13313
13347
  _this25._fireCellCallbacksForRange(oldData, newData, _ar.targetRange.startRow, _ar.targetRange.endRow, _ar.targetRange.startCol, _ar.targetRange.endCol);
13314
13348
  }
13315
13349
  case 35:
@@ -13596,7 +13630,7 @@ var ExcelGridComponent = {
13596
13630
  });
13597
13631
  var offsets = {};
13598
13632
  var _loop2 = function _loop2() {
13599
- var zone = _arr[_i5];
13633
+ var zone = _arr[_i6];
13600
13634
  var inZone = zone === 'left' ? function (ci) {
13601
13635
  return _this27.isColumnPinnedLeft(ci);
13602
13636
  } : zone === 'right' ? function (ci) {
@@ -13631,12 +13665,12 @@ var ExcelGridComponent = {
13631
13665
  } finally {
13632
13666
  _iterator1.f();
13633
13667
  }
13634
- for (var _i6 = 0, _Object$keys = Object.keys(origPos); _i6 < _Object$keys.length; _i6++) {
13635
- var key = _Object$keys[_i6];
13668
+ for (var _i7 = 0, _Object$keys = Object.keys(origPos); _i7 < _Object$keys.length; _i7++) {
13669
+ var key = _Object$keys[_i7];
13636
13670
  offsets[key] = (previewPos[key] !== undefined ? previewPos[key] : origPos[key]) - origPos[key];
13637
13671
  }
13638
13672
  };
13639
- for (var _i5 = 0, _arr = ['left', 'scroll', 'right']; _i5 < _arr.length; _i5++) {
13673
+ for (var _i6 = 0, _arr = ['left', 'scroll', 'right']; _i6 < _arr.length; _i6++) {
13640
13674
  _loop2();
13641
13675
  }
13642
13676
  return offsets;
@@ -15941,7 +15975,12 @@ var __vue_render__ = function () {
15941
15975
  class: "eg-size-" + _vm.internalSize,
15942
15976
  style: _vm.wrapperStyle,
15943
15977
  attrs: { tabindex: "0" },
15944
- on: { keydown: _vm.onKeyDown, keypress: _vm.onKeyPress },
15978
+ on: {
15979
+ keydown: _vm.onKeyDown,
15980
+ keypress: _vm.onKeyPress,
15981
+ mousemove: _vm.onGridMouseMove,
15982
+ mouseleave: _vm.onGridMouseLeave,
15983
+ },
15945
15984
  },
15946
15985
  [
15947
15986
  _vm.contextMenu.visible
@@ -17551,6 +17590,7 @@ var __vue_render__ = function () {
17551
17590
  key: "srh-" + ri,
17552
17591
  staticClass: "eg-row-header-cell",
17553
17592
  style: _vm.getRowHeaderStyle(ri),
17593
+ attrs: { "data-row": ri },
17554
17594
  on: {
17555
17595
  mousedown: function ($event) {
17556
17596
  return _vm.onRowHeaderMouseDown(
@@ -17715,6 +17755,9 @@ var __vue_render__ = function () {
17715
17755
  },
17716
17756
  style:
17717
17757
  _vm.getRowStyle(ri),
17758
+ attrs: {
17759
+ "data-row": ri,
17760
+ },
17718
17761
  },
17719
17762
  [
17720
17763
  _vm._l(
@@ -18802,6 +18845,9 @@ var __vue_render__ = function () {
18802
18845
  },
18803
18846
  style:
18804
18847
  _vm.getRowStyle(ri),
18848
+ attrs: {
18849
+ "data-row": ri,
18850
+ },
18805
18851
  },
18806
18852
  [
18807
18853
  _vm._l(
@@ -19799,12 +19845,12 @@ __vue_render__._withStripped = true;
19799
19845
  /* style */
19800
19846
  const __vue_inject_styles__ = function (inject) {
19801
19847
  if (!inject) return
19802
- inject("data-v-5dda8f14_0", { source: "\n.eg-shell[data-v-5dda8f14] {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n.eg-shell-fullscreen[data-v-5dda8f14] {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material[data-v-5dda8f14] {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd[data-v-5dda8f14] {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent[data-v-5dda8f14] {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon[data-v-5dda8f14] {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n.eg-wrapper[data-v-5dda8f14] {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n.eg-toolbar[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-group[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n.eg-toolbar-group + .eg-toolbar-group[data-v-5dda8f14] {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-spacer[data-v-5dda8f14] {\n flex: 1 1 auto;\n}\n.eg-toolbar-btn[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged[data-v-5dda8f14] {\n position: relative;\n}\n.eg-toolbar-badge[data-v-5dda8f14] {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn[data-v-5dda8f14]:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-btn[data-v-5dda8f14]:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.eg-toolbar-btn-active[data-v-5dda8f14] {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n.eg-toolbar-select[data-v-5dda8f14] {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n.eg-toolbar-select[data-v-5dda8f14]:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-toolbar-select[data-v-5dda8f14]:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-toolbar-select[data-v-5dda8f14]:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.eg-toolbar-segmented[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n.eg-toolbar-segment[data-v-5dda8f14] {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon[data-v-5dda8f14] { width: 12px; height: 12px;\n}\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon[data-v-5dda8f14] { width: 15px; height: 15px;\n}\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon[data-v-5dda8f14] { width: 18px; height: 18px;\n}\n.eg-toolbar-icon[data-v-5dda8f14] {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n.eg-icon-undo[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\");\n}\n.eg-icon-redo[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\");\n}\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\");\n}\n.eg-icon-import[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\");\n}\n.eg-icon-print[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\");\n}\n.eg-icon-local-sync[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\");\n}\n.eg-icon-lock[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\");\n}\n.eg-icon-unlock[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\");\n}\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\");\n}\n.eg-icon-compress[data-v-5dda8f14]{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\");\n}\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\");\n}\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\");\n}\n.eg-icon-size-middle[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\");\n}\n.eg-icon-size-large[data-v-5dda8f14] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\");\n}\n.eg-container[data-v-5dda8f14] {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n.eg-header-area[data-v-5dda8f14] {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n.eg-header-cell[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n.eg-header-cell[data-v-5dda8f14]:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n}\n.eg-header-group[data-v-5dda8f14] { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n}\n.eg-sort-icon[data-v-5dda8f14] {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon[data-v-5dda8f14] { opacity: 0.65;\n}\n.eg-sort-icon[data-v-5dda8f14]:hover { opacity: 1;\n}\n.eg-sort-icon.eg-sort-icon-active[data-v-5dda8f14] {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell[data-v-5dda8f14] {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty[data-v-5dda8f14] { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-filter-input[data-v-5dda8f14],\n.eg-filter-select[data-v-5dda8f14] {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input[data-v-5dda8f14]:focus,\n.eg-filter-select[data-v-5dda8f14]:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select[data-v-5dda8f14] {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n.eg-col-resize-handle[data-v-5dda8f14] {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n.eg-body-area[data-v-5dda8f14] {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer[data-v-5dda8f14],\n.eg-scroll-col-footer[data-v-5dda8f14],\n.eg-pinned-right-footer[data-v-5dda8f14] {\n position: relative;\n}\n.eg-footer-cell[data-v-5dda8f14] {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total[data-v-5dda8f14] {\n font-weight: 700;\n}\n.eg-row[data-v-5dda8f14] {\n position: relative;\n}\n.eg-cell[data-v-5dda8f14] {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n.eg-locked .eg-cell[data-v-5dda8f14] { cursor: not-allowed;\n}\n.eg-cell-value[data-v-5dda8f14] {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right[data-v-5dda8f14] { justify-content: flex-end;\n}\n.eg-cell-align-center[data-v-5dda8f14] { justify-content: center;\n}\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value[data-v-5dda8f14] { text-align: right;\n}\n.eg-cell-align-center .eg-cell-value[data-v-5dda8f14] { text-align: center;\n}\n.eg-cell--has-suffix.eg-cell-select[data-v-5dda8f14]::after { display: none;\n}\n.eg-cell-select-trigger-wrap[data-v-5dda8f14] {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline[data-v-5dda8f14] {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap[data-v-5dda8f14] {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active[data-v-5dda8f14] {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n.eg-cell-selected[data-v-5dda8f14] {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top[data-v-5dda8f14] {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom[data-v-5dda8f14] {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left[data-v-5dda8f14] {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right[data-v-5dda8f14] {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-fill-preview[data-v-5dda8f14] {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n.eg-cell-copy[data-v-5dda8f14] {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell-merged-hidden[data-v-5dda8f14] {\n display: none !important;\n}\n.eg-cell-invalid[data-v-5dda8f14] {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n.eg-cell-input[data-v-5dda8f14] {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n.eg-row-header-cell[data-v-5dda8f14] {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.eg-row-resize-handle[data-v-5dda8f14] {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n.eg-fill-handle[data-v-5dda8f14] {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n.eg-context-menu[data-v-5dda8f14] {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu[data-v-5dda8f14] {\n min-width: 110px;\n}\n.eg-context-item[data-v-5dda8f14] {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n.eg-context-item[data-v-5dda8f14]:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active[data-v-5dda8f14] {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active[data-v-5dda8f14]::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n.eg-context-separator[data-v-5dda8f14] {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n.eg-context-submenu-parent[data-v-5dda8f14]::after { content: ' ▶'; float: right;\n}\n.eg-context-submenu[data-v-5dda8f14] {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-context-submenu-parent:hover .eg-context-submenu[data-v-5dda8f14] { display: block;\n}\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop[data-v-5dda8f14] {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.eg-info-modal[data-v-5dda8f14] {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n.eg-info-modal-header[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n.eg-info-modal-title[data-v-5dda8f14] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-info-modal-close[data-v-5dda8f14] {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n.eg-info-modal-close[data-v-5dda8f14]:hover { color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-info-modal-body[data-v-5dda8f14] {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n.eg-group-btn[data-v-5dda8f14] {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n.eg-group-btn.collapsed[data-v-5dda8f14] { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default));\n}\n.eg-drag-ghost[data-v-5dda8f14] {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source[data-v-5dda8f14] {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source[data-v-5dda8f14] {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost[data-v-5dda8f14] {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging[data-v-5dda8f14],\n.eg-container.eg-col-dragging *[data-v-5dda8f14] {\n cursor: grabbing !important;\n}\n.eg-resize-line[data-v-5dda8f14] {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header[data-v-5dda8f14], .eg-frozen-col-body[data-v-5dda8f14], .eg-frozen-col-footer[data-v-5dda8f14] { position: relative;\n}\n.eg-pinned-right-header[data-v-5dda8f14], .eg-pinned-right-footer[data-v-5dda8f14] { position: relative;\n}\n.eg-frozen-col-header[data-v-5dda8f14]::after, .eg-frozen-col-body[data-v-5dda8f14]::after, .eg-frozen-col-footer[data-v-5dda8f14]::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge[data-v-5dda8f14]::after,\n.eg-frozen-col-body.eg-shadow-edge[data-v-5dda8f14]::after,\n.eg-frozen-col-footer.eg-shadow-edge[data-v-5dda8f14]::after {\n opacity: 1;\n}\n.eg-pinned-right-body[data-v-5dda8f14] {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n.eg-pinned-right-header[data-v-5dda8f14]::before, .eg-pinned-right-body[data-v-5dda8f14]::before, .eg-pinned-right-footer[data-v-5dda8f14]::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge[data-v-5dda8f14]::before,\n.eg-pinned-right-body.eg-shadow-edge[data-v-5dda8f14]::before,\n.eg-pinned-right-footer.eg-shadow-edge[data-v-5dda8f14]::before {\n opacity: 1;\n}\n.eg-pinned-right-header[data-v-5dda8f14] {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-import-overlay[data-v-5dda8f14] {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n.eg-lock-flash[data-v-5dda8f14] {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon[data-v-5dda8f14] {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n.eg-import-dialog[data-v-5dda8f14] {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n.eg-import-title[data-v-5dda8f14] {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n.eg-import-bar-wrap[data-v-5dda8f14] {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n.eg-import-bar[data-v-5dda8f14] {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n.eg-import-msg[data-v-5dda8f14] {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n.eg-import-pct[data-v-5dda8f14] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner[data-v-5dda8f14] {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin-data-v-5dda8f14 0.8s linear infinite;\n}\n@keyframes eg-spin-data-v-5dda8f14 {\nto { transform: rotate(360deg);\n}\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay[data-v-5dda8f14] {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner[data-v-5dda8f14] {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin-data-v-5dda8f14 0.8s linear infinite;\n}\n.eg-skeleton[data-v-5dda8f14] {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header[data-v-5dda8f14] {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block[data-v-5dda8f14] {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer-data-v-5dda8f14 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer-data-v-5dda8f14 {\n0% { background-position: 100% 50%;\n}\n100% { background-position: 0 50%;\n}\n}\n@media (prefers-reduced-motion: reduce) {\n.eg-skeleton-block[data-v-5dda8f14], .eg-loading-spinner[data-v-5dda8f14] { animation: none;\n}\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly[data-v-5dda8f14] {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected[data-v-5dda8f14] {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active[data-v-5dda8f14] {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select[data-v-5dda8f14] {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select[data-v-5dda8f14]:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select[data-v-5dda8f14]::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date[data-v-5dda8f14]:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date[data-v-5dda8f14]::after { display: none;\n}\n.eg-cell-date[data-v-5dda8f14]::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox[data-v-5dda8f14] {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap[data-v-5dda8f14] {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap[data-v-5dda8f14] { justify-content: flex-end;\n}\n.eg-cell-align-center .eg-cell-action-wrap[data-v-5dda8f14] { justify-content: center;\n}\n.eg-validation-tooltip[data-v-5dda8f14] {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell[data-v-5dda8f14]:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell[data-v-5dda8f14]:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell[data-v-5dda8f14] {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input[data-v-5dda8f14] {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell[data-v-5dda8f14] {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell[data-v-5dda8f14] {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell[data-v-5dda8f14] {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu[data-v-5dda8f14],\n.eg-size-small .eg-context-item[data-v-5dda8f14] {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item[data-v-5dda8f14] {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu[data-v-5dda8f14] {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon[data-v-5dda8f14] {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input[data-v-5dda8f14],\n.eg-size-small .eg-filter-select[data-v-5dda8f14] {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon[data-v-5dda8f14] {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell[data-v-5dda8f14] {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input[data-v-5dda8f14] {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell[data-v-5dda8f14] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell[data-v-5dda8f14] {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell[data-v-5dda8f14] {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu[data-v-5dda8f14],\n.eg-size-large .eg-context-item[data-v-5dda8f14] {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item[data-v-5dda8f14] {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu[data-v-5dda8f14] {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon[data-v-5dda8f14] {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input[data-v-5dda8f14],\n.eg-size-large .eg-filter-select[data-v-5dda8f14] {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon[data-v-5dda8f14] {\n width: 13px;\n height: 13px;\n}\n", map: {"version":3,"sources":["/Users/macmini/Projects/Realsoft/vue2-excel-grid/src/components/ExcelGrid.vue"],"names":[],"mappings":";AAggOA;EACA;;GAEA;EACA,mEAAA;;EAEA,uBAAA;EACA,kEAAA;EACA,4BAAA;EACA,mCAAA;;EAEA,kBAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,iCAAA;EACA,kCAAA;EACA,uCAAA;EACA,uDAAA;;EAEA,+BAAA;EACA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,oBAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,mBAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,wBAAA;EACA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,iBAAA;EACA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,oBAAA;EACA,6DAAA;EACA,0DAAA;EACA,0DAAA;EACA,qDAAA;;EAEA,uCAAA;EACA,wDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,qBAAA;EACA,2CAAA;EACA,qDAAA;;EAEA,yCAAA;EACA,yCAAA;EACA,0CAAA;;EAEA,sBAAA;EACA,iEAAA;EACA,wEAAA;EACA,yDAAA;EACA,gBAAA;AACA;AAEA;EACA,eAAA;EACA,QAAA;EACA,aAAA;EACA,oEAAA;EACA,aAAA;EACA,sBAAA;EACA,YAAA;EACA,gBAAA;EACA,gBAAA;AACA;;AAEA,sEAAA;AACA;EACA,oEAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,4CAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,8DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,kEAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,+FAAA;EACA,4DAAA;EACA,4DAAA;EACA,uDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,yBAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,oEAAA;AACA;EACA,oHAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,qCAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,+DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,yCAAA;EACA,mDAAA;EACA,+CAAA;;EAEA,sHAAA;EACA,uDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,4CAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,qEAAA;AACA;EACA,gEAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,oCAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,oDAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,gEAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,8FAAA;EACA,uDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,uBAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,qEAAA;AACA;EACA,8EAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,iCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,yDAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,0DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,wDAAA;EACA,sDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,4CAAA;EACA,qCAAA;EACA,sCAAA;AACA;AAEA;EACA,aAAA;EACA,sBAAA;EACA,gBAAA;EACA,sBAAA;EACA,iEAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,cAAA;EACA,YAAA;EACA,cAAA;EACA,sBAAA;EACA,oEAAA;EACA,+EAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,cAAA;AACA;AAEA;EACA,6EAAA;AACA;AAEA;EACA,cAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,UAAA;EACA,6BAAA;EACA,kBAAA;EACA,uBAAA;EACA,eAAA;EACA,6EAAA;AACA;;AAEA;;2CAEA;AACA;EACA,kBAAA;AACA;AACA;EACA,kBAAA;EACA,QAAA;EACA,UAAA;EACA,UAAA;EACA,WAAA;EACA,kBAAA;EACA,kEAAA;EACA,oBAAA;AACA;AACA;EACA,oFAAA;EACA,oEAAA;AACA;AAEA;EACA,YAAA;EACA,eAAA;AACA;AAEA;EACA,4EAAA;EACA,oEAAA;EACA,uEAAA;AACA;AAEA;EACA,YAAA;EACA,qBAAA;EACA,wEAAA;EACA,kBAAA;EACA,0EAAA;EACA;yEACA;EACA,qQAAA;EACA,4BAAA;EACA,qCAAA;EACA,wBAAA;EACA,6EAAA;EACA,eAAA;EACA,iEAAA;EACA,eAAA;EACA,gBAAA;EACA,wBAAA;EACA,aAAA;AACA;AAEA;EACA,oEAAA;AACA;AAEA;EACA,oEAAA;EACA,sFAAA;AACA;AAEA;EACA,YAAA;EACA,eAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,wEAAA;EACA,kBAAA;EACA,gBAAA;EACA,YAAA;EACA,QAAA;EACA,oEAAA;AACA;AAEA;EACA,kBAAA;AACA;;AAEA;gFACA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AAEA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,8BAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;AACA;AAEA,oCAAA,gRAAA,EAAA,wRAAA;AAAA;AACA,oCAAA,mRAAA,EAAA,2RAAA;AAAA;AACA;;yBAEA;AACA,oCAAA,gVAAA,EAAA,wVAAA;AAAA;AACA,oCAAA,+UAAA,EAAA,uVAAA;AAAA;AACA,oCAAA,8XAAA,EAAA,sYAAA;AAAA;AACA,uCAAA,mUAAA,EAAA,2UAAA;AAAA;AACA,oCAAA,ySAAA,EAAA,iTAAA;AAAA;AACA,oCAAA,wSAAA,EAAA,gTAAA;AAAA;AACA;gEACA;AACA,oCAAA,kTAAA,EAAA,0TAAA;AAAA;AACA,oCAAA,kTAAA,EAAA,0TAAA;AAAA;;AAEA;;;uEAGA;AACA,sCAAA,wNAAA,EAAA,gOAAA;AAAA;;AAEA;;;sBAGA;AACA,wCAAA,6PAAA,EAAA,qQAAA;AAAA;AACA,wCAAA,2PAAA,EAAA,mQAAA;AAAA;AACA,wCAAA,6PAAA,EAAA,qQAAA;AAAA;AAEA;EACA,aAAA;EACA,sBAAA;EACA,OAAA;EACA,cAAA;EACA,kBAAA;AACA;AAEA;EACA,aAAA;EACA,cAAA;EACA,sFAAA;EACA,kBAAA;EACA,WAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,sBAAA;EACA,8EAAA;EACA,+EAAA;EACA,wEAAA;EACA,yEAAA;EACA,gBAAA;EACA,gBAAA;EACA,mBAAA;EACA,kBAAA;EACA,iBAAA;EACA,eAAA;AACA;AAEA,yCAAA,4EAAA;AAAA;AACA,oCAAA,oFAAA;AAAA;AAEA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,gBAAA;EACA,sBAAA;EACA,cAAA;EACA,8BAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;EACA,eAAA;EACA,YAAA;AACA;AACA,uDAAA,aAAA;AAAA;AACA,uCAAA,UAAA;AAAA;AACA;EACA,UAAA;EACA,6DAAA;AACA;;AAEA,2BAAA;AACA;EACA,sBAAA;EACA,gBAAA;EACA,wFAAA;EACA,+EAAA;EACA,wEAAA;EACA,kBAAA;EACA,aAAA;EACA,mBAAA;AACA;AACA,yCAAA,wEAAA;AAAA;AAEA;;EAEA,WAAA;EACA,YAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,oEAAA;EACA,yDAAA;EACA,yEAAA;EACA,iEAAA;EACA,gBAAA;EACA,cAAA;EACA,aAAA;AACA;AACA;;EAEA,oEAAA;EACA,sFAAA;AACA;AACA;EACA,gBAAA;EACA,wBAAA;EACA,eAAA;EACA,mBAAA;EACA,sLAAA;EACA,4BAAA;EACA,qCAAA;EACA,wBAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,MAAA;EACA,SAAA;EACA,UAAA;EACA,kBAAA;EACA,UAAA;AACA;AAEA;EACA,aAAA;EACA,OAAA;EACA,gBAAA;AACA;;AAEA,4CAAA;AACA;;;EAGA,kBAAA;AACA;AAEA;EACA,sBAAA;EACA,wFAAA;EACA,gBAAA;EACA,mBAAA;EACA,2DAAA;EACA,gBAAA;EACA,qEAAA;EACA,kBAAA;EACA,MAAA;EACA,wEAAA;EACA,aAAA;EACA,mBAAA;EACA,yBAAA;AACA;;AAEA;wEACA;AACA;EACA,gBAAA;AACA;AAEA;EACA,kBAAA;AACA;AAEA;EACA,sBAAA;EACA,wFAAA;EACA,yFAAA;EACA,gBAAA;EACA,mBAAA;EACA,2DAAA;EACA,qEAAA;EACA,kBAAA;EACA,MAAA;EACA,oEAAA;EACA,eAAA;EACA,aAAA;EACA,mBAAA;AACA;AAEA,uCAAA,mBAAA;AAAA;AAEA;EACA,OAAA;EACA,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,YAAA;AACA;AACA,yCAAA,yBAAA;AAAA;AACA,yCAAA,uBAAA;AAAA;AACA;;;;2DAIA;AACA,wDAAA,iBAAA;AAAA;AACA,wDAAA,kBAAA;AAAA;AACA,8DAAA,aAAA;AAAA;AACA;EACA,OAAA;EACA,aAAA;EACA,mBAAA;EACA,gBAAA;EACA,YAAA;AACA;AACA;EACA,cAAA;EACA,qEAAA;EACA,eAAA;EACA,gBAAA;AACA;AACA;EACA,cAAA;EACA,oBAAA;EACA,mBAAA;AACA;AACA;EACA,oFAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,2FAAA;AACA;;AAEA;;wEAEA;AACA;EACA,uFAAA;AACA;AACA;EACA,0FAAA;AACA;AACA;EACA,wFAAA;AACA;AACA;EACA,yFAAA;AACA;AAEA;EACA,mGAAA;EACA,oFAAA;EACA,oBAAA;AACA;AAEA;EACA,0EAAA;EACA,oBAAA;AACA;AAEA;EACA,wBAAA;AACA;AAEA;EACA,kFAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,WAAA;EACA,YAAA;EACA,YAAA;EACA,aAAA;EACA,oEAAA;EACA,2DAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,sBAAA;EACA,wEAAA;EACA,yFAAA;EACA,4FAAA;EACA,kBAAA;EACA,eAAA;EACA,6EAAA;EACA,eAAA;EACA,iBAAA;EACA,kBAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;AACA;AAEA;EACA,kBAAA;EACA,SAAA;EACA,OAAA;EACA,QAAA;EACA,WAAA;EACA,kBAAA;EACA,UAAA;AACA;AAEA;EACA,kBAAA;EACA,UAAA;EACA,WAAA;EACA,kEAAA;EACA,uBAAA;EACA,iBAAA;EACA,WAAA;AACA;AAEA;EACA,eAAA;EACA,6FAAA;EACA,wEAAA;EACA,4EAAA;EACA,yDAAA;EACA,wEAAA;EACA,aAAA;EACA,gBAAA;EACA,2DAAA;EACA,cAAA;EACA,yDAAA;AACA;;AAEA;;sDAEA;AACA;EACA,gBAAA;AACA;AAEA;EACA,iBAAA;EACA,eAAA;EACA,kBAAA;EACA,mBAAA;AACA;AAEA;EACA,oFAAA;EACA,yDAAA;AACA;;AAEA;sEACA;AACA;EACA,gBAAA;EACA,6DAAA;AACA;AACA;EACA,YAAA;EACA,kBAAA;EACA,SAAA;EACA,QAAA;EACA,2BAAA;AACA;AAEA;EACA,WAAA;EACA,4EAAA;EACA,aAAA;AACA;AAEA,qDAAA,aAAA,EAAA,YAAA;AAAA;AAEA;EACA,aAAA;EACA,kBAAA;EACA,UAAA;EACA,SAAA;EACA,6FAAA;EACA,wEAAA;EACA,4EAAA;EACA,yDAAA;EACA,wEAAA;EACA,gBAAA;EACA,cAAA;EACA,cAAA;EACA,yDAAA;AACA;AAEA,wEAAA,cAAA;AAAA;;AAEA,oCAAA;AACA;EACA,eAAA;EACA,QAAA;EACA,cAAA;EACA,+BAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;AACA;AAEA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,wEAAA;EACA,UAAA;EACA,gBAAA;EACA,gBAAA;EACA,aAAA;EACA,sBAAA;EACA,iEAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,8BAAA;EACA,SAAA;EACA,kBAAA;EACA,+EAAA;EACA,cAAA;AACA;AAEA;EACA,2DAAA;EACA,gBAAA;EACA,yDAAA;AACA;AAEA;EACA,gBAAA;EACA,YAAA;EACA,eAAA;EACA,eAAA;EACA,cAAA;EACA,6EAAA;EACA,gBAAA;AACA;AAEA,8CAAA,yDAAA;AAAA;AAEA;EACA,SAAA;EACA,aAAA;EACA,cAAA;EACA,OAAA;EACA,4EAAA;EACA,eAAA;EACA,yDAAA;EACA,qBAAA;EACA,sBAAA;AACA;AAEA;EACA,kBAAA;EACA,WAAA;EACA,YAAA;EACA,iBAAA;EACA,kBAAA;EACA,eAAA;EACA,eAAA;EACA,oFAAA;EACA,oFAAA;EACA,kBAAA;EACA,UAAA;EACA,iBAAA;AACA;AAEA,2CAAA,0FAAA;AAAA;AAEA;EACA,kBAAA;EACA,yEAAA;EACA,oBAAA;EACA,WAAA;AACA;;AAEA,yBAAA;AACA;EACA,YAAA;EACA,yEAAA;EACA,oBAAA;AACA;AACA;EACA,YAAA;AACA;AACA;;2BAEA;AACA;EACA,eAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,sBAAA;EACA,eAAA;EACA,wEAAA;EACA,wEAAA;EACA,yDAAA;EACA,0CAAA;EACA,yEAAA;EACA,gBAAA;EACA,yDAAA;EACA,mBAAA;EACA,gBAAA;EACA,uBAAA;EACA,oBAAA;EACA,aAAA;EACA,YAAA;AACA;;AAEA;iDACA;AACA;;EAEA,2BAAA;AACA;AAEA;EACA,oBAAA;EACA,aAAA;AACA;;AAEA;;;;;;;;;iBASA;AACA,uHAAA,kBAAA;AAAA;AACA,qFAAA,kBAAA;AAAA;AAEA;EACA,WAAA;EACA,kBAAA;EACA,MAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,wHAAA;EACA,UAAA;EACA,8BAAA;EACA,oBAAA;AACA;AACA;;;EAGA,UAAA;AACA;AAEA;EACA,gBAAA;EACA,QAAA;EACA,WAAA;EACA,oEAAA;EACA,cAAA;AACA;AAEA;EACA,WAAA;EACA,kBAAA;EACA,MAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA;EACA,wHAAA;EACA,UAAA;EACA,8BAAA;EACA,oBAAA;AACA;AACA;;;EAGA,UAAA;AACA;AAEA;EACA,gBAAA;EACA,cAAA;EACA,2FAAA;EACA,wEAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,0EAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,YAAA;EACA,yDAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,oBAAA;EACA,WAAA;AACA;AACA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,oEAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;EACA,mDAAA;AACA;AAEA;EACA,oEAAA;EACA,kFAAA;EACA,kBAAA;EACA,kBAAA;EACA,gBAAA;EACA,oEAAA;EACA,kBAAA;AACA;AAEA;EACA,eAAA;EACA,gBAAA;EACA,yDAAA;EACA,mBAAA;AACA;AAEA;EACA,WAAA;EACA,4EAAA;EACA,yDAAA;EACA,gBAAA;EACA,mBAAA;AACA;AAEA;EACA,YAAA;EACA,sGAAA;EACA,yDAAA;EACA,4BAAA;AACA;AAEA;EACA,eAAA;EACA,6EAAA;EACA,kBAAA;AACA;AAEA;EACA,2DAAA;EACA,gBAAA;EACA,6DAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,mBAAA;EACA,kFAAA;EACA,wEAAA;EACA,kBAAA;EACA,uDAAA;AACA;AACA;AACA,KAAA,yBAAA;AAAA;AACA;;AAEA,+CAAA;AACA;EACA,kBAAA;EACA,QAAA;EACA,0EAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,gBAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,kFAAA;EACA,wEAAA;EACA,kBAAA;EACA,uDAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,gBAAA;AACA;AACA;EACA,aAAA;EACA,mBAAA;EACA,cAAA;EACA,sBAAA;AACA;AACA;EACA,wEAAA;AACA;AACA;EACA,WAAA;EACA,aAAA;EACA,yDAAA;EACA;;;;;GAKA;EACA,0BAAA;EACA,iEAAA;AACA;AACA;AACA,KAAA,6BAAA;AAAA;AACA,OAAA,0BAAA;AAAA;AACA;AACA;AACA,4EAAA,eAAA;AAAA;AACA;;AAEA,8BAAA;AACA;;;;8EAIA;AACA;EACA;;;;;;;;mFAQA;EACA;;;;;;iEAMA;EACA,wFAAA;EACA,eAAA;AACA;AACA;EACA;;;;;;;;qGAQA;AACA;AACA;EACA,wFAAA;AACA;;AAEA,gCAAA;AACA;EACA,eAAA;AACA;AACA;;;;;;;;2FAQA;AACA;EACA,sFAAA;AACA;AACA;EACA,YAAA;EACA,kBAAA;EACA,UAAA;EACA,QAAA;EACA,2BAAA;EACA,qEAAA;EACA,eAAA;EACA,oBAAA;EACA,cAAA;AACA;;AAEA,+BAAA;AACA;;;;;+BAKA;AACA;EACA,sFAAA;AACA;AACA,4DAAA,aAAA;AAAA;AACA;EACA,WAAA;EACA,kBAAA;EACA,UAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,2BAAA;EACA,gFAAA;EACA,oBAAA;EACA,uYAAA;EACA,+XAAA;EACA,8BAAA;EACA,sBAAA;EACA,0BAAA;EACA,kBAAA;AACA;;AAEA,8BAAA;AACA;EACA,uBAAA;EACA,cAAA;AACA;;AAEA,4BAAA;AACA;EACA,aAAA;EACA,mBAAA;EACA,YAAA;EACA,WAAA;EACA,eAAA;AACA;AACA;;6EAEA;AACA,6DAAA,yBAAA;AAAA;AACA,8DAAA,uBAAA;AAAA;AACA;EACA,0EAAA;EACA,sEAAA;EACA,qEAAA;EACA,eAAA;EACA,gBAAA;EACA,yDAAA;EACA,sEAAA;EACA,mBAAA;EACA,oBAAA;AACA;;AAEA,mCAAA;AACA;EACA,0EAAA;AACA;AACA;EACA,wEAAA;AACA;;AAEA,8BAAA;;AAEA,UAAA;AACA;EACA,eAAA;EACA,cAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,iBAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,UAAA;EACA,WAAA;AACA;;AAEA,UAAA;AACA;EACA,eAAA;EACA,cAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,2DAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,iBAAA;AACA;AACA;EACA,gBAAA;AACA;AACA;EACA,YAAA;EACA,aAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;AACA","file":"ExcelGrid.vue","sourcesContent":["<template>\n <div\n class=\"eg-shell\"\n :class=\"['eg-size-' + internalSize, 'eg-theme-' + internalTheme, { 'eg-shell-fullscreen': isFullscreen, 'eg-locked': internalLocked }]\"\n :style=\"shellStyle\"\n >\n <!-- Toolbar -->\n <div v-if=\"_toolbarVisible\" class=\"eg-toolbar\">\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canUndo || toolbarDisabled('undoRedo')\"\n :title=\"t.undo\"\n @click=\"undo\"\n ><span class=\"eg-toolbar-icon eg-icon-undo\"></span></button>\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canRedo || toolbarDisabled('undoRedo')\"\n :title=\"t.redo\"\n @click=\"redo\"\n ><span class=\"eg-toolbar-icon eg-icon-redo\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('export')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('export')\"\n :title=\"t.export\"\n @click=\"toolbarExportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-export\"></span></button>\n <button\n v-if=\"toolbarHas('import')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || toolbarDisabled('import')\"\n :title=\"t.import\"\n @click=\"toolbarImportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-import\"></span></button>\n <input\n v-if=\"toolbarHas('import')\"\n ref=\"toolbarImportInput\"\n type=\"file\"\n accept=\".xlsx,.xls,.csv\"\n style=\"display: none\"\n @change=\"onToolbarImportFile\"\n >\n <button\n v-if=\"toolbarHas('print')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('print')\"\n :title=\"t.print\"\n @click=\"printGrid\"\n ><span class=\"eg-toolbar-icon eg-icon-print\"></span></button>\n <button\n v-if=\"localSyncKey && toolbarHas('localSync')\"\n type=\"button\"\n class=\"eg-toolbar-btn eg-toolbar-btn-badged\"\n :disabled=\"toolbarDisabled('localSync')\"\n :title=\"localSyncAvailable ? t.localSyncAvailable : t.localSync\"\n @click=\"openLocalSyncModal\"\n ><span class=\"eg-toolbar-icon eg-icon-local-sync\"></span\n ><span v-if=\"localSyncAvailable\" class=\"eg-toolbar-badge\" aria-hidden=\"true\"></span></button>\n </div>\n\n <slot name=\"toolbar-extra\" :locked=\"internalLocked\" :fullscreen=\"isFullscreen\"></slot>\n\n <div v-if=\"toolbarButtons.length\" class=\"eg-toolbar-group\">\n <button\n v-for=\"btn in toolbarButtons\"\n :key=\"btn.key\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :title=\"btn.title\"\n @click=\"onToolbarButtonClick(btn, $event)\"\n ><span class=\"eg-toolbar-icon\" :style=\"btn.icon ? { maskImage: 'url(' + btn.icon + ')', webkitMaskImage: 'url(' + btn.icon + ')' } : null\">{{ btn.icon ? '' : (btn.label || '') }}</span></button>\n </div>\n\n <div class=\"eg-toolbar-spacer\"></div>\n\n <div v-if=\"toolbarHas('theme')\" class=\"eg-toolbar-group\">\n <select\n class=\"eg-toolbar-select\"\n :value=\"internalTheme\"\n :disabled=\"toolbarDisabled('theme')\"\n :title=\"t.theme\"\n @change=\"setTheme($event.target.value)\"\n >\n <option value=\"excel\">Excel</option>\n <option value=\"material\">Material</option>\n <option value=\"antd\">Ant Design</option>\n <option value=\"fluent\">Fluent</option>\n <option value=\"carbon\">Carbon</option>\n </select>\n </div>\n\n <div v-if=\"toolbarHas('size')\" class=\"eg-toolbar-segmented\" role=\"radiogroup\" :aria-label=\"t.size\">\n <button\n v-for=\"key in sizeOptions\"\n :key=\"key\"\n type=\"button\"\n role=\"radio\"\n class=\"eg-toolbar-btn eg-toolbar-segment\"\n :class=\"['eg-toolbar-segment-' + key, { 'eg-toolbar-btn-active': internalSize === key }]\"\n :aria-checked=\"internalSize === key\"\n :disabled=\"toolbarDisabled('size')\"\n :title=\"sizeTitle(key)\"\n @click=\"setSize(key)\"\n ><span class=\"eg-toolbar-icon\" :class=\"'eg-icon-size-' + key\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('lock')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': internalLocked }\"\n :disabled=\"toolbarDisabled('lock')\"\n :title=\"internalLocked ? t.unlock : t.lock\"\n @click=\"toggleLock\"\n ><span class=\"eg-toolbar-icon\" :class=\"internalLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"></span></button>\n <button\n v-if=\"toolbarHas('fullscreen')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': isFullscreen }\"\n :disabled=\"toolbarDisabled('fullscreen')\"\n :title=\"isFullscreen ? t.exitFullscreen : t.fullscreen\"\n @click=\"toggleFullscreen\"\n ><span class=\"eg-toolbar-icon\" :class=\"isFullscreen ? 'eg-icon-compress' : 'eg-icon-expand'\"></span></button>\n </div>\n </div>\n\n <div\n class=\"eg-wrapper\"\n :class=\"'eg-size-' + internalSize\"\n :style=\"wrapperStyle\"\n tabindex=\"0\"\n @keydown=\"onKeyDown\"\n @keypress=\"onKeyPress\"\n ref=\"wrapper\"\n >\n <!-- Context menu -->\n <div\n v-if=\"contextMenu.visible\"\n key=\"eg-context-menu\"\n ref=\"contextMenuEl\"\n class=\"eg-context-menu\"\n :style=\"{ top: contextMenu.y + 'px', left: (contextMenuPositioned ? contextMenu.x : -9999) + 'px', visibility: contextMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" @click=\"ctxCopy\">{{ t.copy }}</div>\n <div class=\"eg-context-item\" @click=\"ctxCut\">{{ t.cut }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPaste\">{{ t.paste }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item eg-context-submenu-parent\">\n {{ t.pasteSpecial }}\n <div class=\"eg-context-submenu\">\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all')\">{{ t.pasteAll }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('values')\">{{ t.pasteValues }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('formats')\">{{ t.pasteFormats }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all', true)\">{{ t.transpose }}</div>\n </div>\n </div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxMerge\">{{ t.mergeCells }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnmerge\">{{ t.unmergeCells }}</div>\n <div class=\"eg-context-separator\"></div>\n <div v-if=\"contextMenu.row >= 0\" class=\"eg-context-item\" @click=\"ctxHideRow\">{{ t.hideRows }}</div>\n <div v-if=\"contextMenu.col >= 0\" class=\"eg-context-item\" @click=\"ctxHideColumn\">{{ t.hideColumns }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideRows\">{{ t.unhideRows }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideColumns\">{{ t.unhideColumns }}</div>\n <template v-if=\"contextMenu.row < 0 && contextMenu.col >= 0\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxPinLeft\">{{ t.pinLeft }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPinRight\">{{ t.pinRight }}</div>\n <div class=\"eg-context-item\" v-if=\"isColPinned(contextMenu.col)\" @click=\"ctxUnpin\">{{ t.unpin }}</div>\n </template>\n <template v-if=\"devMode && (contextMenu.col >= 0 || contextMenu.row >= 0)\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.col >= 0\" @click=\"ctxShowColumnInfo\">{{ t.columnInfo }}</div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.row >= 0\" @click=\"ctxShowRowInfo\">{{ t.rowInfo }}</div>\n </template>\n </div>\n\n <!-- Sort popup menu (header sort icon) -->\n <div\n v-if=\"sortMenu.visible\"\n key=\"eg-sort-menu\"\n ref=\"sortMenuEl\"\n class=\"eg-context-menu eg-sort-menu\"\n :style=\"{ top: sortMenu.y + 'px', left: (sortMenuPositioned ? sortMenu.x : -9999) + 'px', visibility: sortMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('asc') }\" @click=\"sortMenuPickAsc\">{{ t.sortMenuAsc }}</div>\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('desc') }\" @click=\"sortMenuPickDesc\">{{ t.sortMenuDesc }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"sortMenuPickClear\">{{ t.sortMenuClear }}</div>\n </div>\n\n <!-- devMode: column/row info modal -->\n <div v-if=\"infoModal\" key=\"eg-info-modal-backdrop\" class=\"eg-info-modal-backdrop\" @click=\"closeInfoModal\">\n <div class=\"eg-info-modal\" @click.stop>\n <div class=\"eg-info-modal-header\">\n <span class=\"eg-info-modal-title\">{{ infoModal.title }}</span>\n <button type=\"button\" class=\"eg-info-modal-close\" @click=\"closeInfoModal\">×</button>\n </div>\n <pre class=\"eg-info-modal-body\">{{ infoModal.json }}</pre>\n </div>\n </div>\n\n <!-- Scroll container -->\n <div class=\"eg-container\" :class=\"{ 'eg-col-dragging': colDrag.active }\" ref=\"container\" @scroll=\"onScroll\" @click=\"hideFloatingMenus\">\n\n <!-- ===== HEADER AREA ===== -->\n <div class=\"eg-header-area\" :style=\"headerAreaStyle\">\n\n <!-- Corner cell -->\n <div\n class=\"eg-corner\"\n :style=\"cornerStyle\"\n @click=\"selectAll\"\n ></div>\n\n <!-- Row group outline gutter (left) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-gutter\" :style=\"rowOutlineGutterStyle\"></div>\n\n <!-- Frozen columns header -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, true)\">\n <div\n :key=\"'fh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, true)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'ffilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable columns header -->\n <div class=\"eg-scroll-col-header\" ref=\"scrollColHeader\" :style=\"scrollColHeaderStyle\">\n <!-- Inner width = scrollable columns only: this strip renders no pinned cells\n (they live in the sibling zones), and scrollableContentWidth keeps its max\n scrollLeft identical to the body's (see scrollColHeaderStyle). -->\n <div class=\"eg-scroll-col-header-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: (headerRowHeight * headerDepth + _filterRowH) + 'px', position: 'relative' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, false)\">\n <div\n :key=\"'sh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, false)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'sfilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns header -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellInZone(cell, rowIdx, 'right')\">\n <div\n :key=\"'prh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyleForZone(cell, rowIdx, 'right')\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prfilter-' + col._flatIndex\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(col._flatIndex)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- ===== BODY AREA ===== -->\n <div class=\"eg-body-area\" :style=\"bodyAreaStyle\">\n\n <!-- Row number header column -->\n <div class=\"eg-row-header-col\" :style=\"rowHeaderColStyle\">\n <!-- Scrollable row headers -->\n <div class=\"eg-scroll-row-headers\" :style=\"scrollRowHeadersStyle\" ref=\"scrollRowHeaders\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'srh-' + ri\"\n class=\"eg-row-header-cell\"\n :style=\"getRowHeaderStyle(ri)\"\n @mousedown=\"onRowHeaderMouseDown($event, ri)\"\n @contextmenu.prevent=\"onRowContextMenu($event, ri)\"\n >\n {{ ri + 1 }}\n <div class=\"eg-row-resize-handle\" @mousedown.stop=\"startRowResize($event, ri)\"></div>\n </div>\n </template>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Row group outline gutter (left side of rows) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-col\" :style=\"rowOutlineColStyle\">\n <template v-for=\"group in rowGroups\">\n <div\n :key=\"'rg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getRowGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n <!-- Main scroll body -->\n <div class=\"eg-scroll-body\" ref=\"scrollBody\" :style=\"scrollBodyStyle\" @scroll=\"onBodyScroll\">\n <!-- display:flex puts the two sticky pinned zones side by side at y=0 (as\n blocks they'd stack vertically, pushing pinned-right below the rows), and\n lets margin-left:auto give pinned-right its natural place at the inner's\n RIGHT edge — sticky right:0 only ever pulls an element back toward its\n natural position, so from x=0 (block flow) it would never stick at all.\n .eg-main-cells is position:absolute and doesn't participate. -->\n <div class=\"eg-scroll-body-inner\" :style=\"{ width: scrollContentWidth + 'px', height: totalVirtualHeight + 'px', position: 'relative', display: 'flex' }\">\n\n <!-- Pinned left column body -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'fc-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'fc-cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Main data cells -->\n <div class=\"eg-main-cells\" :style=\"mainCellsStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative', width: scrollableContentWidth + 'px' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'row-' + ri\"\n class=\"eg-row\"\n :class=\"{ 'eg-row--even': ri % 2 === 0 }\"\n :style=\"getRowStyle(ri)\"\n :data-row=\"ri\"\n >\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input\n v-else\n ref=\"editInput\"\n class=\"eg-cell-input\"\n v-model=\"editValue\"\n @input=\"onEditInput\"\n @blur=\"commitEdit\"\n @keydown.stop=\"onEditKeyDown\"\n />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Pinned Right column body -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'pr-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\">\n <template v-for=\"col in pinnedRightCols\">\n <div\n :key=\"'pr-cell-' + ri + '-' + col._flatIndex\"\n class=\"eg-cell\"\n :data-fi=\"col._flatIndex\"\n :class=\"getCellClasses(ri, col._flatIndex)\"\n :style=\"[getCellStyle(ri, col._flatIndex), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, col._flatIndex)\"\n @mouseover=\"onCellMouseOver($event, ri, col._flatIndex)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, col._flatIndex)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, col._flatIndex)\"\n >\n <div v-if=\"isMergedAway(ri, col._flatIndex)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, col._flatIndex)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, col._flatIndex)\"\n :disabled=\"!isCellEditable(ri, col._flatIndex)\"\n @change=\"(v) => setCheckboxValue(ri, col._flatIndex, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, col._flatIndex, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Fill handle (shown on primary range) -->\n <div\n v-if=\"fillHandleVisible\"\n class=\"eg-fill-handle\"\n :style=\"fillHandleStyle\"\n @mousedown.stop=\"startFillDrag\"\n ></div>\n\n <!-- Drag move ghost border -->\n <div\n v-if=\"dragMove.active\"\n class=\"eg-drag-ghost\"\n :style=\"dragGhostStyle\"\n ></div>\n </div>\n </div>\n\n <!-- Column group outline row (top, above body) -->\n <div v-if=\"colGroups.length\" class=\"eg-col-outline-row\" :style=\"colOutlineRowStyle\">\n <template v-for=\"group in colGroups\">\n <div\n :key=\"'cg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getColGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n </div><!-- end body-area -->\n\n <!-- ===== FOOTER AREA (column totals) ===== -->\n <div v-if=\"hasFooter\" class=\"eg-footer-area\" :style=\"footerAreaStyle\">\n\n <!-- \"Jami\" label cell (mirrors corner cell) -->\n <div class=\"eg-footer-label\" :style=\"footerLabelStyle\">{{ t.total }}</div>\n\n <!-- Frozen columns footer -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div :key=\"'ff-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable footer (horizontally synced with body via scrollFooter ref) -->\n <div class=\"eg-scroll-col-footer\" ref=\"scrollFooter\" :style=\"scrollColHeaderStyle\">\n <div class=\"eg-scroll-col-footer-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: footerRowHeight + 'px', position: 'relative' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div :key=\"'sf-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns footer -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prf-' + col._flatIndex\"\n class=\"eg-footer-cell\"\n :class=\"getFooterCellClass(col._flatIndex)\"\n :style=\"getFooterCellStyle(col._flatIndex)\"\n >{{ getColumnTotalDisplay(col._flatIndex) }}</div>\n </div>\n </div>\n </div><!-- end footer-area -->\n\n <!-- Resize indicator line -->\n <div v-if=\"resizing.active\" class=\"eg-resize-line\" :style=\"resizeLineStyle\"></div>\n\n <!-- Loading overlay (host-driven, e.g. during an async data fetch) -->\n <div v-if=\"loading\" class=\"eg-loading-overlay\">\n <template v-if=\"columns && columns.length\">\n <div class=\"eg-skeleton\">\n <div class=\"eg-skeleton-row eg-skeleton-row-header\" :style=\"{ height: headerRowHeight + 'px' }\">\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-h-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n <div\n v-for=\"r in 20\"\n :key=\"'sk-r-' + r\"\n class=\"eg-skeleton-row\"\n :style=\"{ height: _rowH + 'px' }\"\n >\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-c-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n </div>\n </template>\n <template v-else>\n <div class=\"eg-loading-spinner\"></div>\n </template>\n </div>\n </div><!-- end container -->\n\n <!-- Import progress overlay -->\n <div v-if=\"importState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl import qilinmoqda</div>\n <div class=\"eg-import-bar-wrap\">\n <div class=\"eg-import-bar\" :style=\"{ width: importState.percent + '%' }\"></div>\n </div>\n <div class=\"eg-import-msg\">{{ importState.message }}</div>\n <div class=\"eg-import-pct\">{{ importState.percent }}%</div>\n </div>\n </div>\n\n <!-- Export progress overlay -->\n <div v-if=\"exportState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl export qilinmoqda...</div>\n <div class=\"eg-export-spinner\"></div>\n </div>\n </div>\n\n <!-- Local sync modal (localSyncKey feature) -->\n <eg-local-sync-modal\n v-if=\"localSyncKey\"\n :visible=\"localSyncModal.visible\"\n :grid-options=\"_selfComponentDef\"\n :columns=\"_localSyncPreviewColumns\"\n :versions=\"localSyncModal.versions\"\n :selected-id=\"localSyncModal.selectedId\"\n :selected-version=\"localSyncModal.selectedVersion\"\n :locale=\"locale\"\n :theme=\"internalTheme\"\n @close=\"closeLocalSyncModal\"\n @select=\"_selectLocalSyncModalVersion\"\n @save=\"_onLocalSyncModalSave\"\n @restore=\"restoreLocalVersion\"\n @clear=\"_onLocalSyncModalDelete\"\n />\n\n <!-- Lock/unlock state feedback — brief centered icon flash -->\n <div v-if=\"lockFlashVisible\" class=\"eg-lock-flash\" aria-hidden=\"true\">\n <span\n ref=\"lockFlashIconEl\"\n class=\"eg-lock-flash-icon\"\n :class=\"lockFlashLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"\n ></span>\n </div>\n\n <!-- Select dropdown overlay (flat select) -->\n <eg-select\n v-if=\"selectDropdown && !selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n <!-- Tree select dropdown overlay -->\n <eg-tree-select\n v-if=\"selectDropdown && selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :children-key=\"selectDropdown.keys.childrenKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n\n <!-- Date picker overlay -->\n <eg-date-picker\n v-if=\"datePickerOverlay\"\n ref=\"datePickerComp\"\n :value=\"datePickerOverlay.value\"\n :format=\"datePickerOverlay.format\"\n :locale=\"locale\"\n :trigger-rect=\"datePickerOverlay.rect\"\n @pick=\"onDatePick\"\n @typing=\"onDateTyping\"\n @close=\"closeDatePicker\"\n />\n\n <!-- Validation error tooltip (cellValidator) -->\n <div\n v-if=\"invalidCell && invalidCell.rect\"\n class=\"eg-validation-tooltip\"\n :style=\"invalidTooltipStyle\"\n >{{ invalidCell.message }}</div>\n\n <!-- Column drag ghost — a header-shaped copy of the grabbed column that follows\n the cursor with the original grab point preserved (position updated via\n direct DOM in handleColDragMove) -->\n <div\n v-if=\"colDrag.active\"\n ref=\"colDragGhost\"\n class=\"eg-col-drag-ghost\"\n :style=\"{\n left: (colDrag.startX - colDrag.grabOffsetX) + 'px',\n top: (colDrag.startY - 14) + 'px',\n width: colDrag.ghostWidth + 'px',\n height: _headerRowH + 'px'\n }\"\n >{{ colDrag.ghostLabel }}</div>\n\n </div>\n </div>\n</template>\n\n<script>\nimport {\n normalizeRange, isCellInRange, isCellInAnyRange,\n buildRange, singleCellRange, getPrimaryRange\n} from '../utils/selection.js'\nimport { flattenColumns, buildHeaderRows, columnTreeDepth, moveColumnNodeInTree } from '../utils/headerTree.js'\nimport { calcVisibleRows, buildCumulativeHeights, findVisibleRange, getRowTop, getRowHeight } from '../utils/virtualScroll.js'\nimport { generateFillValues } from '../utils/fillHandle.js'\nimport {\n matrixToTSV, tsvToMatrix, extractMatrix, transposeMatrix, applyMatrix, tileMatrix\n} from '../utils/clipboard.js'\nimport {\n mergeHasConflict, findMergeAtCell, isMergeAnchor, addMerge,\n removeMergeAt, resolveMergeAnchor\n} from '../utils/merge.js'\nimport {\n addGroup, removeGroup, toggleGroupCollapse, isGroupCollapsed as _isGroupCollapsed,\n getCollapsedRowIndices, getCollapsedColIndices\n} from '../utils/outline.js'\nimport { exportToExcel as _exportToExcel } from '../utils/excelExport.js'\nimport { buildPrintTableHtml } from '../utils/printTable.js'\nimport { importFromFile as _importFromFile } from '../utils/excelImport.js'\nimport { createExcelImportWorker, createExcelExportWorker } from '../utils/workerFactory'\nimport { getMessages } from '../utils/i18n.js'\nimport EgCheckbox from './EgCheckbox.vue'\nimport anime from 'animejs'\nimport EgSelect from './EgSelect.vue'\nimport EgTreeSelect from './EgTreeSelect.vue'\nimport EgDatePicker from './EgDatePicker.vue'\nimport EgLocalSyncModal from './EgLocalSyncModal.vue'\nimport { parseDateValue, formatDate, DEFAULT_DATE_FORMAT } from '../utils/dateUtils.js'\nimport { formatNumber as _formatNumber } from '../utils/numberFormat.js'\nimport { Mask, MaskInput } from '../utils/mask.js'\nimport { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'\nimport { hashRows, hashRowsAsync } from '../utils/dataHash.js'\n\nconst SIZE_CONFIG = {\n small: { row: 22, header: 24, footer: 24, filter: 26 },\n middle: { row: 28, header: 28, footer: 28, filter: 32 },\n large: { row: 36, header: 36, footer: 36, filter: 40 }\n}\nconst ROW_HEADER_WIDTH = 50\nconst DEFAULT_COL_WIDTH = 100\nconst OUTLINE_GUTTER = 20\nconst FIT_COLUMNS_MIN_WIDTH = 40\nconst ROW_LIST_EDGE_PAD = 8 // birinchi/oxirgi qator bilan tepa/pastki border orasidagi bo'sh joy (px)\nconst FILL_DRAG_EDGE_ZONE = 24 // px from .eg-scroll-body's edge that triggers continuous auto-scroll while fill-dragging\nconst FILL_DRAG_MAX_SPEED = 18 // px/frame, at the very edge (scales down to 0 at the zone's outer boundary)\n\n// Classifies a col.width spec so resolveColWidth() knows how to turn it into a\n// concrete px number. Plain numbers/px strings resolve instantly with no DOM\n// work; '%' resolves via arithmetic against the grid's own measured viewport\n// width; any other CSS length (em, rem, vw, calc(), ...) needs a DOM probe.\nfunction parseColWidthSpec(spec) {\n if (typeof spec === 'number' && !isNaN(spec)) return { type: 'px', value: spec }\n if (typeof spec === 'string') {\n const trimmed = spec.trim()\n if (/^\\d+(\\.\\d+)?$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?px$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?%$/.test(trimmed)) return { type: 'percent', value: parseFloat(trimmed) }\n if (trimmed) return { type: 'css', value: trimmed }\n }\n return { type: 'px', value: DEFAULT_COL_WIDTH }\n}\n// Default toolbar tool list when `toolbar` is `true` or an object without\n// its own `list` — every tool group. Like `size`, the theme picker mirrors\n// its own `internalTheme` state and works standalone; `:theme.sync` is only\n// needed if the host wants to read the current theme back, not a prerequisite\n// for the picker to function.\nconst DEFAULT_TOOLBAR_LIST = ['undoRedo', 'export', 'import', 'print', 'localSync', 'size', 'lock', 'fullscreen', 'theme']\n// Column drag live-preview transition (AG Grid-like feel). Native CSS transitions\n// retarget smoothly from whatever intermediate position an element is currently\n// at when the drop slot changes mid-flight — no JS animation library races.\nconst COL_DRAG_TRANSITION = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)'\nconst THEME_LIST = ['excel', 'material', 'antd', 'fluent', 'carbon']\n\n// Kept as a named const (rather than an inline `export default {}`) so the\n// local-sync preview modal can receive the plain component definition\n// itself as a prop — see `_selfComponentDef` below. Importing ExcelGrid.vue\n// from EgLocalSyncModal.vue to nest a grid inside it would be a circular\n// import (ExcelGrid.vue -> EgLocalSyncModal.vue -> ExcelGrid.vue); passing\n// this reference down avoids that entirely, and avoids passing `this.$options`\n// (an instance's merged/runtime-contaminated options, not a clean definition).\nconst ExcelGridComponent = {\n name: 'ExcelGrid',\n\n components: { EgCheckbox, EgSelect, EgTreeSelect, EgDatePicker, EgLocalSyncModal },\n\n // v-model → :data + @input\n model: { prop: 'data', event: 'input' },\n\n props: {\n /** Array of row objects keyed by columns[].key */\n data: { type: Array, default: () => [] },\n\n /**\n * Column definitions (may be nested via children[]).\n *\n * `width` accepts anything the CSS `width` property accepts, not just a\n * raw px number:\n * - `Number` (e.g. `150`) or `'150px'` — fixed pixel width (fast path, no\n * DOM measurement).\n * - `'20%'` — relative to the grid's own rendered viewport width. Stays\n * responsive: recalculated whenever the grid's container is resized,\n * exactly like a real CSS percentage — until the column is manually\n * drag-resized (or auto-fit), at which point it becomes a fixed px\n * override and stops rescaling.\n * - Any other CSS length (`'10em'`, `'5rem'`, `'20vw'`, `calc(...)`, ...)\n * — resolved once via a hidden probe element measured against the\n * grid's own font-size/layout. `'auto'`/`'fit-content'` have no\n * intrinsic width this way and fall back to the 100px default; use the\n * `autoWidth` prop or double-click auto-fit for real content-fit sizing.\n *\n * `mask` — input mask for the plain-text editable input (engine adapted\n * from the `maska` library, vendored in `src/utils/mask.js`), applied\n * while typing and to every other value-entry path (paste, fill handle,\n * custom edit slots). Ignored by `select`/`treeSelect`/`date`/`checkbox`/\n * `action` columns, which use their own pickers. The masked/formatted\n * text is what gets written to `data` — display, storage, and export\n * all see the same string.\n * - `String` — e.g. `'(##) ###-##-##'` (`#` digit, `@` letter, `*`\n * alphanumeric by default). Use non-digit separators (spaces, dashes,\n * parens, letters) between token runs — a literal made of digits\n * (e.g. `'998##'`) can misalign against a raw/pasted value whose own\n * digits happen to coincide with the literal's; type the value through\n * the live cell editor or keep the fixed prefix outside the mask\n * (as static UI, e.g. a `#prefix-{key}` slot) if that's a concern.\n * - `Array<String>` — dynamic mask, picked by input length, e.g.\n * `['#-#', '##-##']`.\n * - `Object` — full maska options passthrough (`{ mask, tokens, eager,\n * reversed, ... }`) for custom tokens or advanced behavior.\n *\n * @example\n * columns: [\n * { key: 'id', title: '#', width: 60 },\n * { key: 'name', title: 'Name', width: '30%' },\n * { key: 'note', title: 'Note', width: '12em' },\n * { key: 'phone', title: 'Phone', mask: '(##) ###-##-##' }\n * ]\n */\n columns: { type: Array, default: () => [] },\n\n /** Indices of hidden leaf columns */\n hiddenColumns: { type: Array, default: () => [] },\n\n /** Indices of hidden rows */\n hiddenRows: { type: Array, default: () => [] },\n\n /**\n * Dynamic row visibility: (row, rowIndex) => boolean. Return false to\n * hide that row — same visual effect as `hiddenRows`/collapsed groups\n * (row is not rendered, but stays in `data`; nothing is deleted). Feeds\n * the same unification point as every other row-hiding mechanism, so it\n * automatically applies to virtual scroll, keyboard navigation, and\n * Excel export with no extra wiring.\n *\n * @example\n * // Hide inactive rows\n * <excel-grid :row-visible=\"(row) => row.status !== 'inactive'\" />\n * @example\n * // Hide every other row (by position, not by content)\n * <excel-grid :row-visible=\"(row, rowIndex) => rowIndex % 2 === 0\" />\n */\n rowVisible: { type: Function, default: null },\n\n /** Initial merge descriptors [{ startRow, startCol, endRow, endCol }] */\n merges: { type: Array, default: () => [] },\n\n /** Collapsed group descriptors [{ type, startIndex, endIndex }] */\n collapsedGroups: { type: Array, default: () => [] },\n\n /**\n * Height of the grid container in px.\n *\n * Omit it (or pass `null`) for auto-height: the grid sizes itself to fit\n * the header + filter row + all rows + footer for the current `data`\n * length, capped at 70% of the viewport height (`window.innerHeight`) —\n * beyond that it stops growing and scrolls internally, same as a fixed\n * `height` would. Recomputes reactively as `data` grows/shrinks and on\n * window resize. Does not apply in fullscreen mode (already sized to the\n * viewport there) and does not include the toolbar's own height.\n *\n * Pass an explicit `Number` to opt back into the old fixed-height behavior.\n */\n height: { type: Number, default: null },\n\n /**\n * Width of the grid container (px or a CSS length string).\n * Only applies while `widthMode` is `'full'` — in `'auto'` mode the grid\n * sizes itself to its columns and this prop is ignored.\n */\n width: { type: [Number, String], default: '100%' },\n\n /**\n * How the grid decides its overall width.\n *\n * - `'full'` (default) — the grid takes the `width` prop's value\n * (default `100%` of the host container), regardless of how little\n * space its columns actually need. Current/legacy behavior.\n * - `'auto'` — the grid hugs its content (max-content): its width is\n * the row-number gutter + the sum of all visible column widths\n * (live: reacts to column resize, hide/show, and `col.visible`),\n * plus the vertical scrollbar's width when rows overflow the height.\n * Capped at `max-width: 100%` — when the columns are wider than the\n * host, the grid stops at the host's edge and scrolls horizontally\n * inside, exactly like `'full'`. The `width` prop is ignored.\n *\n * Fullscreen mode always fills the viewport, whatever this is set to.\n *\n * @example\n * <excel-grid v-model=\"rows\" :columns=\"columns\" width-mode=\"auto\" />\n */\n widthMode: {\n type: String,\n default: 'full',\n validator: (v) => ['full', 'auto'].includes(v)\n },\n\n /**\n * Called before drag-drop: (sourceRange, targetRange) => bool\n * Return false to cancel the drop.\n */\n beforeDrop: { type: Function, default: null },\n\n /**\n * UI locale for the context menu.\n * Supported: 'en' | 'ru' | 'uz' | 'uz-Cyrl'\n */\n locale: { type: String, default: 'en' },\n\n /**\n * Pinned columns map: { [colKey]: 'left' | 'right' }\n * Pinned-left columns render as a fixed zone on the left.\n * Pinned-right columns render as a fixed zone on the right.\n * Emit 'update:pinnedColumns' to support .sync modifier.\n */\n pinnedColumns: { type: Object, default: () => ({}) },\n\n /**\n * Dynamic cell editability: (row, rowIndex, col, colIndex) => boolean\n * Overrides column-level editable when provided.\n * row = plain data object, col = column definition.\n */\n cellEditable: { type: Function, default: null },\n\n /**\n * Cell value validation: (value, row, rowIndex, col, colIndex) => boolean\n * Return false to reject the write — the cell keeps its old value and\n * an error tooltip is shown anchored to that cell.\n */\n cellValidator: { type: Function, default: null },\n\n /**\n * Error tooltip text for a rejected value.\n * Function: (value, row, rowIndex, col, colIndex) => string\n * String: same message for every rejected cell.\n * Default: locale's \"invalidValue\" message.\n */\n cellValidationMessage: { type: [Function, String], default: null },\n\n /**\n * Bulk-write strictness (paste / fill handle / Delete).\n * false (default): invalid cells in the affected range are skipped,\n * the rest of the operation still applies.\n * true: if ANY cell in the affected range fails validation, the whole\n * bulk operation is rejected — nothing is written.\n */\n strictBulkValidation: { type: Boolean, default: false },\n\n /**\n * Paste auto-grows rows: false (default) — pasting a clipboard matrix\n * taller than the remaining rows below the paste target clips to the\n * existing rows, same as before. true — missing rows are appended to\n * `data` to fit the full pasted range (Excel/Sheets-style). New rows'\n * columns not covered by the pasted matrix get a type-aware blank value\n * (`type: 'checkbox'` → false, everything else → ''). Columns are never\n * auto-created — pasting more columns than exist still clips.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows />\n */\n pasteGrowsRows: { type: Boolean, default: false },\n\n /**\n * Extra fields merged into rows auto-created by pasteGrowsRows, beyond\n * the type-aware column blanks. Rows only ever get values for `columns`\n * keys — anything else your app tracks per row (an id, a visibility\n * flag `rowVisible` depends on, a nested list, etc.) is NOT set unless\n * provided here, since the grid has no way to know about it.\n * Object: same fields merged into every new row.\n * Function(newRowIndex) => Object: computed per new row (e.g. a fresh\n * uuid per row). `newRowIndex` is the row's index in the resulting\n * `data` array.\n * Applied before the pasted values are written, so pasted cells always\n * win over any column-key present here.\n * For a single aggregate action after ALL rows from one paste have been\n * created and written to v-model, listen for the `rows-added` event\n * instead of doing it here (this runs once per row, before the commit).\n * @type {Object|Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows\n * :paste-row-defaults=\"() => ({ uuid: uuidv4(), state: 1 })\" />\n */\n pasteRowDefaults: { type: [Object, Function], default: null },\n\n /** Visual size of the grid. Affects row/header height and font sizes. */\n size: { type: String, default: 'middle' },\n\n /** Auto-fit all column widths to content (same as double-clicking each resize handle). Re-applies on data/columns change. */\n autoWidth: { type: Boolean, default: false },\n\n /**\n * Forces every visible leaf column to fit within a single viewport —\n * no horizontal scroll. Column widths are still resolved (`col.width`,\n * `%`/`px`/other CSS lengths, `DEFAULT_COL_WIDTH` when omitted) but only\n * as RELATIVE WEIGHTS: all resolved widths are scaled by one shared\n * factor so their sum exactly fills the available width, keeping each\n * column's proportion to the others. Each column still gets at least\n * `40px` — if there are enough columns that even that minimum doesn't\n * fit, horizontal scroll returns for that (rare) case rather than\n * shrinking columns illegibly thin.\n *\n * Highest-priority width control: overrides `col.width` as an absolute\n * value and `autoWidth`. Manual column resize (drag handle) and\n * double-click auto-fit ARE both allowed — either one turns that column\n * into a fixed px width, excluded from the fit; the rest keep scaling\n * proportionally to fill what's left, which can reintroduce horizontal\n * scroll if the fixed column(s) alone exceed the viewport. Pinned\n * columns count toward the fit too — the goal is the whole row fitting,\n * regardless of pin state.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" fit-columns />\n */\n fitColumns: { type: Boolean, default: false },\n\n /**\n * Allow columns to be reordered by dragging their headers.\n * On drop, emits 'update:columns' (compatible with .sync) and 'column-reorder'.\n */\n columnDraggable: { type: Boolean, default: true },\n\n /**\n * Controls the built-in toolbar above the grid — visibility as a whole,\n * and (optionally) which individual tool groups appear. Controlled\n * entirely by the parent — no in-component toggle for the bar itself.\n *\n * @type {Boolean|{visible?: Boolean, list?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>, disabled?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>}}\n * @default false\n *\n * Two forms:\n * - `Boolean` — `false` (default) hides the toolbar entirely; `true`\n * shows it with the default tool list — every group, including\n * `'theme'` (it mirrors its own internal state like `size` does;\n * `:theme.sync` is optional, only needed if the parent wants to read\n * the current theme back).\n * - `Object { visible, list, disabled }` — fine-grained control:\n * - `visible` {Boolean} — defaults to `true` when an object is given\n * (passing an object is itself an opt-in to show the toolbar);\n * set explicitly to `false` to hide it while still describing a\n * `list` you intend to reuse later.\n * - `list` {Array<String>} — which tool groups render, using the\n * identifiers above. Unknown/misspelled strings are silently\n * ignored. Omitting `list` falls back to the same default set as\n * the `true` shorthand. **Order in `list` does not change the\n * toolbar's layout** — groups always render in a fixed position\n * (undo/redo → export/import/print → #toolbar-extra slot → toolbarButtons\n * → spacer → theme → size → lock/fullscreen); `list` is purely an\n * inclusion filter.\n * - `disabled` {Array<String>} — which tool groups render grayed out\n * and unclickable while staying **visible** (same identifiers as\n * `list`) — distinct from `list`, which removes a tool entirely.\n * Scoped to the toolbar button itself: it doesn't block the\n * underlying keyboard shortcut (only `'undoRedo'` has one —\n * Ctrl+Z/Ctrl+Y) or a ref method call (`undo()`, `exportToExcel()`,\n * etc.), matching how `list` also only hides the button, not the\n * shortcut/method. Omitting `disabled` disables nothing (default).\n *\n * `toolbarButtons` and the `#toolbar-extra` slot are independent of\n * this prop — they render whenever content is provided, regardless of\n * `list`/`disabled`.\n *\n * @example\n * // Whole default set\n * <excel-grid toolbar />\n * @example\n * // Only undo/redo and fullscreen\n * <excel-grid :toolbar=\"{ list: ['undoRedo', 'fullscreen'] }\" />\n * @example\n * // Toggle visibility from a data property while keeping a fixed list\n * <excel-grid :toolbar=\"{ visible: showToolbar, list: ['export', 'import'] }\" />\n * @example\n * // Visible but grayed out — e.g. import disabled while a prior import is processing\n * <excel-grid :toolbar=\"{ disabled: ['import', 'size'] }\" />\n * @example\n * // Read the current theme back (optional — the picker works without it too)\n * <excel-grid toolbar :theme.sync=\"myTheme\" />\n */\n toolbar: { type: [Boolean, Object], default: false },\n\n /** Extra custom toolbar buttons: [{ key, icon, title, onClick(event, gridApi) }] */\n toolbarButtons: { type: Array, default: () => [] },\n\n /**\n * Print (`printGrid()`/toolbar \"Print\" tugmasi) chiqishida jadval tepasida,\n * markazda ko'rinadigan sarlavha. `null` (standart) — sarlavha umuman\n * render qilinmaydi.\n * @property {String} title - sarlavha matni (bo'sh/undefined bo'lsa render qilinmaydi)\n * @property {Object} [style] - Vue :style uslubidagi obyekt (camelCase),\n * standart markazlashtirilgan/qalin uslub USTIGA qo'shiladi\n * @property {String} [class] - qo'shimcha CSS klass nomi(lari)\n * @example\n * <excel-grid :print-title=\"{ title: 'Talabalar ro\\'yxati', style: { color: '#1a73e8' } }\" />\n */\n printTitle: { type: Object, default: null },\n\n /**\n * Locks the entire grid: blocks all edits (typing, paste, fill, delete,\n * checkbox, select/treeSelect) and structural changes (resize, column\n * reorder, merge/unmerge, undo/redo). Independent from cellEditable/\n * col.editable, which remain per-cell rules. Emit 'update:locked' to\n * support the .sync modifier.\n */\n locked: { type: Boolean, default: false },\n\n /**\n * Host-driven loading state (e.g. while an async data fetch is in\n * flight). When `true`, only the grid body (`.eg-container`) is\n * covered — the toolbar stays visible and interactive. If `columns`\n * is non-empty, a shimmering skeleton matching each column's width is\n * shown; otherwise a plain centered spinner is shown. Purely visual —\n * doesn't block edits/interaction the way `locked` does (combine both\n * if that's also needed).\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :loading=\"isFetching\" />\n */\n loading: { type: Boolean, default: false },\n\n /**\n * Data-driven per-row styling: `(row, rowIndex) => styleObject`. The\n * returned style object is applied to every cell in that row (across\n * the frozen/scrollable/pinned-right zones alike), giving the row a\n * continuous horizontal background/style without any host-side CSS.\n * Selection/active/fill-preview/readonly cell highlighting keeps\n * winning visually — those already use `!important`, which always\n * beats a plain (non-`!important`) inline style. Doesn't affect the\n * row-number header column (mirrors Excel: conditional formatting\n * colors data cells, not the row gutter). Return `null`/`undefined`\n * for rows that shouldn't get a custom style.\n * @type {Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :row-style=\"(row) => row.status === 'error' ? { background: '#fde2e2' } : null\" />\n */\n rowStyle: { type: Function, default: null },\n\n /** Show \"Column info\" / \"Row info\" items in the context menu (dev/debug aid). */\n devMode: { type: Boolean, default: false },\n\n /**\n * Visual theme of the grid.\n * Built-in: 'excel' | 'material' | 'antd' | 'fluent' | 'carbon'\n * Default is the classic Excel 365 look. Use 'material' for Material Design 3.\n * Mirrors the `size` prop's convention: an internal `internalTheme` state\n * mirrors this prop and is what the toolbar's theme picker actually reads\n * from/writes to (via `setTheme()`), so the picker works standalone even\n * without `:theme.sync` — `.sync` is only needed to reflect the current\n * theme back into the host's own state.\n */\n theme: { type: String, default: 'excel' },\n\n /**\n * Shows a filter row directly beneath the column headers (inside the\n * sticky header area). Each leaf column gets a filter control matched to\n * its `type`:\n * - default (no type / 'date' / 'progress'): text search input,\n * matched case-insensitively against the column's *displayed* value\n * (so 'date' matches the formatted string, not the raw stored value).\n * - 'select' / 'treeSelect' *with a static `col.options` array*: a\n * native <select> populated from those options (tree options are\n * flattened, indented by depth), matched by exact raw value.\n * - 'select' / 'treeSelect' with function-based `col.options` (options\n * computed per-row): falls back to the text control, since a single\n * fixed option list can't be resolved outside a specific row.\n * - 'checkbox': a tri-state <select> — All / Checked / Unchecked.\n * - 'action': no filter control (not a data-bound column).\n *\n * Rows that don't match every active filter are hidden using the same\n * mechanism as `hiddenRows` — virtual scroll, keyboard navigation, and\n * `exportToExcel()` all skip them automatically, with no separate\n * filtering code path to keep in sync.\n *\n * Set `col.filterable: false` to omit the filter control for one column\n * (its filter cell renders empty). Set\n * `col.filterFn(cellValue, filterValue, row, rowIndex, column)` to\n * override the default match logic for that column entirely.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row />\n */\n filterRow: { type: Boolean, default: false },\n\n /**\n * Current filter values, keyed by column `key`. Optional — the filter\n * row works standalone via internal state (`internalFilters`) even\n * without `.sync`, mirroring the `size`/`theme` prop convention. Bind\n * `:filters.sync` only if the host needs to read or restore the current\n * filter state.\n * @type {Object}\n * @default {}\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row :filters.sync=\"myFilters\" />\n */\n filters: { type: Object, default: () => ({}) },\n\n /**\n * Shows a sort icon (kebab/\"more actions\" glyph) in every leaf column\n * header. Clicking it opens a popup menu — A → Z / Z → A / Clear sort —\n * matched to Excel's own column-header dropdown. Picking a different\n * column's option replaces the active sort — single-column sort only.\n *\n * Sorting **physically reorders** the `data` array (like a real\n * spreadsheet sort) and emits it through the normal v-model/`input`\n * event — it is recorded in the undo/redo history exactly like any\n * other data edit, so `undo()` restores the pre-sort row order.\n * \"Clear sort\" (or `clearSort()`) both clears the indicator **and**\n * restores the row order from immediately before the first sort of\n * this session — switching columns or direction in between doesn't\n * move that reference point. If rows were added/removed since sorting\n * (row count no longer matches), the restore is skipped and only the\n * indicator clears, to avoid silently dropping/duplicating rows.\n *\n * Comparison is matched to the column's `type`: plain/`'progress'`\n * columns compare the raw value (numeric-aware); `'date'` compares the\n * parsed date chronologically, not the formatted string; `'select'` /\n * `'treeSelect'` compare the displayed option label; `'checkbox'`\n * compares the boolean state. Empty/`null` values always sort last,\n * regardless of direction. `'action'` columns are never sortable.\n *\n * Set `col.sortable: false` to omit the icon for one column. Set\n * `col.sortFn(rowA, rowB, column) => number` to fully override the\n * comparison for that column.\n *\n * Blocked (no-op) while the grid is `locked`, or while any merged\n * cells or row outline groups exist — both are anchored to row\n * *position*, which a reorder would silently invalidate.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable />\n */\n sortable: { type: Boolean, default: false },\n\n /**\n * Current sort state — `{ key, direction }` (`direction` is `'asc'` or\n * `'desc'`) or `null` when unsorted. Optional — sorting works standalone\n * via internal state (`internalSort`) even without `.sync`, mirroring\n * the `size`/`theme`/`filters` prop convention. Bind `:sort.sync` only\n * if the host needs to read or restore the current sort state.\n * @type {Object}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable :sort.sync=\"mySort\" />\n */\n sort: { type: Object, default: null },\n\n /**\n * Unique key that opts the grid into local version history: when the page\n * is being left (tab hidden/closed, or this component unmounted — NOT on\n * every edit), the current `v-model` is content-hashed (via a Web Worker\n * when available, so the main thread stays responsive right as the page\n * may be unloading) and saved to the browser's IndexedDB as a **version**,\n * keyed by this string. If the hash matches an already-saved version for\n * this key, that version's timestamp is refreshed instead of creating a\n * duplicate — only genuinely different content produces a new version.\n * Also adds a toolbar button ('localSync' — see the `toolbar` prop's\n * `list`/`disabled`) that opens a modal listing saved versions, each with\n * a read-only preview and **Restore** / **Delete** actions, plus a\n * **Save** button for an explicit checkpoint.\n *\n * Two different grid instances must use different keys — the same key\n * means \"the same version history\" (concurrent tabs interleave). Omit\n * this prop entirely to opt out completely: no IndexedDB access happens\n * at all, and the toolbar button never renders.\n *\n * See also `localSyncMaxVersions`, the ref-callable methods\n * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,\n * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,\n * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the\n * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`\n * events — [details](/local-sync).\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" toolbar />\n */\n localSyncKey: { type: String, default: null },\n\n /**\n * Optional tenant/organization identifier for multi-tenant hosts (SaaS\n * apps where the same browser/device is used across different\n * organizations). When set, it's combined with `localSyncKey`\n * internally (`${tenant}::${localSyncKey}`) so each tenant gets a fully\n * isolated IndexedDB version history — switching tenants on the same\n * device never shows another tenant's saved drafts. Omit it (default)\n * for single-tenant hosts; behavior is then identical to before this\n * prop existed.\n *\n * Has no effect without `localSyncKey` — it only changes how that key\n * is namespaced internally, it doesn't opt into the feature by itself.\n * Reactive: changing `tenant` (e.g. after an async \"current\n * organization\" load) reloads that tenant's own version list/buffer,\n * the same way a `localSyncKey` that starts empty and becomes truthy\n * later does. Avoid `::` inside `tenant`/`localSyncKey` values\n * themselves — it's the internal separator between the two.\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :tenant=\"currentOrgId\" toolbar />\n */\n tenant: { type: String, default: null },\n\n /**\n * Maximum number of local versions kept per `localSyncKey` (per\n * `tenant`, when set). Once a new version pushes the count past this,\n * the oldest version(s) (by `createdAt`) are deleted automatically\n * (FIFO). Has no effect without `localSyncKey`.\n * @type {Number}\n * @default 10\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :local-sync-max-versions=\"20\" />\n */\n localSyncMaxVersions: { type: Number, default: 10 }\n },\n\n data() {\n return {\n // Selection\n activeCell: { row: 0, col: 0 },\n selectionRanges: [], // [{ startRow, startCol, endRow, endCol }]\n isMouseDown: false,\n mouseDownRange: null,\n isCtrlDrag: false,\n\n // Edit\n editingCell: null, // { row, col }\n editValue: '',\n editOriginalValue: '',\n\n // Scroll\n scrollTop: 0,\n scrollLeft: 0,\n headerViewportWidth: null,\n // The grid's DOM parent's width — the reference for relative (%, css)\n // column widths, in BOTH widthMode values (see _relativeWidthContainer).\n // In 'auto' mode the shell's width is DERIVED from column widths, so\n // resolving a relative column width against the shell's own measured\n // viewport (headerViewportWidth) is circular (each resolve nudges the\n // shell size, which nudges the next resolve...). 'full' mode isn't\n // circular that way, but headerViewportWidth (scrollBody.clientWidth)\n // turned out to have its OWN instability source: a vertical scrollbar\n // toggling on/off flips it by the scrollbar's width even though the\n // shell's own outer size never changes — confirmed via a real host\n // report (repeated brief re-render bursts hurting CLS) that 'auto'\n // mode didn't reproduce. The parent's width doesn't depend on the\n // grid's own rendered size OR its internal scrollbar state, so using\n // it everywhere sidesteps both instability sources uniformly.\n hostContainerWidth: null,\n // Real vertical-scrollbar width measured off the scroll body (0 for\n // macOS overlay scrollbars) — widthMode:'auto' adds it to the natural\n // width so the scrollbar never eats into the last column.\n vScrollbarW: 0,\n // .eg-shell's horizontal border total (border-box). Measured at mount —\n // themes can restyle it — so widthMode:'auto' accounts for it instead\n // of shaving those pixels off the last column.\n shellBorderW: 2,\n\n // Auto-height (height prop omitted) — 70vh cap, kept in sync on resize\n windowInnerH: typeof window !== 'undefined' ? window.innerHeight : 800,\n\n // Virtual scroll\n virtualStartIndex: 0,\n virtualEndIndex: 0,\n virtualOffsetTop: 0,\n totalVirtualHeight: 0,\n\n // Column widths (flat index → px)\n colWidths: {},\n\n // Flat index → raw CSS width spec, for columns whose width is a relative\n // unit ('20%', '10em', ...) and therefore needs re-resolving whenever the\n // grid's viewport width changes. Cleared for a column once it's manually\n // resized/auto-fit — see startColResize/autoFitColumn/_applyAutoWidth.\n relativeColSpecs: {},\n\n // col.key → true, for columns manually resized while fitColumns is on.\n // Such a column becomes a fixed px width (excluded from the proportional\n // fit); the rest keep scaling among themselves to fill what's left — see\n // _applyFitColumns(). Keyed by col.key (not flat index) so it survives\n // column reorders, same convention as relativeColSpecs preservation.\n fitColumnsOverrides: {},\n\n // Row heights (row index → px)\n rowHeights: {},\n\n // Import/export progress\n importState: { active: false, percent: 0, message: '' },\n exportState: { active: false },\n\n // localSyncKey feature — modal state: the version list (metadata only)\n // plus which one is selected/previewed. `selectedVersion` holds that\n // version's full { id, data, createdAt, updatedAt } for the nested-grid\n // preview (see EgLocalSyncModal.vue).\n localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },\n // True when one or more versions already existed at mount and the user\n // hasn't opened the modal yet — drives the toolbar button's \"unseen\n // local versions\" dot. See _loadLocalVersionsOnMount() for the full lifecycle.\n localSyncAvailable: false,\n\n resizing: {\n active: false,\n type: null, // 'col' | 'row'\n index: -1,\n startX: 0,\n startY: 0,\n startSize: 0,\n currentPos: 0\n },\n\n // Fill handle\n fillDrag: {\n active: false,\n sourceRange: null,\n currentRow: -1,\n currentCol: -1\n },\n\n // Drag-move\n dragMove: {\n active: false,\n sourceRange: null,\n startX: 0,\n startY: 0,\n targetRow: -1,\n targetCol: -1,\n copyMode: false\n },\n\n // Clipboard (internal)\n clipboardMatrix: null,\n clipboardRange: null,\n clipboardMode: null, // 'copy' | 'cut'\n\n // Context menu\n contextMenu: { visible: false, x: 0, y: 0, row: -1, col: -1 },\n // false while the menu's real size hasn't been measured yet — kept hidden\n // (not painted) so the flip-to-fit repositioning in _positionContextMenu()\n // doesn't cause a visible jump.\n contextMenuPositioned: false,\n\n // Sort popup menu (header sort icon click) — { visible, x, y, key }\n sortMenu: { visible: false, x: 0, y: 0, key: null },\n sortMenuPositioned: false,\n\n // devMode — column/row config info modal ({ title, json } | null)\n infoModal: null,\n\n // Select dropdown\n selectDropdown: null, // { row, col, options, currentValue, rect }\n\n // Date picker overlay\n datePickerOverlay: null, // { row, col, format, value, rect }\n\n // Cell value validation (cellValidator)\n invalidCell: null, // { row, col, message, rect }\n\n // Merges (controlled via prop + update:merges)\n internalMerges: [],\n\n // Toolbar (controlled via prop + update:locked / update:size / update:theme)\n internalLocked: false,\n internalSize: 'middle',\n internalTheme: 'excel',\n // Pinned columns — merges col.pin (columns config default) with pinnedColumns prop.\n // context-menu pin/unpin updates this directly so .sync is not required.\n internalPinnedColumns: {},\n // Brief centered icon feedback whenever the lock state actually changes\n // (toolbar click, toggleLock(), or a host-driven :locked.sync change).\n lockFlashVisible: false,\n lockFlashLocked: false,\n isFullscreen: false,\n // Bumped whenever _history/_historyIndex change so canUndo/canRedo stay reactive.\n historyVersion: 0,\n\n // Groups\n internalGroups: [], // [{ type, startIndex, endIndex }]\n internalCollapsedGroups: [],\n\n // Header\n headerDepth: 1,\n headerRows: [],\n\n // Leaf columns cache\n leafCols: [],\n\n // Footer column totals (flat col index → number | null)\n columnTotals: {},\n\n // Filter row (controlled via prop + update:filters)\n internalFilters: {},\n // Row indices currently hidden by an active filter — merged into\n // hiddenRowSet, so virtual scroll/export/navigation need no separate path.\n filteredRowIndices: [],\n\n // Sort (controlled via prop + update:sort) — { key, direction } | null\n internalSort: null,\n\n // Column drag-and-drop state\n colDrag: {\n pending: false, // mousedown received, waiting for movement threshold\n active: false, // drag in progress\n sourceFirstLeaf: -1, // first leaf flat-index of the dragged node\n sourceLeafCount: 1, // number of leaves in the dragged node (1=leaf, N=group)\n dropIndex: -1, // insert-before flat index (0..N)\n startX: 0,\n startY: 0,\n ghostLabel: '',\n ghostWidth: 0, // ghost mirrors the grabbed header's real width (capped)\n grabOffsetX: 0 // cursor offset inside the grabbed header — keeps the grab point under the cursor\n },\n }\n },\n\n computed: {\n t() { return getMessages(this.locale) },\n\n // localSyncKey feature — the actual IndexedDB storage key. idbStore.js\n // itself knows nothing about \"tenants\"; this is the one place that\n // namespaces localSyncKey by tenant when one is given, so every\n // storage-layer call site below just uses this instead of\n // this.localSyncKey directly. null whenever localSyncKey itself is\n // unset — tenant alone doesn't opt into the feature.\n effectiveSyncKey() {\n if (!this.localSyncKey) return null\n return this.tenant ? `${this.tenant}::${this.localSyncKey}` : this.localSyncKey\n },\n\n pinnedLeftSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'left') s.add(col._flatIndex) })\n return s\n },\n\n pinnedRightSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'right') s.add(col._flatIndex) })\n return s\n },\n\n pinnedLeftCols() { return this.leafCols.filter(col => this.pinnedLeftSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n pinnedRightCols() { return this.leafCols.filter(col => this.pinnedRightSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n\n hasPinnedLeft() { return this.pinnedLeftSet.size > 0 },\n hasPinnedRight() { return this.pinnedRightSet.size > 0 },\n\n hasFooter() { return this.leafCols.some(col => this.isColumnTotalVisible(col)) },\n\n pinnedLeftWidth() {\n let w = 0\n for (const col of this.pinnedLeftCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n pinnedRightWidth() {\n let w = 0\n for (const col of this.pinnedRightCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n totalRows() { return this.data.length },\n\n // See the `toolbar` prop's JSDoc for the full Boolean|Object contract.\n _toolbarVisible() {\n if (typeof this.toolbar === 'boolean') return this.toolbar\n return !!this.toolbar && this.toolbar.visible !== false\n },\n _toolbarList() {\n if (this.toolbar && Array.isArray(this.toolbar.list)) return this.toolbar.list\n return DEFAULT_TOOLBAR_LIST\n },\n // Same key set as _toolbarList, but for the disabled (grayed out, still visible) state\n // instead of visibility — see toolbar prop JSDoc.\n _toolbarDisabledList() {\n if (this.toolbar && Array.isArray(this.toolbar.disabled)) return this.toolbar.disabled\n return []\n },\n\n // Columns shown in the local-sync modal's preview table — action columns\n // are pure interactive UI (buttons), not printable/previewable data,\n // same exclusion printGrid()/exportToExcel() already apply.\n _localSyncPreviewColumns() {\n return this.leafCols.filter(col => col.type !== 'action')\n },\n\n // Passed to EgLocalSyncModal so it can render the snapshot preview as a\n // real, nested ExcelGrid instance — see the comment on ExcelGridComponent\n // above for why this is the module-level const and not `this.$options`.\n _selfComponentDef() {\n return ExcelGridComponent\n },\n\n _sizeConf() { return SIZE_CONFIG[this.internalSize] || SIZE_CONFIG.middle },\n\n sizeOptions() { return Object.keys(SIZE_CONFIG) },\n _rowH() { return this._sizeConf.row },\n _headerRowH() { return this._sizeConf.header },\n _footerRowH() { return this._sizeConf.footer },\n _filterRowH() { return this.filterRow ? this._sizeConf.filter : 0 },\n\n headerRowHeight() { return this._headerRowH },\n footerRowHeight() { return this._footerRowH },\n\n // Pre-built cumulative height array — O(n) once, then cached by Vue.\n // findVisibleRange() uses binary search against this cached array → O(log n) per scroll.\n // Invalidated only when row count, custom heights, or hidden rows change.\n cumulativeHeights() {\n return buildCumulativeHeights(this.totalRows, this._rowH, this.rowHeights, this.hiddenRowSet, ROW_LIST_EDGE_PAD, ROW_LIST_EDGE_PAD)\n },\n\n // height prop omitted → fit header + filter row + all (visible) rows + footer exactly.\n _autoContentHeight() {\n const headerH = this._headerRowH * this.headerDepth + this._filterRowH\n const bodyH = this.cumulativeHeights[this.totalRows] || 0\n const footerH = this.hasFooter ? this._footerRowH : 0\n return headerH + bodyH + footerH\n },\n\n // Resolved grid-area height: explicit `height` prop wins as-is (old\n // behavior); omitted → auto-fit content, capped at 70% of the viewport.\n effectiveHeight() {\n if (this.height != null) return this.height\n return Math.min(this._autoContentHeight, this.windowInnerH * 0.7)\n },\n\n hiddenColSet() { return new Set(this.hiddenColumns) },\n\n hiddenRowSet() {\n const s = new Set(this.hiddenRows)\n // Also add rows hidden by collapsed row groups\n const collapsed = getCollapsedRowIndices(this.internalGroups, this.internalCollapsedGroups)\n collapsed.forEach(i => s.add(i))\n // Also add rows hidden by the filter row (see filteredRowIndices/_recomputeRowFilters)\n this.filteredRowIndices.forEach(i => s.add(i))\n // Also add rows the host's rowVisible(row, rowIndex) rejects — a static/\n // dynamic rule (status, parity, ...) independent of the toggleable\n // hiddenRows list and the interactive filter row.\n if (typeof this.rowVisible === 'function') {\n const rows = this.data\n for (let i = 0; i < rows.length; i++) {\n if (this.rowVisible(rows[i], i) === false) s.add(i)\n }\n }\n return s\n },\n\n hiddenColFromGroupSet() {\n return getCollapsedColIndices(this.internalGroups, this.internalCollapsedGroups)\n },\n\n effectiveHiddenColSet() {\n const s = new Set(this.hiddenColSet)\n this.hiddenColFromGroupSet.forEach(i => s.add(i))\n // col.visible === false — a schema-level hide, independent of the\n // toggleable hiddenColumns list. \"Show All Columns\" never reveals it.\n // Boolean or Function(column, colIndex) => boolean — like rowVisible (see\n // hiddenRowSet above), the function form is called fresh on every\n // evaluation of this computed, so it stays reactive to whatever host\n // state its closure reads even though leafCols itself (built by\n // flattenColumns' `{...col}` shallow clone) is only rebuilt when the\n // columns prop reference changes. A function value survives that clone\n // unchanged (functions copy by reference), so the closure keeps running\n // fresh — no need to restructure columns into a computed just to react.\n this.leafCols.forEach(col => {\n const v = typeof col.visible === 'function' ? col.visible(col, col._flatIndex) : col.visible\n if (v === false) s.add(col._flatIndex)\n })\n return s\n },\n\n rowGroups() { return this.internalGroups.filter(g => g.type === 'row') },\n colGroups() { return this.internalGroups.filter(g => g.type === 'column') },\n\n visibleRowRange() {\n const rows = []\n for (let i = this.virtualStartIndex; i <= this.virtualEndIndex; i++) rows.push(i)\n return rows\n },\n\n primaryRange() {\n return getPrimaryRange(this.selectionRanges, this.activeCell)\n },\n\n fillHandleVisible() {\n return this.primaryRange && !this.editingCell\n },\n\n fillHandleStyle() {\n if (!this.primaryRange) return {}\n const r = normalizeRange(this.primaryRange)\n // O(1) lookup via cumulativeHeights instead of O(endRow) getRowTop loop\n const bottom = this.cumulativeHeights[r.endRow + 1] || 0\n const right = this.getColLeft(r.endCol + 1)\n return {\n position: 'absolute',\n width: '6px',\n height: '6px',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n border: '1px solid white',\n bottom: (this.totalVirtualHeight - bottom + 1) + 'px',\n right: (this.scrollContentWidth - right + 1) + 'px',\n cursor: 'crosshair',\n zIndex: 10\n }\n },\n\n\n invalidTooltipStyle() {\n if (!this.invalidCell || !this.invalidCell.rect) return {}\n const { rect } = this.invalidCell\n return {\n position: 'fixed',\n top: (rect.bottom + 4) + 'px',\n left: rect.left + 'px',\n zIndex: 9999\n }\n },\n\n dragGhostStyle() {\n if (!this.dragMove.active || this.dragMove.targetRow < 0) return {}\n const r = normalizeRange(this.dragMove.sourceRange)\n const rows = r.endRow - r.startRow\n const cols = r.endCol - r.startCol\n // O(1) lookup via cumulativeHeights instead of O(targetRow) getRowTop loop\n const top = this.cumulativeHeights[this.dragMove.targetRow] || 0\n const left = this.getColLeft(this.dragMove.targetCol)\n const width = this.getRangeWidth(this.dragMove.targetCol, this.dragMove.targetCol + cols)\n const height = this.getRangeHeight(this.dragMove.targetRow, this.dragMove.targetRow + rows)\n return {\n position: 'absolute',\n top: top + 'px',\n left: left + 'px',\n width: width + 'px',\n height: height + 'px',\n border: '2px dashed #1a73e8',\n pointerEvents: 'none',\n zIndex: 20\n }\n },\n\n // widthMode:'auto' — the grid's natural (max-content) width: row-number\n // gutter + outline gutter (when row groups exist) + every visible leaf\n // column's current width + the measured vertical scrollbar width (0 when\n // rows fit / overlay scrollbars). Fully reactive: column resize,\n // hide/show, col.visible, and group changes all re-derive it.\n naturalGridWidth() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return ROW_HEADER_WIDTH + outlineW + this.getColLeft(this.leafCols.length) + this.vScrollbarW + this.shellBorderW\n },\n\n // Reference width for resolving relative (%, css) column widths — always\n // hostContainerWidth (the grid's DOM parent), in both widthMode values.\n // See hostContainerWidth's declaration comment for why headerViewportWidth\n // (the grid's own scrollBody.clientWidth) isn't used for this in either\n // mode: circular in 'auto', and independently unstable in 'full' (a\n // vertical scrollbar toggling flips it without the shell's outer size\n // ever changing).\n _relativeWidthContainer() {\n return this.hostContainerWidth\n },\n\n // The CSS width both the shell and the wrapper use in non-fullscreen mode.\n _hostWidth() {\n if (this.widthMode === 'auto') return this.naturalGridWidth + 'px'\n return typeof this.width === 'number' ? this.width + 'px' : this.width\n },\n\n wrapperStyle() {\n if (this.isFullscreen) {\n return {\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n }\n const style = {\n width: this._hostWidth,\n height: this.effectiveHeight + 'px',\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n shellStyle() {\n if (this.isFullscreen) return {}\n const style = {\n width: this._hostWidth,\n display: 'flex',\n flexDirection: 'column'\n }\n // The cap that keeps a too-wide column set at the host's edge\n // (internal horizontal scroll takes over from there).\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n canUndo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex >= 0\n },\n\n canRedo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex < this._history.length - 1\n },\n\n headerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n position: 'sticky',\n top: 0,\n zIndex: 30,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n cornerStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: (this._headerRowH * this.headerDepth + this._filterRowH) + 'px',\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderBottom: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0\n }\n },\n\n rowOutlineGutterStyle() {\n return { width: OUTLINE_GUTTER + 'px', flexShrink: 0 }\n },\n\n frozenColHeaderStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n scrollColHeaderStyle() {\n // Once measured, lock the header to the body's exact clientWidth (flex: 0 0 <px>)\n // instead of growing independently via flex:1 — this guarantees identical\n // scrollWidth-clientWidth (max scrollLeft) on both, so syncing scrollLeft never clamps\n // one before the other. (padding-right can't do this: clientWidth always includes\n // padding, so padding never actually shrinks it.)\n //\n // The pinned zones must be subtracted from the measured clientWidth: in the\n // body they live INSIDE .eg-scroll-body as sticky overlays (so clientWidth\n // already spans them), but in the header/footer rows they are separate flex\n // siblings rendered BEFORE/AFTER this strip. Without the subtraction the\n // header/footer rows end up frozenWidth+pinnedRightWidth wider than the\n // container, giving .eg-container (overflow:auto) its own second horizontal\n // scrollbar. With it, this strip's viewport exactly matches the body's\n // scrollable window between the sticky zones, and — together with the strip\n // inner width being scrollableContentWidth — both max scrollLefts stay equal.\n // A falsy 0 (not just null/undefined) means \"not really measured yet\" — e.g. the grid\n // mounted while its container was display:none (a hidden v-show tab). Locking to '0 0 0px'\n // in that case would collapse the header/footer strip to invisible; fall back to the same\n // natural flex:1 used pre-measurement instead. The ResizeObserver in mounted() re-measures\n // once the container becomes visible, replacing this with a real px lock.\n if (this.headerViewportWidth) {\n const w = Math.max(\n 0,\n this.headerViewportWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n )\n return { flex: '0 0 ' + w + 'px', overflow: 'hidden' }\n }\n return { flex: 1, overflow: 'hidden' }\n },\n\n bodyAreaStyle() {\n // Fullscreen: let flexbox fill the viewport dynamically (mirrors\n // _containerH()'s fullscreen branch, which reads the resulting\n // clientHeight back for virtual-scroll math) — an explicit height\n // here would fight that.\n if (this.isFullscreen) {\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: 1,\n position: 'relative'\n }\n }\n // Fixed/auto height: an explicit px height (matching _containerH()'s\n // non-fullscreen formula) instead of flex:1 growth. flex:1 here only\n // ever has ONE sibling competing for space mathematically, so it\n // should be exact — but nested flex cross-axis stretch (this box's\n // height flows down to .eg-scroll-body/.eg-row-header-col via\n // align-items:stretch) is vulnerable to sub-pixel rounding between\n // browser-rendered header/footer row heights and the integer px\n // this component assumes, occasionally landing 1px short. That 1px\n // shortfall is enough to make .eg-scroll-body's overflow:auto show a\n // vertical scrollbar even when content should fit exactly (reported\n // with a single data row and no `height` prop — content height ==\n // container height, so any shortfall trips overflow instantly).\n // flex: '0 0 auto' overrides the .eg-body-area class rule's own\n // `flex: 1` (flex-basis: 0%) — with a non-auto flex-basis, flexbox\n // ignores this element's own `height` entirely for main-axis sizing,\n // so basis must explicitly go back to `auto` for `height` to apply.\n const h = this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - (this.hasFooter ? this._footerRowH : 0)\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: '0 0 auto',\n height: Math.max(h, 0) + 'px',\n position: 'relative'\n }\n },\n\n rowHeaderColStyle() {\n return {\n width: ROW_HEADER_WIDTH + 'px',\n minWidth: ROW_HEADER_WIDTH + 'px',\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n position: 'sticky',\n left: 0,\n zIndex: 20,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n rowOutlineColStyle() {\n return {\n width: OUTLINE_GUTTER + 'px',\n minWidth: OUTLINE_GUTTER + 'px',\n flexShrink: 0,\n position: 'relative',\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))'\n }\n },\n\n scrollBodyStyle() {\n return {\n flex: 1,\n overflow: 'auto',\n position: 'relative',\n overflowAnchor: 'none' /* prevent Chrome scroll anchoring from fighting virtual scroll translateY */\n }\n },\n\n frozenColBodyStyle() {\n const w = this.getFrozenWidth()\n return {\n position: 'sticky',\n left: 0,\n width: w + 'px',\n flexShrink: 0,\n zIndex: 10,\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))',\n overflow: 'hidden'\n }\n },\n\n mainCellsStyle() {\n const left = this.getFrozenWidth()\n return {\n position: 'absolute',\n left: left + 'px',\n top: 0,\n bottom: 0\n }\n },\n\n scrollContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && !this.pinnedRightSet.has(i)) w += this.getColW(i)\n }\n // Extra right padding so pinned-right zone doesn't cover the last scrollable column\n if (this.hasPinnedRight) w += this.pinnedRightWidth\n return w\n },\n\n scrollableContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && this.isColumnScrollable(i)) w += this.getColW(i)\n }\n return w\n },\n\n pinnedRightBodyStyle() {\n return {\n position: 'sticky',\n right: 0,\n // Natural position must be the inner's right edge for sticky right:0 to\n // engage (see the .eg-scroll-body-inner flex comment in the template).\n marginLeft: 'auto',\n width: this.pinnedRightWidth + 'px',\n zIndex: 10,\n background: 'white',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightHeaderStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n scrollRowHeadersStyle() {\n return { flex: 1, overflow: 'hidden', position: 'relative', overflowAnchor: 'none' }\n },\n\n footerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n flexShrink: 0,\n position: 'sticky',\n bottom: 0,\n zIndex: 30,\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n // box-shadow (not a real border, like .eg-header-area's own\n // separator) — a real border would add an unaccounted extra 1px to\n // the footer's layout height (nothing in the height math knows\n // about it), throwing off effectiveHeight/_autoContentHeight and\n // tripping a phantom vertical scrollbar on .eg-scroll-body even\n // when content should fit exactly.\n boxShadow: '0 -2px 0 0 var(--eg-color-border-strong, var(--eg-color-border-strong-default))'\n }\n },\n\n footerLabelStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: this._footerRowH + 'px',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderTop: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0,\n fontSize: 'var(--eg-font-size-header, var(--eg-font-size-header-default))',\n fontWeight: 600\n }\n },\n\n frozenColFooterStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightFooterStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n colOutlineRowStyle() {\n return {\n position: 'absolute',\n top: -OUTLINE_GUTTER + 'px',\n left: 0,\n right: 0,\n height: OUTLINE_GUTTER + 'px'\n }\n },\n\n resizeLineStyle() {\n if (!this.resizing.active) return {}\n if (this.resizing.type === 'col') {\n return {\n position: 'absolute',\n top: 0,\n left: (this.resizing.currentPos - (this.resizing.containerLeft || 0)) + 'px',\n width: '2px',\n height: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n } else {\n return {\n position: 'absolute',\n left: 0,\n top: (this.resizing.currentPos - (this.resizing.containerTop || 0)) + 'px',\n height: '2px',\n width: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n }\n }\n },\n\n watch: {\n size: {\n immediate: true,\n handler(v) { this.internalSize = v || 'middle' }\n },\n\n theme: {\n immediate: true,\n handler(v) { this.internalTheme = v || 'excel' }\n },\n\n // Runs before the `columns`/`data` watchers below (both call\n // _recomputeRowFilters()) so internalFilters already reflects the prop\n // by the time either of them fires during the initial immediate pass.\n filters: {\n immediate: true,\n handler(v) { this.internalFilters = v ? { ...v } : {} }\n },\n\n internalFilters() { this._scheduleFilterRecompute() },\n\n filterRow() {\n this._recomputeRowFilters()\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n sort: {\n immediate: true,\n handler(v) { this.internalSort = v ? { key: v.key, direction: v.direction } : null }\n },\n\n internalSize() {\n this.rowHeights = {}\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n autoWidth(v) {\n if (v) this._applyAutoWidth()\n },\n\n fitColumns(v) {\n if (v) {\n this.fitColumnsOverrides = {}\n this._applyFitColumns()\n } else {\n // Back to normal per-column resolution (same as the columns\n // watcher's non-fit branch, without keyToWidth — fitColumns already\n // discarded any remembered manual-resize widths when it turned on).\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n this.fitColumnsOverrides = {}\n this._applyAutoWidth()\n }\n },\n\n columns: {\n immediate: true,\n handler(cols) {\n // Build key→width map from current state before rebuilding leafCols.\n // This preserves user-resized widths across column reorders: when columns\n // are moved, _flatIndex values shift but col.key stays the same.\n const keyToWidth = {}\n const keyToSpec = {}\n ;(this.leafCols || []).forEach(col => {\n if (this.colWidths[col._flatIndex] !== undefined) {\n keyToWidth[col.key] = this.colWidths[col._flatIndex]\n }\n if (this.relativeColSpecs[col._flatIndex] !== undefined) {\n keyToSpec[col.key] = this.relativeColSpecs[col._flatIndex]\n }\n })\n\n this.leafCols = flattenColumns(cols)\n this.headerDepth = columnTreeDepth(cols)\n this.headerRows = buildHeaderRows(cols, this.headerDepth)\n this._maskCache = {}\n\n if (this.fitColumns) {\n // fitColumns is the highest-priority width control — it overrides\n // even remembered manual-resize widths (keyToWidth above), so it\n // deliberately does NOT participate in that preservation logic.\n // fitColumnsOverrides (per-column fixed-width overrides set by\n // manually resizing while fitColumns is on) IS preserved, by key,\n // dropping any keys that no longer exist in the new column set.\n this.colWidths = {}\n this.relativeColSpecs = {}\n const validKeys = new Set(this.leafCols.map(c => c.key))\n const newOverrides = {}\n Object.keys(this.fitColumnsOverrides).forEach(k => {\n if (validKeys.has(k)) newOverrides[k] = true\n })\n this.fitColumnsOverrides = newOverrides\n this._applyFitColumns()\n } else {\n // Rebuild colWidths by new flat index, using col.key to look up saved widths\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n if (keyToWidth[col.key] !== undefined) {\n newColWidths[col._flatIndex] = keyToWidth[col.key]\n if (keyToSpec[col.key] !== undefined) newRelativeSpecs[col._flatIndex] = keyToSpec[col.key]\n } else {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n }\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n }\n\n this.computeColumnTotals()\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this._applyAutoWidth()\n\n // Merge col.pin entries for columns not yet tracked in internalPinnedColumns.\n // Existing entries (from prop or prior runtime pin/unpin) are not overwritten.\n const pinFromCols = this._colPinMap(this.leafCols)\n Object.keys(pinFromCols).forEach(key => {\n if (!(key in this.internalPinnedColumns)) this.$set(this.internalPinnedColumns, key, pinFromCols[key])\n })\n }\n },\n pinnedColumns: {\n immediate: true,\n handler(v) {\n // Rebuild: col.pin is the base default, prop value wins on conflict.\n this.internalPinnedColumns = { ...this._colPinMap(this.leafCols), ...(v || {}) }\n }\n },\n merges: {\n immediate: true,\n handler(v) { this.internalMerges = v ? [...v] : [] }\n },\n collapsedGroups: {\n immediate: true,\n handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }\n },\n // `immediate: true` guarantees this fires once, synchronously, right at\n // setup regardless of `v` — that's the one reliable signal that THIS\n // particular call is the initial sync, not a real host-driven change\n // (e.g. via `:locked.sync`). Every call after the first is a genuine\n // change, so only those flash. (Watching internalLocked itself for this\n // instead — the previous approach — can't tell \"initial value happened\n // to be true\" from \"the user's first-ever real toggle\": both look like\n // \"the first time internalLocked actually changed value\".)\n locked: {\n immediate: true,\n handler(v) {\n this.internalLocked = !!v\n if (this._lockPropSynced) this._flashLock(this.internalLocked)\n this._lockPropSynced = true\n }\n },\n data: {\n immediate: true,\n handler(newData) {\n // Keep _dataRaw in sync so template uses fresh data without triggering dependArray\n this._dataRaw = newData || []\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this.computeColumnTotals()\n // Force re-render so cell values refresh even when virtualScroll indices don't change\n // (e.g. editing a cell: same row count, same scroll position, only content changed)\n this.$forceUpdate()\n this._applyAutoWidth()\n }\n },\n totalRows() { this.updateVirtualScroll() },\n hiddenRowSet() { this.updateVirtualScroll() },\n totalVirtualHeight() { this.$nextTick(this.syncHeaderViewportWidth) },\n // widthMode:'auto' — the shell resizes with its columns (resize, hide/\n // show), so the header/body viewport lock must be re-measured after the\n // DOM picks up the new width. (The runaway-loop safety net lives in\n // _widthSyncChainOk(), gating the actual colWidths mutation inside\n // syncHeaderViewportWidth() — not here — since that single choke point\n // catches every trigger path, not just this one.)\n naturalGridWidth() {\n if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)\n },\n widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },\n\n // localSyncKey/tenant feature — mounted() only calls\n // _loadLocalVersionsOnMount() once, at mount time. Without these\n // watchers, a key/tenant that starts empty (a host conditionally\n // passing `:local-sync-key=\"ready ? id : null\"`, or a `tenant` only\n // known after an async \"current organization\" load) and later becomes\n // truthy would never load its version list, never promote a leftover\n // buffer, and never show the toolbar badge — the feature would look\n // \"broken\" for the rest of that page's lifetime even though everything\n // else works. Both share _reloadLocalSyncForKeyChange() since either one\n // changing means the effective storage key changed.\n localSyncKey(newKey, oldKey) {\n if (newKey === oldKey) return\n this._reloadLocalSyncForKeyChange()\n },\n tenant(newTenant, oldTenant) {\n if (newTenant === oldTenant) return\n this._reloadLocalSyncForKeyChange()\n }\n },\n\n created() {\n // Non-reactive row data reference — avoids Vue 2 dependArray(data) O(n) loop.\n // Vue 2 calls dependArray() for EVERY `data[ri]` access in the template,\n // iterating all 50,000 rows each time. With 1200 cells per render that's\n // 60 million iterations per render (~600ms). _dataRaw bypasses this.\n this._dataRaw = this.data || []\n\n // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes\n this._cellStyleCache = Object.create(null)\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n // rAF handles for scroll/resize throttle\n this._scrollRAF = null\n this._resizeRAF = null\n\n // Undo/redo history — non-reactive to avoid Vue overhead.\n // Each entry: { type: 'data'|'merges', before: {...}, after: {...} }\n // 'data' stores only diff rows: { indices: number[], rows: object[] }\n this._history = []\n this._historyIndex = -1\n this._historySkip = false // true while undo/redo is in progress\n\n // Column drag preview offsets — non-reactive so direct DOM updates bypass Vue re-renders.\n // Keys are column keys, values are pixel offsets.\n this._dragOffsets = {}\n\n // Column drag internals (all non-reactive):\n // _dragBounds — snapshot of every visible leaf header's ORIGINAL viewport\n // rect taken once at drag start; drop-index math reads only this snapshot\n // (adjusted by scrollLeft delta), never live DOM rects — live rects carry\n // in-flight CSS transforms and give unstable intermediate positions.\n this._dragBounds = null\n this._dragStartScrollLeft = 0\n this._pendingDragHeaderRect = null\n this._lastDragClientX = 0\n this._dragScrollV = 0 // px/frame edge auto-scroll velocity (0 = idle)\n this._dragScrollRAF = null\n this._dragCleanupTimer = null\n\n // Fill-handle drag edge auto-scroll (see _updateFillDragAutoScroll) — same\n // idle-when-0 convention as _dragScrollV/_dragScrollRAF above, just for\n // both axes since a fill can extend down OR right.\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragScrollRAF = null\n this._fillDragLastEvent = null\n\n // localSyncKey — the last `data` reference a version was actually saved\n // for. Initialized to the current (mount-time) value so an untouched grid\n // that's merely tab-switched away from doesn't spam a \"nothing happened\"\n // version on every hide — only a REAL edit (which replaces this array\n // reference, see _emitDataChange) makes the next hide event flush.\n this._lastFlushedDataRef = this.data\n },\n\n mounted() {\n this.updateVirtualScroll()\n this.$nextTick(this.syncHeaderViewportWidth)\n document.addEventListener('mousemove', this.onGlobalMouseMove)\n document.addEventListener('mouseup', this.onGlobalMouseUp)\n document.addEventListener('keydown', this.onGlobalKeyDown)\n document.addEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.addEventListener('resize', this.syncHeaderViewportWidth)\n window.addEventListener('scroll', this._onWindowScrollForDropdown, true)\n // Self-heals the \"mounted while display:none\" case (e.g. a v-show tab that isn't\n // active yet) — scrollBody.clientWidth reads 0 at mount, and nothing but a real\n // window resize would otherwise ever re-measure it. ResizeObserver also fires when\n // the container regains real dimensions (tab becomes visible), so headers/footer\n // (scrollColHeaderStyle) get correctly re-locked with no host code changes needed.\n // rAF-batched like onBodyScroll's _scrollRAF, to avoid thrashing during a live drag-resize.\n if (typeof ResizeObserver !== 'undefined' && this.$refs.scrollBody) {\n this._scrollBodyResizeObserver = new ResizeObserver(() => {\n if (this._roRAF) return\n this._roRAF = requestAnimationFrame(() => {\n this._roRAF = null\n this.syncHeaderViewportWidth()\n })\n })\n this._scrollBodyResizeObserver.observe(this.$refs.scrollBody)\n // Also observe the grid's own DOM parent — needed in BOTH widthMode\n // values to keep hostContainerWidth (relative column widths' reference\n // in both modes) fresh when the host's layout changes.\n if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)\n }\n this._loadLocalVersionsOnMount()\n // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).\n // 'visibilitychange'->hidden is the primary trigger: it fires for tab\n // switches, minimizing, AND reliably before actual tab close/navigation on\n // both desktop and mobile, while the page is still very likely alive —\n // giving the async worker-hash + IndexedDB write a real chance to finish.\n // 'pagehide' is a backstop for the cases visibilitychange misses; it's\n // idempotent with visibilitychange (same content -> same hash -> no-op\n // beyond a timestamp touch) so firing both is harmless.\n document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.addEventListener('pagehide', this._onPageHideForLocalSync)\n },\n\n updated() {\n // After Vue re-renders (e.g., virtual scroll adds new rows during drag),\n // apply missing transforms to newly rendered cells that don't have one yet.\n // The transform lands with transitions disabled (a fresh cell must appear\n // in place, not fly in from its untransformed position); the drag\n // transition is re-armed on the next frame.\n if (!this.colDrag.active || !this.$el) return\n const offsets = this._dragOffsets\n if (!offsets || !Object.keys(offsets).length) return\n const fresh = []\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n if (el.style.transform) return\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transition = 'none'\n el.style.transform = `translateX(${dx}px)`\n fresh.push(el)\n })\n if (fresh.length) {\n requestAnimationFrame(() => {\n if (!this.colDrag.active) return\n fresh.forEach(el => { el.style.transition = COL_DRAG_TRANSITION })\n })\n }\n },\n\n beforeDestroy() {\n clearTimeout(this._filterRecomputeTimer)\n clearTimeout(this._onInputTimer)\n clearTimeout(this._dragCleanupTimer)\n clearTimeout(this._widthSyncChainResetTimer)\n clearTimeout(this._bufferWriteTimer)\n if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)\n if (this._fillDragScrollRAF) cancelAnimationFrame(this._fillDragScrollRAF)\n document.removeEventListener('mousemove', this.onGlobalMouseMove)\n document.removeEventListener('mouseup', this.onGlobalMouseUp)\n document.removeEventListener('keydown', this.onGlobalKeyDown)\n document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.removeEventListener('resize', this.syncHeaderViewportWidth)\n window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)\n document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.removeEventListener('pagehide', this._onPageHideForLocalSync)\n // Best-effort — the component may be torn down (SPA route change) without\n // the page itself unloading, which visibilitychange/pagehide won't catch.\n // sync: true — see _flushLocalVersionOnHide()'s comment; a component\n // being destroyed is exactly the same \"might not survive an async hop\"\n // situation as the other two triggers.\n this._flushLocalVersionOnHide(false, true)\n if (this.isFullscreen) {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (this._scrollBodyResizeObserver) { this._scrollBodyResizeObserver.disconnect(); this._scrollBodyResizeObserver = null }\n if (this._roRAF) { cancelAnimationFrame(this._roRAF); this._roRAF = null }\n },\n\n methods: {\n // =========================================================\n // VIRTUAL SCROLL\n // =========================================================\n // In fullscreen, .eg-scroll-body's real height comes from flexbox filling\n // the viewport (which can be far taller than the `height` prop the host\n // originally passed for the fixed-size layout) — measure it directly\n // instead of trusting `height`, otherwise virtual scroll only ever\n // renders enough rows for the old fixed size, leaving the rest of the\n // (visually stretched) container blank.\n _containerH() {\n if (this.isFullscreen && this.$refs.scrollBody) return this.$refs.scrollBody.clientHeight\n const footerH = this.hasFooter ? this._footerRowH : 0\n return this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - footerH\n },\n\n // Same fullscreen-aware measurement as _containerH(), but mirrors PageUp/\n // PageDown's pre-existing (footer-agnostic) row-span formula exactly for\n // the non-fullscreen case, so normal-mode behavior is unchanged.\n _pageRowSpan() {\n const h = this.isFullscreen && this.$refs.scrollBody\n ? this.$refs.scrollBody.clientHeight\n : (this.effectiveHeight - this._headerRowH * this.headerDepth - this._filterRowH)\n return Math.floor(h / this._rowH)\n },\n\n updateVirtualScroll() {\n // Use binary search against the Vue-cached cumulativeHeights → O(log n) per scroll.\n // overscan=8 (≈224px at default row height) gives enough buffer for fast scroll\n // without blank-band artifacts between rAF updates.\n const result = findVisibleRange(\n this.scrollTop, Math.max(this._containerH(), 100), this.totalRows, this.cumulativeHeights, 8\n )\n this.virtualStartIndex = result.startIndex\n this.virtualEndIndex = result.endIndex\n this.virtualOffsetTop = result.offsetTop\n this.totalVirtualHeight = result.totalHeight\n },\n\n onBodyScroll(e) {\n const target = e.target\n // Sync sibling scroll containers immediately (visual alignment, no layout trigger)\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = target.scrollLeft\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = target.scrollLeft\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = target.scrollTop\n\n // Close dropdown on scroll — fixed-position dropdown drifts away from the moving cell\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n\n // Throttle reactive state updates + virtual scroll recalc to one rAF per frame.\n // Read scrollTop from the DOM ref at rAF time (not from the stale event closure)\n // so that multiple scroll events arriving between frames still resolve to the\n // final position — eliminates the flickering caused by render/scroll mismatch.\n if (!this._scrollRAF) {\n this._scrollRAF = requestAnimationFrame(() => {\n this._scrollRAF = null\n const sb = this.$refs.scrollBody\n if (!sb) return\n this.scrollTop = sb.scrollTop\n this.scrollLeft = sb.scrollLeft\n this.updateVirtualScroll()\n })\n }\n },\n\n onScroll(e) {},\n\n // .eg-scroll-col-header (overflow:hidden, synced via JS) has no native scrollbar, but\n // .eg-scroll-body (overflow:auto) does — its real vertical scrollbar eats into its\n // clientWidth. If both containers just grow via flex:1, the header ends up wider than\n // the body, so its max scrollLeft is smaller and syncing clamps it before the body\n // reaches its rightmost scroll position — column borders desync right at the scroll\n // end. Locking the header to the body's exact measured clientWidth (scrollColHeaderStyle)\n // makes both max scrollLeft values identical.\n syncHeaderViewportWidth() {\n // Auto-height's 70vh cap tracks the real viewport — keep it current on resize.\n if (typeof window !== 'undefined' && this.windowInnerH !== window.innerHeight) {\n this.windowInnerH = window.innerHeight\n }\n const el = this.$refs.scrollBody\n if (!el) return\n // headerViewportWidth is still tracked in both modes (scrollColHeaderStyle/\n // virtual scroll need the grid's OWN viewport width) — but no longer\n // feeds relative column-width resolution at all (see\n // _relativeWidthContainer/hostContainerWidth's comments for why: it's\n // circular in 'auto' and independently scrollbar-toggle-unstable in\n // 'full'). _widthSyncChainOk() still guards the assignment itself,\n // since it's reactive (scrollColHeaderStyle reads it) — at fractional\n // page-zoom levels el.clientWidth can genuinely oscillate by ±1px\n // indefinitely (a vertical scrollbar toggling right at that subpixel\n // rounding boundary), which would otherwise re-render on every flip\n // forever even with nothing left downstream to actually resolve.\n if (this.headerViewportWidth !== el.clientWidth && this._widthSyncChainOk()) {\n this.headerViewportWidth = el.clientWidth\n }\n // Reference for relative (%, css) column widths, in BOTH widthMode\n // values — see hostContainerWidth's declaration comment. Measured from\n // the grid's DOM PARENT (not the grid itself, and not its scrollBody),\n // which is stable regardless of the grid's own rendered size or\n // internal scrollbar state — UNLESS the host's own layout also\n // auto-sizes around the grid (e.g. the parent shrink-wraps to its\n // content), which recreates a similar cycle outside our control.\n // _widthSyncChainOk() guards the assignment itself against that\n // residual case — the single choke point both a resized parent (this\n // block) and a resized shell (the naturalGridWidth watcher, via a\n // fresh call to this method, 'auto' mode only) funnel through.\n if (this.$el && this.$el.parentElement) {\n const pw = this.$el.parentElement.clientWidth\n if (pw !== this.hostContainerWidth && this._widthSyncChainOk()) {\n this.hostContainerWidth = pw\n if (this.fitColumns) this._applyFitColumns()\n else this._resolveRelativeColWidths()\n }\n }\n const sbw = el.offsetWidth - el.clientWidth\n if (sbw >= 0 && sbw !== this.vScrollbarW) this.vScrollbarW = sbw\n // Shell border total (see naturalGridWidth) — offsetWidth-clientWidth\n // is exactly the horizontal borders here (the shell itself never scrolls).\n const shell = this.$el\n if (shell && shell.offsetWidth) {\n const bw = shell.offsetWidth - shell.clientWidth\n if (bw >= 0 && bw !== this.shellBorderW) this.shellBorderW = bw\n }\n // Window resize while fullscreen changes the real available row-viewport\n // height too (see _containerH()) — recompute which rows are rendered.\n // Same for auto-height (height prop omitted): the 70vh cap just moved.\n if (this.isFullscreen || this.height == null) this.updateVirtualScroll()\n },\n\n // Runaway width-measurement guard — used in BOTH widthMode values.\n // In 'auto' mode, hostContainerWidth re-resolving colWidths is DESIGNED\n // to settle in 1-2 calls, but a host layout that also auto-sizes AROUND\n // the grid can recreate a circular dependency outside our control. In\n // 'full' mode there's no such circularity by construction, but\n // el.clientWidth is still a real browser measurement, and at fractional\n // page-zoom levels it can genuinely oscillate by ±1px indefinitely\n // (scrollbar toggling right at the rounding boundary, or ResizeObserver's\n // own well-known \"callback reads a size that changes again\" footgun).\n // Either way the symptom is the same: syncHeaderViewportWidth keeps\n // getting re-triggered forever. Caps consecutive calls instead of\n // chasing forever; 400ms of quiet resets the count, so a legitimately\n // slow (but finite) settle is never penalized — only a genuinely\n // runaway chain trips it.\n _widthSyncChainOk() {\n this._widthSyncChainCount = (this._widthSyncChainCount || 0) + 1\n clearTimeout(this._widthSyncChainResetTimer)\n this._widthSyncChainResetTimer = setTimeout(() => { this._widthSyncChainCount = 0 }, 400)\n if (this._widthSyncChainCount > 30) {\n if (!this._widthSyncChainWarned) {\n this._widthSyncChainWarned = true\n // eslint-disable-next-line no-console\n console.warn('[vue2-excel-grid] widthMode=\"' + this.widthMode + '\": column width measurement did not stabilize after 30 consecutive re-measures (possibly fractional browser zoom, or a host layout that also auto-sizes around the grid) — freezing at the last measured size instead of re-rendering forever.')\n }\n return false // stop chasing; keep the last measured/resolved widths instead of looping forever\n }\n return true\n },\n\n // =========================================================\n // HELPERS\n // =========================================================\n isRowHidden(ri) { return this.hiddenRowSet.has(ri) },\n isColHidden(ci) { return this.effectiveHiddenColSet.has(ci) },\n\n getColW(ci) {\n return this.colWidths[ci] !== undefined ? this.colWidths[ci] : (this.leafCols[ci] ? this.resolveColWidth(this.leafCols[ci].width, this._relativeWidthContainer) : DEFAULT_COL_WIDTH)\n },\n\n // Turns a col.width spec (number, 'Npx', 'N%', or any other CSS length like\n // 'Nem'/'calc(...)') into a concrete px number. Percent is resolved against\n // containerWidth (see _relativeWidthContainer — always hostContainerWidth,\n // in both widthMode values); other CSS units are resolved via a hidden\n // DOM probe (_measureCssWidth).\n resolveColWidth(spec, containerWidth) {\n const parsed = parseColWidthSpec(spec)\n if (parsed.type === 'px') return Math.max(20, parsed.value)\n if (parsed.type === 'percent') {\n if (!containerWidth) return DEFAULT_COL_WIDTH\n return Math.max(20, (parsed.value / 100) * containerWidth)\n }\n const measured = this._measureCssWidth(parsed.value)\n return Math.max(20, measured || DEFAULT_COL_WIDTH)\n },\n\n // Measures an arbitrary CSS width string (em/rem/vw/calc/...) by applying it\n // to a hidden probe element, so it inherits ambient font-size for em/rem.\n // Parented in the grid's DOM PARENT (not the grid root or its scrollBody)\n // in both widthMode values, matching _relativeWidthContainer — a\n // %/calc(%...) value would otherwise resolve against the grid's own\n // rendered size, which is either circular ('auto') or independently\n // scrollbar-toggle-unstable ('full'). Falls back to the grid root if\n // there's no parent (e.g. a detached/test mount). Returns null before\n // mount (no DOM yet).\n _measureCssWidth(cssValue) {\n if (typeof document === 'undefined' || !this.$el || !this.$el.appendChild) return null\n const parent = this.$el.parentElement || this.$el\n if (!this._widthProbeEl || this._widthProbeEl.parentNode !== parent) {\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n const el = document.createElement('div')\n el.style.position = 'absolute'\n el.style.visibility = 'hidden'\n el.style.height = '0'\n el.style.overflow = 'hidden'\n el.style.pointerEvents = 'none'\n parent.appendChild(el)\n this._widthProbeEl = el\n }\n this._widthProbeEl.style.width = cssValue\n return this._widthProbeEl.offsetWidth\n },\n\n // Re-resolves every column whose width spec is relative (%, em, vw, ...)\n // against the current _relativeWidthContainer — called whenever that\n // reference width changes (initial mount + window resize), so\n // percent/relative columns stay responsive like real CSS, matching a\n // plain numeric/px width's stability.\n _resolveRelativeColWidths() {\n const specs = this.relativeColSpecs\n const keys = Object.keys(specs)\n if (!keys.length) return\n keys.forEach(ci => {\n this.$set(this.colWidths, ci, this.resolveColWidth(specs[ci], this._relativeWidthContainer))\n })\n this._cellStyleCache = Object.create(null)\n },\n\n getRowH(ri) {\n if (this.isRowHidden(ri)) return 0\n return this.rowHeights[ri] || this._rowH\n },\n\n getColLeft(ci) {\n let left = 0\n for (let i = 0; i < ci && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) left += this.getColW(i)\n }\n return left\n },\n\n getFrozenWidth() {\n return this.hasPinnedLeft ? this.pinnedLeftWidth : 0\n },\n\n // Scroll-position-aware pinned-column edge shadow (mirrors Ant Design/AG Grid's own\n // convention): the shadow only appears while there's actually scrolled-away content\n // hiding under the pinned zone on that side, not as a permanent decoration — a static\n // shadow reads as background noise and the user stops noticing it exactly when it\n // matters (mid-scroll). Read scrollLeft (already tracked reactively, updated every\n // rAF tick in onBodyScroll) so template :class bindings re-evaluate on every scroll frame.\n hasHiddenScrollLeft() {\n return this.getFrozenWidth() > 0 && this.scrollLeft > 0\n },\n hasHiddenScrollRight() {\n if (!this.hasPinnedRight) return false\n const sb = this.$refs.scrollBody\n if (!sb) return false\n return this.scrollLeft < sb.scrollWidth - sb.clientWidth - 1\n },\n\n isColumnPinnedLeft(ci) {\n return this.pinnedLeftSet.has(ci)\n },\n\n isColumnPinnedRight(ci) {\n return this.pinnedRightSet.has(ci)\n },\n\n isColumnScrollable(ci) {\n return !this.isColumnPinnedLeft(ci) && !this.isColumnPinnedRight(ci)\n },\n\n getZoneColLeft(ci, zone) {\n let left = 0\n if (zone === 'left') {\n for (const col of this.pinnedLeftCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else if (zone === 'right') {\n for (const col of this.pinnedRightCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else {\n for (let i = 0; i < this.leafCols.length; i++) {\n const col = this.leafCols[i]\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex) && this.isColumnScrollable(col._flatIndex)) {\n left += this.getColW(col._flatIndex)\n }\n }\n }\n return left\n },\n\n isCellInZone(cell, rowIdx, zone) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n const z = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n return z === zone\n },\n\n getHeaderCellStyleForZone(cell, rowIndex, zone) {\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z !== zone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getRangeWidth(startCol, endCol) {\n let w = 0\n for (let i = startCol; i <= endCol && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) w += this.getColW(i)\n }\n return w\n },\n\n getRangeHeight(startRow, endRow) {\n let h = 0\n for (let i = startRow; i <= endRow && i < this.totalRows; i++) {\n if (!this.isRowHidden(i)) h += this.getRowH(i)\n }\n return h\n },\n\n getCellValue(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return ''\n const row = this._dataRaw[ri]\n if (!row) return ''\n const v = row[col.key]\n return v !== undefined ? v : ''\n },\n\n isEditing(ri, ci) {\n return this.editingCell && this.editingCell.row === ri && this.editingCell.col === ci\n },\n\n isMergedAway(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return false\n const nm = normalizeRange(merge)\n return !(nm.startRow === ri && nm.startCol === ci)\n },\n\n getMergeSpan(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return { rowspan: 1, colspan: 1 }\n const nm = normalizeRange(merge)\n return {\n rowspan: nm.endRow - nm.startRow + 1,\n colspan: nm.endCol - nm.startCol + 1\n }\n },\n\n isSelected(ri, ci) {\n return isCellInAnyRange(ri, ci, this.selectionRanges)\n },\n\n isActiveCell(ri, ci) {\n return this.activeCell.row === ri && this.activeCell.col === ci\n },\n\n isCopyHighlighted(ri, ci) {\n if (!this.clipboardRange || this.clipboardMode !== 'copy') return false\n return isCellInRange(ri, ci, this.clipboardRange)\n },\n\n // =========================================================\n // STYLES\n // =========================================================\n getRowStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowStyleCache[key]) return this._rowStyleCache[key]\n const style = { height: h + 'px', minHeight: h + 'px' }\n this._rowStyleCache[key] = style\n return style\n },\n\n getRowHeaderStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowHeaderStyleCache[key]) return this._rowHeaderStyleCache[key]\n const style = { height: h + 'px' }\n this._rowHeaderStyleCache[key] = style\n return style\n },\n\n getCellStyle(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n const nm = merge ? normalizeRange(merge) : null\n let w = this.getColW(ci)\n let h = this.getRowH(ri)\n\n if (nm && nm.startRow === ri && nm.startCol === ci) {\n w = this.getRangeWidth(nm.startCol, nm.endCol)\n h = this.getRangeHeight(nm.startRow, nm.endRow)\n }\n\n // Position cells with the same absolute-left arithmetic the header uses\n // (getZoneColLeft), instead of relying on flex layout to accumulate widths —\n // flex and a JS integer sum can drift apart by a few px over many columns,\n // desyncing the body's column borders from the header's.\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n\n // Cache by dimensions+position — same (w,h,left) → same object ref → Vue 2 skips DOM style writes\n const key = w + '_' + h + '_' + left\n if (this._cellStyleCache[key]) return this._cellStyleCache[key]\n const style = { position: 'absolute', left: left + 'px', top: '0px', width: w + 'px', minWidth: w + 'px', height: h + 'px' }\n this._cellStyleCache[key] = style\n return style\n },\n\n // rowStyle prop — deliberately NOT merged into getCellStyle()'s return value:\n // that object is cached/shared by (w,h,left) across unrelated rows/cells (see\n // comment above), so mutating it here would leak one row's style onto every\n // other cell that happens to share the same cache key. Kept as a second,\n // uncached :style array entry instead (see template) — cheap, and correctly\n // reactive to the row's actual data instead of just its geometry.\n getRowCustomStyle(ri) {\n if (!this.rowStyle) return null\n return this.rowStyle(this._dataRaw[ri], ri) || null\n },\n\n getCellClasses(ri, ci) {\n const col = this.leafCols[ci]\n const active = this.isActiveCell(ri, ci)\n // Non-active selected cell: border only on edges NOT shared with another\n // selected neighbor, so a multi-cell range gets one continuous outline\n // around its outer perimeter (Excel-style) instead of each cell only\n // getting a flat background tint. Works for disjoint multi-range\n // selections too (e.g. ctrl+click rows) since it's a pure neighbor check.\n const rangeSelected = this.isSelected(ri, ci) && !active\n return {\n 'eg-cell-active': active,\n 'eg-cell-selected': rangeSelected,\n 'eg-cell-range-top': rangeSelected && !this.isSelected(ri - 1, ci),\n 'eg-cell-range-bottom': rangeSelected && !this.isSelected(ri + 1, ci),\n 'eg-cell-range-left': rangeSelected && !this.isSelected(ri, ci - 1),\n 'eg-cell-range-right': rangeSelected && !this.isSelected(ri, ci + 1),\n 'eg-cell-fill-preview': this.isInFillPreview(ri, ci),\n 'eg-cell-copy': this.isCopyHighlighted(ri, ci),\n 'eg-cell-merged-hidden': this.isMergedAway(ri, ci),\n 'eg-cell-select': !!(col && this.isSelectLikeCol(col)),\n 'eg-cell-date': !!(col && col.type === 'date'),\n 'eg-cell-checkbox': !!(col && col.type === 'checkbox'),\n 'eg-cell-action': !!(col && col.type === 'action'),\n 'eg-cell-readonly': !this.isCellEditable(ri, ci),\n 'eg-cell-invalid': !!(this.invalidCell && this.invalidCell.row === ri && this.invalidCell.col === ci),\n 'eg-cell--has-suffix': !!(col && this.hasSuffixSlot(col)),\n 'eg-cell--drag-source': this.colDrag.active && ci >= this.colDrag.sourceFirstLeaf && ci < this.colDrag.sourceFirstLeaf + this.colDrag.sourceLeafCount,\n 'eg-cell-align-right': !!(col && col.align === 'right'),\n 'eg-cell-align-center': !!(col && col.align === 'center')\n }\n },\n\n isInFillPreview(ri, ci) {\n if (!this.fillDrag.active || !this.fillDrag.sourceRange) return false\n const src = this.fillDrag.sourceRange\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n if (targetRow > src.endRow) {\n return ri > src.endRow && ri <= targetRow && ci >= src.startCol && ci <= src.endCol\n }\n if (targetCol > src.endCol) {\n return ci > src.endCol && ci <= targetCol && ri >= src.startRow && ri <= src.endRow\n }\n return false\n },\n\n getHeaderCellStyle(cell, rowIndex, isFrozen) {\n const zone = isFrozen ? 'left' : 'scroll'\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n if (isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (!inZone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getHeaderDragTransform(cell) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking, no re-render on offset change\n let key\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n } else {\n key = cell.column.key\n }\n if (!key) return null\n const dx = this._dragOffsets[key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n getCellDragTransform(ci) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking\n const col = this.leafCols[ci]\n if (!col) return null\n const dx = this._dragOffsets[col.key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n // =========================================================\n // FOOTER (column totals)\n // =========================================================\n\n // col.totalConfig.visible (boolean) takes priority over col.showTotal when present.\n isColumnTotalVisible(col) {\n if (col.totalConfig && typeof col.totalConfig.visible === 'boolean') return col.totalConfig.visible\n return !!col.showTotal\n },\n\n // Columns with a visible total are implicitly numeric — computeColumnTotals()\n // only sums typeof === 'number' cells, so a value written as a string (every\n // edit path hands over a string: the built-in text <input>, pasted TSV text,\n // fill-handle/drag-move source values) would silently drop out of the sum\n // with no error. Coerce user-entered values that fully parse as a number into\n // an actual Number for these columns; anything else (blank, non-numeric text)\n // passes through unchanged so it still fails validation/renders as typed.\n _coerceCellValue(ci, value) {\n if (typeof value !== 'string') return value\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return value\n const trimmed = value.trim()\n if (trimmed === '' || isNaN(Number(trimmed))) return value\n return Number(trimmed)\n },\n\n // Default value for a column on a row auto-created by pasteGrowsRows.\n _blankValueForCol(col) {\n return col.type === 'checkbox' ? false : ''\n },\n\n // One `Mask` instance per masked flat column index, reused across calls\n // (maska memoizes internally too, but this avoids re-parsing col.mask on\n // every keystroke/paste/fill). Invalidated whenever columns changes (see\n // the `columns` watcher, which resets `_maskCache = {}`).\n _getColMask(ci) {\n if (!this._maskCache) this._maskCache = {}\n if (this._maskCache[ci] !== undefined) return this._maskCache[ci]\n const col = this.leafCols[ci]\n const spec = col && col.mask\n let mask = null\n if (spec) {\n const options = (typeof spec === 'string' || Array.isArray(spec)) ? { mask: spec } : spec\n mask = new Mask(options)\n }\n this._maskCache[ci] = mask\n return mask\n },\n\n // Reformats a value through col.mask, if the column has one. Used at every\n // path that writes a value without going through the live MaskInput-driven\n // editor input (commit of a custom edit slot, paste, fill handle/double-fill)\n // — the live-typing case is handled by MaskInput itself (see startEditing).\n // Idempotent: masking an already-masked value returns it unchanged.\n _applyColMask(ci, value) {\n const mask = this._getColMask(ci)\n if (!mask || value === '' || value === null || value === undefined) return value\n if (typeof value !== 'string' && typeof value !== 'number') return value\n return mask.masked(value)\n },\n\n computeColumnTotals() {\n const totals = {}\n const rows = this._dataRaw || []\n this.leafCols.forEach(col => {\n if (!this.isColumnTotalVisible(col)) return\n let sum = 0\n let hasNumber = false\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n const v = row ? row[col.key] : undefined\n if (typeof v === 'number' && isFinite(v)) {\n sum += v\n hasNumber = true\n }\n }\n totals[col._flatIndex] = hasNumber ? Math.round(sum * 1e6) / 1e6 : null\n })\n this.columnTotals = totals\n },\n\n getColumnTotalDisplay(ci) {\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return ''\n const raw = this.columnTotals[ci]\n const cfg = col.totalConfig\n if (cfg && typeof cfg.formatter === 'function') {\n return cfg.formatter(raw, this._dataRaw, col, ci)\n }\n return raw === null || raw === undefined ? '-' : raw\n },\n\n getFooterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n const style = {\n position: 'absolute',\n left: left + 'px',\n top: '0px',\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px'\n }\n const col = this.leafCols[ci]\n const extra = col && col.totalConfig && col.totalConfig.style\n return extra ? { ...style, ...extra } : style\n },\n\n getFooterCellClass(ci) {\n const col = this.leafCols[ci]\n if (!col) return null\n const classList = col.totalConfig && col.totalConfig.classList\n const base = this.isColumnTotalVisible(col) ? 'eg-footer-cell-total' : null\n if (!base) return classList || null\n return classList ? [base, classList] : base\n },\n\n isCellVisible(cell, rowIndex, isFrozen) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (inZone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n return isFrozen ? this.isColumnPinnedLeft(ci) : this.isColumnScrollable(ci)\n },\n\n getRowGroupBtnStyle(group) {\n const top = getRowTop(group.startIndex, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD) - this.scrollTop\n return {\n position: 'absolute',\n top: top + 'px',\n left: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px',\n zIndex: 5\n }\n },\n\n getColGroupBtnStyle(group) {\n const left = this.getColLeft(group.startIndex) - this.scrollLeft\n return {\n position: 'absolute',\n left: left + 'px',\n top: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px'\n }\n },\n\n // =========================================================\n // CELL SELECTION (3.1)\n // =========================================================\n selectCell(row, col, addToSelection = false) {\n if (row < 0 || row >= this.totalRows || col < 0 || col >= this.leafCols.length) return\n this.activeCell = { row, col }\n if (addToSelection) {\n this.selectionRanges.push(singleCellRange(row, col))\n } else {\n this.selectionRanges = [singleCellRange(row, col)]\n }\n this.emitSelectionChange()\n },\n\n selectRange(startRow, startCol, endRow, endCol, replace = true) {\n const range = { startRow, startCol, endRow, endCol }\n if (replace) {\n this.selectionRanges = [range]\n } else {\n this.selectionRanges.push(range)\n }\n this.emitSelectionChange()\n },\n\n selectRow(ri) {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [{ startRow: ri, endRow: ri, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n selectColumn(ci) {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n this.emitSelectionChange()\n },\n\n selectAll() {\n this.activeCell = { row: 0, col: 0 }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n emitSelectionChange() {\n this.$emit('selection-change', {\n activeCell: { ...this.activeCell },\n ranges: this.selectionRanges.map(r => normalizeRange(r))\n })\n },\n\n onCellMouseDown(e, ri, ci) {\n this.invalidCell = null\n if (this.editingCell) {\n const editCol = this.leafCols[this.editingCell.col]\n if (editCol && this._hasCustomEdit(editCol)) {\n // Custom cell/edit slots commit via their own @blur/@done handler\n // (see commitEditSlot()), and the browser fires that native blur\n // *after* this mousedown listener returns (blur is the default\n // action of the focus shift, which runs post-dispatch). Cancelling\n // here immediately would null out editingCell before the slot's\n // blur handler gets to call done(), silently discarding the typed\n // value. Defer the cleanup instead, and only run it if the slot's\n // own commit hasn't already resolved editingCell by then.\n const staleEditingCell = this.editingCell\n this.$nextTick(() => {\n if (this.editingCell === staleEditingCell) this.cancelEdit()\n })\n } else {\n this.commitEdit()\n }\n }\n this.hideContextMenu()\n this.$refs.wrapper && this.$refs.wrapper.focus()\n\n if (e.ctrlKey || e.metaKey) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Ctrl+click: start new independent range\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges.push(this.mouseDownRange)\n this.isCtrlDrag = true\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Shift+click: extend primary range to this cell\n this.selectionRanges = [buildRange(this.activeCell, { row: ri, col: ci })]\n } else {\n const wasActive = this.activeCell.row === ri && this.activeCell.col === ci\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges = [this.mouseDownRange]\n this.isCtrlDrag = false\n\n // Open select/date dropdown on click if cell was already active (2nd click)\n const col = this.leafCols[ci]\n if (wasActive && col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openSelectDropdown(ri, ci, e)\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (wasActive && col && col.type === 'date' && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openDatePickerForActive()\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n }\n\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onCellMouseOver(e, ri, ci) {\n if (!this.isMouseDown) return\n if (this.fillDrag.active) return\n if (this.dragMove.active) {\n this.dragMove.targetRow = ri\n this.dragMove.targetCol = ci\n return\n }\n\n // Extend current drag range\n if (this.mouseDownRange) {\n this.mouseDownRange.endRow = ri\n this.mouseDownRange.endCol = ci\n this.emitSelectionChange()\n }\n },\n\n onCellMouseUp() {\n this.isMouseDown = false\n },\n\n onCellDblClick(ri, ci) {\n const col = this.leafCols[ci]\n if (!this.isCellEditable(ri, ci)) return\n if (col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col)) {\n this.openSelectDropdownForActive()\n return\n }\n if (col && col.type === 'date' && !this._hasCustomEdit(col)) {\n this.openDatePickerForActive()\n return\n }\n if (col && col.type === 'checkbox') return\n if (col && col.type === 'action') return\n this.startEditing(ri, ci, null, true)\n },\n\n onRowHeaderMouseDown(e, ri) {\n const endCol = this.leafCols.length - 1\n const fullRow = { startRow: ri, endRow: ri, startCol: 0, endCol }\n\n if (e.ctrlKey || e.metaKey) {\n if (e.shiftKey && this.selectionRanges.length > 0) {\n // Ctrl+Shift: active qatordan ri gacha yangi range qo'sh\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [...this.selectionRanges, { startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n // Ctrl+click: qatorni toggle qil\n const idx = this.selectionRanges.findIndex(r =>\n r.startRow === ri && r.endRow === ri && r.startCol === 0 && r.endCol === endCol\n )\n if (idx >= 0) {\n const next = this.selectionRanges.filter((_, i) => i !== idx)\n this.selectionRanges = next.length ? next : [fullRow]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [...this.selectionRanges, fullRow]\n }\n }\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [{ startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [fullRow]\n }\n\n this.mouseDownRange = this.selectionRanges[this.selectionRanges.length - 1]\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderMouseDown(e, cell) {\n // Start pending drag (activates after 5 px horizontal movement)\n // Works for both leaf and group headers\n if (this.columnDraggable && !this.internalLocked && !e.shiftKey && !e.ctrlKey && !e.metaKey) {\n this.colDrag.pending = true\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = cell._firstLeafFlatIndex\n this.colDrag.sourceLeafCount = cell.isGroup ? cell.colspan : 1\n this.colDrag.startX = e.clientX\n this.colDrag.startY = e.clientY\n this.colDrag.ghostLabel = cell.column.title || ''\n // Grabbed header's rect sizes the ghost and anchors the grab point if\n // the 5px threshold later activates the drag (see _startColDrag).\n this._pendingDragHeaderRect = e.currentTarget ? e.currentTarget.getBoundingClientRect() : null\n }\n\n if (cell.isGroup) {\n // Group header click: select all leaf columns within the group\n const startCol = cell._firstLeafFlatIndex\n const endCol = startCol + cell.colspan - 1\n this.activeCell = { row: 0, col: startCol }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol, endCol }]\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n return\n }\n\n const ci = cell.column._flatIndex\n if (e.shiftKey && this.selectionRanges.length > 0) {\n const sc = Math.min(this.activeCell.col, ci)\n const ec = Math.max(this.activeCell.col, ci)\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: sc, endCol: ec }]\n } else {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n }\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderDblClick(e, cell) {\n if (cell.isGroup) return\n this.autoFitColumn(cell.column._flatIndex)\n },\n\n onColHeaderContextMenu(e, cell) {\n if (!cell.isGroup) {\n this._openContextMenu(e, -1, cell.column._flatIndex)\n }\n },\n\n onRowContextMenu(e, ri) {\n this._openContextMenu(e, ri, -1)\n },\n\n onCellContextMenu(e, ri, ci) {\n if (!this.isSelected(ri, ci)) {\n this.selectCell(ri, ci)\n }\n this._openContextMenu(e, ri, ci)\n },\n\n _openContextMenu(e, row, col) {\n this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row, col }\n this.contextMenuPositioned = false\n this.$nextTick(this._positionContextMenu)\n },\n\n // Flips the menu up/left if it would overflow the viewport at the raw\n // click coordinates — measured against the actual rendered size (item\n // count varies with devMode/pin/row-vs-col context, so an estimate isn't\n // reliable). Rendered off-screen (left:-9999px, see template) while\n // unpositioned so this first measurement reflects the menu's true\n // natural (single-line) width — measuring it in place near a viewport\n // edge would let the browser's own shrink-to-fit sizing wrap the text\n // to fit the cramped space, which then \"sticks\" even after flipping to\n // a roomier position since we only move it, we don't force a width.\n // Stays hidden (visibility, not v-if) until repositioned so there's no\n // visible jump.\n _positionContextMenu() {\n const el = this.$refs.contextMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.contextMenu.x\n let y = this.contextMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.contextMenu.x = x\n this.contextMenu.y = y\n this.contextMenuPositioned = true\n },\n\n hideContextMenu() {\n this.contextMenu.visible = false\n },\n\n // General click-anywhere-in-the-grid dismissal for both floating menus —\n // each menu item stops propagation (@click.stop) so a click *inside*\n // either menu never reaches this handler.\n hideFloatingMenus() {\n this.hideContextMenu()\n this.hideSortMenu()\n },\n\n // =========================================================\n // KEYBOARD NAVIGATION (3.2)\n // =========================================================\n onKeyDown(e) {\n // Delegate keyboard navigation to the active dropdown component\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n if (comp && comp.handleKey) comp.handleKey(e)\n return\n }\n\n if (this.infoModal) {\n if (e.key === 'Escape') this.closeInfoModal()\n return\n }\n\n if (this.editingCell) {\n // Normally handled by onEditKeyDown on the built-in <input> (which stops\n // propagation, so this branch never runs for it). Custom #cell-{key} edit\n // slots own their own input and focus it themselves — if that focus didn't\n // land (e.g. the host relies on the unreliable HTML `autofocus` attribute\n // on a freshly-inserted element), Escape falls through to here instead.\n // Without this, editingCell stays stuck and even arrow-key navigation is\n // swallowed by this same guard on every subsequent keydown.\n if (e.key === 'Escape') this.cancelEdit()\n return\n }\n const key = e.key\n // Layout-independent letter for the Ctrl/Cmd shortcuts below (z/y/a/c/x/v/d/r).\n // e.key reports the character a layout PRODUCES — on a non-Latin layout\n // (Cyrillic, etc.) the physical C key can report e.key: 'с' (Cyrillic es)\n // even with Ctrl held and the physical key/position unchanged, silently\n // breaking `key === 'c'`. e.code is the physical key position and is\n // immune to layout, so it's used here instead wherever available;\n // falls back to e.key when e.code is missing (e.g. synthetic events in\n // tests that don't set it).\n const shortcutKey = (e.code && e.code.length === 4 && e.code.slice(0, 3) === 'Key')\n ? e.code.charAt(3).toLowerCase()\n : key\n\n if ((e.ctrlKey || e.metaKey) && shortcutKey === 'z' && !e.shiftKey) {\n e.preventDefault()\n this.undo()\n return\n }\n if ((e.ctrlKey || e.metaKey) && (shortcutKey === 'y' || (shortcutKey === 'z' && e.shiftKey))) {\n e.preventDefault()\n this.redo()\n return\n }\n\n if (shortcutKey === 'a' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.selectAll()\n return\n }\n\n if (shortcutKey === 'c' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.copySelection()\n return\n }\n if (shortcutKey === 'x' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.cutSelection()\n return\n }\n if (shortcutKey === 'v' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.pasteAt(this.activeCell.row, this.activeCell.col)\n return\n }\n\n if (shortcutKey === 'd' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillDown()\n return\n }\n if (shortcutKey === 'r' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillRight()\n return\n }\n\n if (key === 'Delete' || key === 'Backspace') {\n e.preventDefault()\n this.clearSelection()\n return\n }\n\n if (key === 'F2') {\n e.preventDefault()\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openSelectDropdownForActive()\n } else if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openDatePickerForActive()\n } else if (activeCol && activeCol.type === 'checkbox' && activeEditable) {\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n } else if (activeCol && activeCol.type === 'action' && activeEditable) {\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n } else if (activeEditable) {\n this.startEditing(this.activeCell.row, this.activeCell.col, null, true)\n }\n return\n }\n\n if (key === ' ') {\n const activeCol = this.leafCols[this.activeCell.col]\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n return\n }\n }\n\n if (key === 'Escape') {\n this.clipboardRange = null\n this.clipboardMatrix = null\n this.clipboardMode = null\n return\n }\n\n if (key === 'Tab') {\n e.preventDefault()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n return\n }\n if (key === 'Enter') {\n e.preventDefault()\n this.moveActive(e.shiftKey ? -1 : 1, 0, e)\n return\n }\n\n const arrows = { ArrowUp: [-1,0], ArrowDown: [1,0], ArrowLeft: [0,-1], ArrowRight: [0,1] }\n if (arrows[key]) {\n e.preventDefault()\n const [dr, dc] = arrows[key]\n if (e.shiftKey) {\n this.extendSelection(dr, dc)\n } else {\n this.moveActive(dr, dc, e)\n }\n return\n }\n\n if (key === 'Home') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(0, 0)\n } else {\n this.selectCell(this.activeCell.row, 0)\n }\n return\n }\n if (key === 'End') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(this.totalRows - 1, this.leafCols.length - 1)\n } else {\n this.selectCell(this.activeCell.row, this.leafCols.length - 1)\n }\n return\n }\n if (key === 'PageUp') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(-this._pageRowSpan(), 0)\n } else {\n this.moveActive(-this._pageRowSpan(), 0, e)\n }\n return\n }\n if (key === 'PageDown') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(this._pageRowSpan(), 0)\n } else {\n this.moveActive(this._pageRowSpan(), 0, e)\n }\n return\n }\n },\n\n onKeyPress(e) {\n if (this.editingCell) return\n if (this.selectDropdown) return\n if (this.datePickerOverlay) return\n if (e.ctrlKey || e.metaKey || e.altKey) return\n if (e.key && e.key.length === 1) {\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openSelectDropdownForActive()\n return\n }\n if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openDatePickerForActive()\n return\n }\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n return\n }\n // Without this, the browser's own native \"insert this character\" action\n // (queued for whatever element ends up focused) still fires after\n // startEditing()'s $nextTick focuses the freshly-created edit <input> —\n // which is already seeded with this same character via editValue — so\n // the char gets inserted a second time (e.g. \"1\" -> \"11\").\n e.preventDefault()\n this.startEditing(this.activeCell.row, this.activeCell.col, e.key, false)\n }\n },\n\n onGlobalKeyDown(e) {\n if (e.key === 'Escape' && (this.colDrag.active || this.colDrag.pending)) {\n this._resetColDrag(true)\n e.preventDefault()\n }\n },\n\n // Shift+Arrow / Shift+PageUp/PageDown: extend or shrink the selection\n // relative to the FIXED anchor (activeCell) — not just always growing in\n // the pressed direction. The \"far\" corner is whichever end of the current\n // (normalized) range isn't the anchor; per axis, _extendAxis grows it\n // further out while it's already on the pressed side, or shrinks it back\n // toward the anchor (never past it in one step) while it's on the\n // opposite side — matching Excel/Sheets. Only the axis(es) with a\n // non-zero delta are touched, so a pure vertical extend preserves the\n // current column range untouched, and vice versa.\n extendSelection(dr, dc) {\n const anchor = this.activeCell\n const pr = this.primaryRange || singleCellRange(anchor.row, anchor.col)\n const nm = normalizeRange(pr)\n let farRow = nm.startRow === anchor.row ? nm.endRow : nm.startRow\n let farCol = nm.startCol === anchor.col ? nm.endCol : nm.startCol\n if (dr !== 0) farRow = this._extendAxis(anchor.row, farRow, dr, this.totalRows - 1)\n if (dc !== 0) farCol = this._extendAxis(anchor.col, farCol, dc, this.leafCols.length - 1)\n this.selectionRanges = [buildRange(anchor, { row: farRow, col: farCol })]\n this.emitSelectionChange()\n // Unlike moveActive(), this doesn't move the active cell — but the newly\n // extended (far) corner of the range must still be scrolled into view,\n // exactly like moveActive() does for the active cell itself.\n this.scrollToCell(farRow, farCol)\n },\n\n _extendAxis(anchorIdx, farIdx, delta, maxIdx) {\n if (delta > 0) {\n return farIdx >= anchorIdx ? Math.min(maxIdx, farIdx + delta) : Math.min(anchorIdx, farIdx + delta)\n }\n return farIdx <= anchorIdx ? Math.max(0, farIdx + delta) : Math.max(anchorIdx, farIdx + delta)\n },\n\n moveActive(dr, dc, e) {\n let row = this.activeCell.row + dr\n let col = this.activeCell.col + dc\n\n // Tab: wrap around rows\n if (e && e.key === 'Tab') {\n if (col >= this.leafCols.length) { col = 0; row++ }\n if (col < 0) { col = this.leafCols.length - 1; row-- }\n }\n\n row = Math.max(0, Math.min(this.totalRows - 1, row))\n col = Math.max(0, Math.min(this.leafCols.length - 1, col))\n\n // Skip hidden cols\n while (this.isColHidden(col) && col > 0 && col < this.leafCols.length - 1) {\n col += dc >= 0 ? 1 : -1\n }\n // Skip hidden rows\n while (this.isRowHidden(row) && row > 0 && row < this.totalRows - 1) {\n row += dr >= 0 ? 1 : -1\n }\n\n // Handle merge navigation\n const resolved = resolveMergeAnchor(row, col, this.internalMerges)\n row = resolved.row\n col = resolved.col\n\n this.activeCell = { row, col }\n this.selectionRanges = [singleCellRange(row, col)]\n this.emitSelectionChange()\n this.scrollToCell(row, col)\n },\n\n scrollToCell(ri, ci) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return\n const rowTop = getRowTop(ri, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD)\n const rowBottom = rowTop + this.getRowH(ri)\n\n if (rowTop < scrollBody.scrollTop) scrollBody.scrollTop = rowTop\n if (rowBottom > scrollBody.scrollTop + scrollBody.clientHeight) {\n scrollBody.scrollTop = rowBottom - scrollBody.clientHeight\n }\n\n // Pinned columns are sticky — always horizontally in view, nothing to do.\n if (!this.isColumnScrollable(ci)) return\n const zoneLeft = this.getZoneColLeft(ci, 'scroll')\n const zoneRight = zoneLeft + this.getColW(ci)\n // A scrollable cell only counts as visible inside the window the sticky\n // pinned zones don't cover — without subtracting them, a cell could sit\n // fully underneath a pinned zone yet be treated as already in view.\n const visibleW = scrollBody.clientWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n if (zoneLeft < scrollBody.scrollLeft) scrollBody.scrollLeft = zoneLeft\n if (zoneRight > scrollBody.scrollLeft + visibleW) {\n scrollBody.scrollLeft = zoneRight - visibleW\n }\n },\n\n // =========================================================\n // EDITING (used by 3.1 + 3.2)\n // =========================================================\n startEditing(ri, ci, initialChar = null, keepValue = false) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (ri < 0 || ri >= this.totalRows) return\n if (!this._hasCustomEdit(col)) {\n if (this.isSelectLikeCol(col)) { this.openSelectDropdownForActive(); return }\n if (col.type === 'date') { this.openDatePickerForActive(); return }\n if (col.type === 'checkbox') return\n if (col.type === 'action') return\n }\n\n this.invalidCell = null\n this.editingCell = { row: ri, col: ci }\n if (initialChar !== null) {\n this.editValue = initialChar\n } else if (keepValue) {\n this.editValue = String(this.getCellValue(ri, ci) || '')\n } else {\n this.editValue = ''\n }\n this.editOriginalValue = String(this.getCellValue(ri, ci) || '')\n\n this.$nextTick(() => {\n const inputs = this.$refs.editInput\n if (inputs) {\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) {\n // col.mask: MaskInput owns the element's live formatting/caret while\n // typing and re-dispatches a corrected native 'input' event after\n // every keystroke, which the existing v-model=\"editValue\" binding\n // picks up normally — no separate sync code needed. It also masks\n // whatever value is already in the input (seeded above) on attach.\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (col.mask) {\n const options = (typeof col.mask === 'string' || Array.isArray(col.mask)) ? { mask: col.mask } : col.mask\n this._maskInput = new MaskInput(input, options)\n }\n input.focus()\n if (keepValue) {\n input.setSelectionRange(input.value.length, input.value.length)\n }\n }\n }\n })\n },\n\n commitEdit() {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n\n // editValue is already masked live by MaskInput (see startEditing) — this\n // is a defensive final pass, cheap/idempotent, and covers any commit that\n // skipped the live-typing path (e.g. programmatic editValue changes).\n const value = this._coerceCellValue(col, this._applyColMask(col, this.editValue))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n cancelEdit() {\n clearTimeout(this._onInputTimer)\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Fires while the user is actively typing in the plain-text edit input —\n // debounced (col.debounceMs, default 400ms) so col.onInput isn't called\n // on every keystroke. `row` is the pre-edit row (not yet committed);\n // `value` is the live in-progress text.\n onEditInput(e) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n this._scheduleOnInput(row, col, e.target.value)\n },\n\n _scheduleOnInput(ri, ci, value) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n clearTimeout(this._onInputTimer)\n this._onInputPending = true\n const delay = typeof col.debounceMs === 'number' ? col.debounceMs : 400\n this._onInputTimer = setTimeout(() => {\n this._onInputPending = false\n const row = this._dataRaw[ri]\n if (row) {\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row, ri, col)\n // col.onInput is a common place for a host to derive/write another\n // cell's value on the same (already-reactive) row object — but cell\n // rendering reads through the non-reactive _dataRaw (see created()),\n // so an in-place mutation like that is otherwise invisible until the\n // next time `data` itself gets a new reference (e.g. on commit).\n this.$forceUpdate()\n }\n }, delay)\n },\n\n // Cancels any pending debounced call and fires col.onInput immediately —\n // used at commit points (Enter/blur, date pick, checkbox toggle) so a\n // still-in-flight debounce timer never drops the final value. If nothing\n // is pending (the debounce already fired naturally with this exact\n // value), skip re-firing — commitEdit() calls this unconditionally, so\n // without this guard a slow typist would get onInput called twice for\n // the same settled value.\n _flushOnInput(ri, ci, value, row) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n const wasPending = this._onInputPending\n clearTimeout(this._onInputTimer)\n this._onInputPending = false\n const last = this._lastFiredOnInput\n if (!wasPending && last && last.ri === ri && last.ci === ci && last.value === value) return\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row || this._dataRaw[ri], ri, col)\n // See _scheduleOnInput — a same-tick mutation to another cell on `row`\n // needs its own forceUpdate, since it happens after the data-watcher's\n // own forceUpdate (triggered by the commit that led here) already ran.\n this.$forceUpdate()\n },\n\n hasCellSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['cell-' + col.key])\n },\n hasLegacyEditSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['edit-' + col.key])\n },\n hasPrefixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['prefix-' + col.key])\n },\n hasSuffixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['suffix-' + col.key])\n },\n _hasCustomEdit(col) {\n // For select/treeSelect/date: cell-{key} slot only customizes display; picker still opens normally\n if (col.type === 'select' || col.type === 'treeSelect' || col.type === 'date') {\n return this.hasLegacyEditSlot(col)\n }\n return this.hasCellSlot(col) || this.hasLegacyEditSlot(col)\n },\n\n commitEditSlot(value) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n value = this._coerceCellValue(col, this._applyColMask(col, value))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // CHECKBOX CELLS\n // =========================================================\n\n isCheckboxChecked(ri, ci) {\n const v = this.getCellValue(ri, ci)\n if (v === true || v === 1) return true\n if (typeof v === 'string') return ['true', '1'].includes(v.trim().toLowerCase())\n return false\n },\n\n setCheckboxValue(ri, ci, value) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (!this.isCellValueValid(ri, ci, value)) {\n this._showInvalidCell(ri, ci, value)\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[ri] = { ...oldData[ri], [col.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(ri, ci, value, newData[ri])\n },\n\n toggleCheckbox(ri, ci) {\n this.setCheckboxValue(ri, ci, !this.isCheckboxChecked(ri, ci))\n },\n\n // =========================================================\n // ACTION CELLS (col.type === 'action')\n // =========================================================\n\n // Dynamic v-on argument expressions can't contain quotes/spaces in Vue 2.6\n // templates, so the 'click' fallback is resolved here instead of inline.\n actionHandlerEvent(col) {\n return (col && col.handlerType) || 'click'\n },\n\n triggerCellAction(ri, ci, e) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || col.type !== 'action' || typeof col.handler !== 'function') return\n if (!this.isCellEditable(ri, ci)) return\n col.handler(this._dataRaw[ri], ri, col, ci, e)\n },\n\n // =========================================================\n // CELL EDITABILITY (3.17)\n // =========================================================\n\n isCellEditable(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return false\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellEditable === 'function') return !!this.cellEditable(row, ri, col, ci)\n if (typeof col.editable === 'function') return !!col.editable(row, ri)\n if (col.editable === false) return false\n return true\n },\n\n // =========================================================\n // CELL VALUE VALIDATION (cellValidator)\n // =========================================================\n\n isCellValueValid(ri, ci, value) {\n if (typeof this.cellValidator !== 'function') return true\n const col = this.leafCols[ci]\n if (!col) return true\n const row = this._dataRaw[ri] || {}\n return !!this.cellValidator(value, row, ri, col, ci)\n },\n\n _resolveValidationMessage(ri, ci, value) {\n const col = this.leafCols[ci]\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellValidationMessage === 'function') return this.cellValidationMessage(value, row, ri, col, ci)\n if (typeof this.cellValidationMessage === 'string' && this.cellValidationMessage) return this.cellValidationMessage\n return this.t.invalidValue\n },\n\n // Find the on-screen rect of the cell currently being committed — the edit\n // input if still mounted (commitEdit, before editingCell is cleared), else\n // the active cell element (checkbox/select commits, which never enter edit mode).\n _invalidCellRect() {\n const inputs = this.$refs.editInput\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) return input.getBoundingClientRect()\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n return el ? el.getBoundingClientRect() : null\n },\n\n _showInvalidCell(ri, ci, value) {\n const message = this._resolveValidationMessage(ri, ci, value)\n this.invalidCell = { row: ri, col: ci, message, rect: this._invalidCellRect() }\n this.$emit('cell-validation-error', { row: ri, col: ci, value, message })\n },\n\n // Resolve+emit messages for a batch of rejected { row, col, value } entries\n // from a bulk write (paste / fill / clear). blocked=true means the whole\n // operation was aborted (strictBulkValidation); false means these cells\n // were skipped but the rest of the write still applied.\n _emitBulkValidationError(skipped, blocked) {\n const cells = skipped.map(c => ({\n row: c.row, col: c.col, value: c.value,\n message: this._resolveValidationMessage(c.row, c.col, c.value)\n }))\n this.$emit('bulk-validation-error', { blocked, cells })\n },\n\n // =========================================================\n // SELECT DROPDOWN (3.16) + TREE SELECT (3.22)\n // =========================================================\n\n isSelectLikeCol(col) {\n return !!(col && (col.type === 'select' || col.type === 'treeSelect'))\n },\n\n treeOptionKeys(col) {\n return {\n valueKey: col.optionValueKey || 'value',\n labelKey: col.optionLabelKey || 'label',\n childrenKey: col.optionChildrenKey || 'children'\n }\n },\n\n // Returns { [colKey]: 'left'|'right' } for columns that declare col.pin.\n _colPinMap(cols) {\n const map = {}\n ;(cols || []).forEach(col => {\n if (col.pin === 'left' || col.pin === 'right') map[col.key] = col.pin\n })\n return map\n },\n\n _treeFindLabel(nodes, keys, value) {\n for (const node of (nodes || [])) {\n // A malformed col.options (e.g. a host array with a hole/undefined entry,\n // often from an unguarded Array.prototype.find() result spread back in)\n // shouldn't crash the whole grid's render — skip it like a missing option.\n if (!node) continue\n if (String(node[keys.valueKey]) === value) {\n return node[keys.labelKey] != null ? node[keys.labelKey] : String(node[keys.valueKey])\n }\n const found = this._treeFindLabel(node[keys.childrenKey], keys, value)\n if (found != null) return found\n }\n return null\n },\n\n // col.options may be a static array OR a function (row, rowIndex, column, colIndex) => array,\n // letting hosts return a different option list per cell.\n hasOptionsSource(col) {\n return !!(col && (Array.isArray(col.options) || typeof col.options === 'function'))\n },\n\n resolveOptionsArray(col, ri, ci) {\n if (!col) return []\n if (typeof col.options === 'function') {\n const row = ri != null ? (this._dataRaw[ri] || {}) : {}\n const result = col.options(row, ri, col, ci != null ? ci : col._flatIndex)\n return Array.isArray(result) ? result : []\n }\n return Array.isArray(col.options) ? col.options : []\n },\n\n colOptions(ci, ri) {\n const col = this.leafCols[ci]\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return []\n // optionValueKey/optionLabelKey apply to type:'select' the same way they\n // already do for type:'treeSelect' — same config, same defaults.\n const { valueKey, labelKey } = this.treeOptionKeys(col)\n return raw.map(o =>\n typeof o === 'string' ? { value: o, label: o } : { value: o[valueKey], label: o[labelKey] != null ? o[labelKey] : String(o[valueKey]), color: o.color }\n )\n },\n\n getSelectLabel(ci, value, ri) {\n const col = this.leafCols[ci]\n const v = value == null ? '' : String(value)\n if (col && col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return v\n const found = this._treeFindLabel(raw, this.treeOptionKeys(col), v)\n return found != null ? found : v\n }\n const opts = this.colOptions(ci, ri)\n if (!opts.length) return v\n const found = opts.find(o => String(o.value) === v)\n return found ? found.label : v\n },\n\n _buildSelectDropdownState(ri, ci, col, currentValue, rect) {\n const rectObj = { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n return {\n row: ri, col: ci,\n tree: col.type === 'treeSelect',\n // valueKey/labelKey apply to both select and treeSelect (childrenKey\n // only matters for the tree dropdown, harmlessly unused otherwise).\n keys: this.treeOptionKeys(col),\n rawOptions: this.resolveOptionsArray(col, ri, ci),\n currentValue,\n rect: rectObj,\n // Defaults to on (opt out with enableSearch: false) — same convention\n // as clearable below.\n enableSearch: col.enableSearch !== false,\n clearable: col.clearable !== false\n }\n },\n\n openSelectDropdown(ri, ci, e) {\n const col = this.leafCols[ci]\n if (!this.hasOptionsSource(col)) return\n const el = e.currentTarget\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(ri, ci) != null ? this.getCellValue(ri, ci) : '')\n this.selectDropdown = this._buildSelectDropdownState(ri, ci, col, currentValue, rect)\n },\n\n openSelectDropdownForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!this.hasOptionsSource(colDef)) return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(row, col) != null ? this.getCellValue(row, col) : '')\n this.selectDropdown = this._buildSelectDropdownState(row, col, colDef, currentValue, rect)\n })\n },\n\n onComponentSelect({ value, label }) {\n if (this.internalLocked) { this.closeSelectDropdown(); return }\n if (!this.selectDropdown) return\n const { row, col } = this.selectDropdown\n const colDef = this.leafCols[col]\n if (colDef) {\n // Clearing (value === '') always goes through, same as onDatePick()'s\n // empty-string bypass — \"no selection\" is a valid state regardless of\n // what cellValidator requires of a real picked value.\n if (value !== '' && !this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.closeSelectDropdown()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n if (typeof colDef.onSelect === 'function') {\n // Deferred the same way as _fireCellCallbacksForRange (paste/fill/drag-move) —\n // a host watching the emitted data (e.g. mirroring it into its own\n // store) needs that watcher to run first, or onSelect reads stale state.\n this.$nextTick(() => {\n colDef.onSelect({ value, label }, newData[row], row, colDef)\n // See _scheduleOnInput — picks up any same-tick mutation the callback\n // made to another cell on this (already-reactive) row object.\n this.$forceUpdate()\n })\n }\n }\n this.closeSelectDropdown()\n },\n\n // Always returns focus to the grid wrapper — the dropdown (especially with\n // enableSearch) can hold real DOM focus on its own search input, and every\n // close path (Escape, outside click, picking a value) must hand focus back\n // or keyboard navigation on the grid goes dead until the user clicks a cell.\n closeSelectDropdown() {\n this.selectDropdown = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // DATE PICKER\n // =========================================================\n\n getCellDisplayValue(ci, val, ri) {\n const col = this.leafCols[ci]\n if (!col) return val == null ? '' : val\n if (typeof col.formatter === 'function') {\n const out = col.formatter(val, this._dataRaw[ri], ri, col, ci)\n return out == null ? '' : out\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, val, ri)\n if (col.type === 'date') {\n const d = parseDateValue(val)\n return d ? formatDate(d, col.format || DEFAULT_DATE_FORMAT) : (val == null ? '' : val)\n }\n return val == null ? '' : val\n },\n\n // =========================================================\n // FILTER ROW\n // =========================================================\n\n // Which control the filter cell renders for this column.\n // 'none' cells still render (empty) so column alignment stays intact.\n filterControlType(col) {\n if (!col || col.filterable === false || col.type === 'action') return 'none'\n if (col.type === 'checkbox') return 'checkbox'\n // Function-based options can't be resolved into a fixed list outside a\n // specific row, so those columns fall back to the text control.\n if ((col.type === 'select' || col.type === 'treeSelect') && Array.isArray(col.options)) return 'select'\n return 'text'\n },\n\n // Flat { value, label } list for a select-control filter cell. treeSelect\n // options are recursively flattened, each depth indented for readability.\n filterOptionsFor(col) {\n if (!col) return []\n if (col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, null, col._flatIndex)\n const out = []\n this._flattenTreeOptionsForFilter(raw, this.treeOptionKeys(col), 0, out)\n return out\n }\n return this.colOptions(col._flatIndex, null)\n },\n\n _flattenTreeOptionsForFilter(nodes, keys, depth, out) {\n for (const node of (nodes || [])) {\n const value = node[keys.valueKey]\n const label = node[keys.labelKey] != null ? node[keys.labelKey] : String(value)\n out.push({ value: String(value), label: '  '.repeat(depth) + label })\n this._flattenTreeOptionsForFilter(node[keys.childrenKey], keys, depth + 1, out)\n }\n },\n\n getFilterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n return {\n position: 'absolute',\n left: left + 'px',\n top: (this._headerRowH * this.headerDepth) + 'px',\n width: w + 'px',\n height: this._filterRowH + 'px',\n boxSizing: 'border-box'\n }\n },\n\n setFilter(key, value) {\n if (!this.leafCols.some(c => c.key === key)) return\n const next = { ...this.internalFilters }\n if (value === '' || value === null || value === undefined) {\n delete next[key]\n } else {\n next[key] = value\n }\n this.internalFilters = next\n this.$emit('update:filters', { ...next })\n this.$emit('filter-change', { key, value, filters: { ...next } })\n },\n\n clearFilters() {\n if (!Object.keys(this.internalFilters).length) return\n this.internalFilters = {}\n // A deliberate, infrequent action (unlike typing) — recompute immediately\n // rather than waiting out the debounce, and drop any timer already pending.\n clearTimeout(this._filterRecomputeTimer)\n this._recomputeRowFilters()\n this.$emit('update:filters', {})\n this.$emit('filter-change', { key: null, value: null, filters: {} })\n },\n\n // Debounced — recomputing filteredRowIndices scans every row, so typing\n // in the text filter shouldn't trigger it on every keystroke.\n _scheduleFilterRecompute() {\n clearTimeout(this._filterRecomputeTimer)\n this._filterRecomputeTimer = setTimeout(() => { this._recomputeRowFilters() }, 150)\n },\n\n _activeFilterCols() {\n const cols = []\n for (const col of this.leafCols) {\n if (this.filterControlType(col) === 'none') continue\n const v = this.internalFilters[col.key]\n if (v === undefined || v === null || v === '') continue\n cols.push(col)\n }\n return cols\n },\n\n _cellMatchesFilter(row, ri, col) {\n const ci = col._flatIndex\n const filterValue = this.internalFilters[col.key]\n if (typeof col.filterFn === 'function') {\n return !!col.filterFn(this.getCellValue(ri, ci), filterValue, row, ri, col)\n }\n const kind = this.filterControlType(col)\n if (kind === 'checkbox') {\n const checked = this.isCheckboxChecked(ri, ci)\n return filterValue === 'true' ? checked : !checked\n }\n if (kind === 'select') {\n const raw = this.getCellValue(ri, ci)\n return String(raw == null ? '' : raw) === String(filterValue)\n }\n const display = this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri)\n return String(display == null ? '' : display).toLowerCase().indexOf(String(filterValue).toLowerCase()) !== -1\n },\n\n _recomputeRowFilters() {\n // Can be reached (via the `columns`/`filterRow` watchers) during initWatch(),\n // before created() has set up _dataRaw — getCellValue() depends on it. The\n // `data` watcher's own immediate handler sets _dataRaw first, then always\n // calls this again, so the initial mount still ends up correct.\n if (!this._dataRaw) return\n if (!this.filterRow) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const activeCols = this._activeFilterCols()\n if (!activeCols.length) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const rows = this.data\n const out = []\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n let matches = true\n for (const col of activeCols) {\n if (!this._cellMatchesFilter(row, ri, col)) { matches = false; break }\n }\n if (!matches) out.push(ri)\n }\n this.filteredRowIndices = out\n },\n\n // =========================================================\n // COLUMN SORTING\n // =========================================================\n\n isColSortable(col) {\n return !!(this.sortable && col && col.sortable !== false && col.type !== 'action')\n },\n\n sortIconClass(col) {\n const active = !!(this.internalSort && this.internalSort.key === col.key)\n return active ? 'eg-icon-sort-menu eg-sort-icon-active' : 'eg-icon-sort-menu'\n },\n\n sortIconTitle() {\n return this.t.sortMenuTooltip\n },\n\n // Opens the sort popup menu (A → Z / Z → A / Cancel) anchored to the\n // clicked header icon. Replaces the old direct cycle-on-click behavior.\n openSortMenu(e, col) {\n if (this.internalLocked) return\n this.sortMenu = { visible: true, x: e.clientX, y: e.clientY, key: col.key }\n this.sortMenuPositioned = false\n this.$nextTick(this._positionSortMenu)\n },\n\n // Same flip-to-fit reasoning as _positionContextMenu().\n _positionSortMenu() {\n const el = this.$refs.sortMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.sortMenu.x\n let y = this.sortMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.sortMenu.x = x\n this.sortMenu.y = y\n this.sortMenuPositioned = true\n },\n\n hideSortMenu() {\n this.sortMenu.visible = false\n },\n\n // Marks whichever direction is currently applied to the menu's own\n // column so the popup shows which option is active (checkmark, see CSS).\n isSortMenuOptionActive(direction) {\n return !!(\n this.internalSort &&\n this.sortMenu.key &&\n this.internalSort.key === this.sortMenu.key &&\n this.internalSort.direction === direction\n )\n },\n\n sortMenuPickAsc() {\n this.setSort(this.sortMenu.key, 'asc')\n this.hideSortMenu()\n },\n\n sortMenuPickDesc() {\n this.setSort(this.sortMenu.key, 'desc')\n this.hideSortMenu()\n },\n\n sortMenuPickClear() {\n this.clearSort()\n this.hideSortMenu()\n },\n\n // Cycles a column through none → asc → desc → none. Kept as a public\n // method for programmatic/custom #header-{key} slot use — the built-in\n // header icon now opens the popup menu (openSortMenu) instead.\n toggleSort(key) {\n if (this.internalLocked) return\n const current = this.internalSort\n if (!current || current.key !== key) { this.setSort(key, 'asc'); return }\n if (current.direction === 'asc') { this.setSort(key, 'desc'); return }\n this.clearSort()\n },\n\n setSort(key, direction) {\n if (direction !== 'asc' && direction !== 'desc') { this.clearSort(); return }\n this.sortByColumn(key, direction)\n },\n\n // Clicking a third time (or calling this directly) clears the sort\n // indicator AND restores the row order from immediately before the\n // first sort of this session (see _preSortData in sortByColumn()) —\n // switching columns or direction mid-session doesn't move that\n // reference point, so this always lands back on the true original\n // order. Emitted as a normal data change (_emitDataChange), so it's\n // itself undoable/redoable like any other edit.\n clearSort() {\n if (!this.internalSort) return\n this.internalSort = null\n const oldData = this.data\n const restored = this._preSortData\n this._preSortData = null\n // Row count mismatch means rows were added/removed since the sort —\n // the snapshot no longer lines up with the current data, so skip the\n // restore rather than silently drop/duplicate rows (indicator still\n // clears either way).\n if (restored && restored.length === oldData.length) {\n this._emitDataChange(oldData, restored)\n }\n this.$emit('update:sort', null)\n this.$emit('sort-change', { key: null, direction: null })\n },\n\n sortByColumn(key, direction) {\n if (this.internalLocked) return\n // Merges and row groups are anchored to row *position* — a reorder\n // would silently invalidate them, so sorting is blocked while either\n // exists (same \"blocked while locked\" no-op convention as elsewhere).\n if (this.internalMerges.length || this.rowGroups.length) return\n const col = this.leafCols.find(c => c.key === key)\n if (!this.isColSortable(col)) return\n\n const oldData = this.data\n // Remember the order from before the *first* sort of this session —\n // re-sorting the same or a different column (asc<->desc, or switching\n // columns) without clearing first must not move this reference point,\n // so clearSort() always lands back on the true original order.\n if (!this.internalSort) this._preSortData = oldData\n const pairs = oldData.map((row, ri) => ({ row, ri }))\n pairs.sort((a, b) => this._compareForSort(a.row, a.ri, b.row, b.ri, col, direction))\n const newData = pairs.map(p => p.row)\n\n this.internalSort = { key, direction }\n this._emitDataChange(oldData, newData)\n this.$emit('update:sort', { key, direction })\n this.$emit('sort-change', { key, direction })\n\n this.scrollTop = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) this.$refs.scrollBody.scrollTop = 0\n })\n },\n\n _sortValueFor(row, ri, col) {\n const ci = col._flatIndex\n if (col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? 1 : 0\n if (col.type === 'date') {\n const d = parseDateValue(this.getCellValue(ri, ci))\n return d ? d.getTime() : null\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, this.getCellValue(ri, ci), ri)\n return this.getCellValue(ri, ci)\n },\n\n _compareForSort(rowA, riA, rowB, riB, col, direction) {\n if (typeof col.sortFn === 'function') {\n const result = col.sortFn(rowA, rowB, col)\n return direction === 'desc' ? -result : result\n }\n const a = this._sortValueFor(rowA, riA, col)\n const b = this._sortValueFor(rowB, riB, col)\n const aEmpty = a === null || a === undefined || a === ''\n const bEmpty = b === null || b === undefined || b === ''\n if (aEmpty && bEmpty) return 0\n if (aEmpty) return 1\n if (bEmpty) return -1\n const cmp = (typeof a === 'number' && typeof b === 'number')\n ? a - b\n : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n return direction === 'desc' ? -cmp : cmp\n },\n\n openDatePickerForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!colDef || colDef.type !== 'date') return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const val = this.getCellValue(row, col)\n this.datePickerOverlay = {\n row, col,\n format: colDef.format || DEFAULT_DATE_FORMAT,\n value: val == null ? '' : String(val),\n rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n }\n })\n },\n\n onDatePick(formattedVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n const colDef = this.leafCols[col]\n if (colDef) {\n if (formattedVal !== '' && !this.isCellValueValid(row, col, formattedVal)) {\n this._showInvalidCell(row, col, formattedVal)\n this.closeDatePicker()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: formattedVal }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, formattedVal, newData[row])\n }\n this.closeDatePicker()\n },\n\n // Raw keystrokes while typing in the date picker's manual text field —\n // debounced, same as onEditInput(). onDatePick() above flushes the final,\n // validated value immediately when the date actually commits.\n onDateTyping(rawVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n this._scheduleOnInput(row, col, rawVal)\n },\n\n // See closeSelectDropdown() — same reasoning: every close path must return\n // focus to the grid wrapper or keyboard navigation goes dead afterward.\n closeDatePicker() {\n clearTimeout(this._onInputTimer)\n this.datePickerOverlay = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n _onWindowScrollForDropdown(e) {\n if (this.contextMenu.visible) this.hideContextMenu()\n if (this.sortMenu.visible) this.hideSortMenu()\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n _onDocMouseDownForDropdown(e) {\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n onEditKeyDown(e) {\n if (e.key === 'Escape') {\n e.preventDefault()\n this.cancelEdit()\n } else if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(1, 0, e)\n } else if (e.key === 'Tab') {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n }\n },\n\n clearSelection() {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n let hasChanges = false\n const skipped = []\n for (const range of this.selectionRanges) {\n const r = {\n startRow: Math.min(range.startRow, range.endRow),\n endRow: Math.max(range.startRow, range.endRow),\n startCol: Math.min(range.startCol, range.endCol),\n endCol: Math.max(range.startCol, range.endCol)\n }\n for (let ri = r.startRow; ri <= r.endRow && ri < newData.length; ri++) {\n let rowUpdated = null\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n if (!this.isCellValueValid(ri, ci, '')) { skipped.push({ row: ri, col: ci, value: '' }); continue }\n if (!rowUpdated) rowUpdated = { ...newData[ri] }\n rowUpdated[col.key] = ''\n hasChanges = true\n }\n }\n if (rowUpdated) newData[ri] = rowUpdated\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n if (hasChanges) this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // FILL HANDLE (3.3)\n // =========================================================\n startFillDrag(e) {\n if (this.internalLocked) return\n const pr = this.primaryRange\n if (!pr) return\n this.fillDrag.active = true\n this.fillDrag.sourceRange = normalizeRange(pr)\n this.fillDrag.currentRow = pr.endRow\n this.fillDrag.currentCol = pr.endCol\n e.preventDefault()\n },\n\n onGlobalMouseMove(e) {\n // Detect column drag threshold (5 px)\n if (this.colDrag.pending) {\n if (Math.abs(e.clientX - this.colDrag.startX) > 5) {\n this.colDrag.pending = false\n this.colDrag.active = true\n this.colDrag.dropIndex = this.colDrag.sourceFirstLeaf\n this.isMouseDown = false // cancel cell-selection drag\n this._startColDrag()\n }\n }\n if (this.colDrag.active) {\n this.handleColDragMove(e)\n return\n }\n\n if (this.resizing.active) {\n this.handleResizeMove(e)\n return\n }\n if (this.fillDrag.active) {\n this.handleFillDragMove(e)\n return\n }\n if (this.dragMove.active) {\n this.handleDragMoveMove(e)\n return\n }\n },\n\n onGlobalMouseUp(e) {\n if (this.colDrag.active) {\n this.commitColDrag()\n return\n }\n if (this.colDrag.pending) {\n this._resetColDrag()\n // fall through — normal mouseup\n }\n\n if (this.resizing.active) {\n this.commitResize()\n return\n }\n if (this.fillDrag.active) {\n this.commitFillDrag()\n return\n }\n if (this.dragMove.active) {\n this.commitDragMove(e)\n return\n }\n this.isMouseDown = false\n },\n\n // Resolves which (row, col) the mouse is currently over, given the\n // scroll body's CURRENT scrollTop/scrollLeft — called both directly from\n // mousemove and repeatedly from the edge-auto-scroll rAF loop below (as\n // scrollTop changes on its own, the row under a STATIONARY cursor changes\n // with it).\n _fillDragTargetFromEvent(e) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return null\n const rect = scrollBody.getBoundingClientRect()\n const relY = e.clientY - rect.top + scrollBody.scrollTop\n const relX = e.clientX - rect.left + scrollBody.scrollLeft\n\n // Binary search on cumulativeHeights — O(log n) instead of O(n)\n const cum = this.cumulativeHeights\n const total = this.totalRows\n let lo = 0, hi = total - 1\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if (cum[mid + 1] <= relY) lo = mid + 1\n else hi = mid\n }\n const row = Math.min(lo, total - 1)\n\n // Columns are few — O(columns) is fast\n let left = this.getFrozenWidth()\n let col = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColumnScrollable(i)) continue\n left += this.getColW(i)\n if (relX < left) { col = i; break }\n col = i\n }\n return { row, col }\n },\n\n handleFillDragMove(e) {\n this._fillDragLastEvent = e\n const target = this._fillDragTargetFromEvent(e)\n if (!target) return\n this.fillDrag.currentRow = target.row\n this.fillDrag.currentCol = target.col\n // Covers the common case of a single big mousemove jump landing the\n // cursor (and its computed target row/col) outside the visible window.\n this.scrollToCell(target.row, target.col)\n // Covers the — far more common in practice — case of the cursor\n // PINNED at the container's edge (it usually can't travel further,\n // since the grid typically fills its host layout down to that edge):\n // keep scrolling for as long as it stays there. Same rAF/edge-zone\n // pattern as _updateDragAutoScroll (column-drag reordering) below.\n this._updateFillDragAutoScroll(e.clientX, e.clientY)\n },\n\n // Edge auto-scroll for fill-handle dragging — vertical AND horizontal\n // (column-drag's _updateDragAutoScroll is horizontal-only). Speed scales\n // with how deep into the 24px edge zone the cursor sits.\n _updateFillDragAutoScroll(clientX, clientY) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._fillDragScrollV = 0; this._fillDragScrollH = 0; return }\n const rect = sb.getBoundingClientRect()\n const ZONE = FILL_DRAG_EDGE_ZONE\n\n let v = 0\n if (clientY < rect.top + ZONE) v = -Math.ceil((rect.top + ZONE - clientY) / 4)\n else if (clientY > rect.bottom - ZONE) v = Math.ceil((clientY - (rect.bottom - ZONE)) / 4)\n this._fillDragScrollV = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, v))\n\n let h = 0\n if (clientX < rect.left + ZONE) h = -Math.ceil((rect.left + ZONE - clientX) / 4)\n else if (clientX > rect.right - ZONE) h = Math.ceil((clientX - (rect.right - ZONE)) / 4)\n this._fillDragScrollH = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, h))\n\n if ((this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) && !this._fillDragScrollRAF) {\n const step = () => {\n this._fillDragScrollRAF = null\n if (!this.fillDrag.active) return\n const body = this.$refs.scrollBody\n if (body) {\n if (this._fillDragScrollV) body.scrollTop = Math.max(0, Math.min(body.scrollHeight - body.clientHeight, body.scrollTop + this._fillDragScrollV))\n if (this._fillDragScrollH) body.scrollLeft = Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollLeft + this._fillDragScrollH))\n // The cursor itself hasn't moved, but the content under it has —\n // re-derive the fill target at the new scroll position.\n if (this._fillDragLastEvent) {\n const target = this._fillDragTargetFromEvent(this._fillDragLastEvent)\n if (target) { this.fillDrag.currentRow = target.row; this.fillDrag.currentCol = target.col }\n }\n }\n if (this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopFillDragAutoScroll() {\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragLastEvent = null\n if (this._fillDragScrollRAF) {\n cancelAnimationFrame(this._fillDragScrollRAF)\n this._fillDragScrollRAF = null\n }\n },\n\n async commitFillDrag() {\n this._stopFillDragAutoScroll()\n if (this.internalLocked) { this.fillDrag.active = false; return }\n const src = this.fillDrag.sourceRange\n if (!src) { this.fillDrag.active = false; return }\n\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n\n const oldData = this.data\n const newData = oldData.slice() // only affected rows get new objects\n let targetRange\n const skipped = []\n\n if (targetRow > src.endRow) {\n // Fill down\n const count = targetRow - src.endRow\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n targetRange = { startRow: src.endRow + 1, endRow: targetRow, startCol: src.startCol, endCol: src.endCol }\n } else if (targetCol > src.endCol) {\n // Fill right\n const count = targetCol - src.endCol\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n const srcValues = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n srcValues.push(col && oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ci = src.endCol + 1 + k\n if (ci < this.leafCols.length) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n }\n targetRange = { startRow: src.startRow, endRow: src.endRow, startCol: src.endCol + 1, endCol: targetCol }\n }\n\n let fireOnSelect = false\n if (targetRange) {\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n } else {\n this._emitDataChange(oldData, newData)\n this.$emit('fill', { sourceRange: src, targetRange, values: newData })\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n fireOnSelect = true\n }\n }\n\n this.fillDrag.active = false\n this.fillDrag.sourceRange = null\n\n if (fireOnSelect) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n doubleFillDown() {\n const pr = this.primaryRange\n if (!pr) return\n const src = normalizeRange(pr)\n // Find adjacent column filled length\n const adjCol = src.startCol > 0 ? src.startCol - 1 : src.endCol + 1\n if (adjCol < 0 || adjCol >= this.leafCols.length) return\n const adjColDef = this.leafCols[adjCol]\n if (!adjColDef) return\n let lastFilledRow = src.endRow\n for (let ri = src.endRow + 1; ri < this.totalRows; ri++) {\n if (this.data[ri] && this.data[ri][adjColDef.key] !== '' && this.data[ri][adjColDef.key] !== undefined) {\n lastFilledRow = ri\n } else break\n }\n if (lastFilledRow <= src.endRow) return\n\n const count = lastFilledRow - src.endRow\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // Ctrl+D / Ctrl+R — Excel's standard fill-down/fill-right (3.96)\n // =========================================================\n // Plain copy, NOT the fill-handle's series continuation (generateFillValues,\n // 1,2,3->4,5,6) — matches real Excel: Ctrl+D/Ctrl+R just duplicates a row/\n // column's values, it never extrapolates a pattern.\n async _fillAxis(direction) {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n const appliedRanges = []\n\n for (const range of this.selectionRanges) {\n const r = normalizeRange(range)\n if (direction === 'down') {\n let srcRow, firstTargetRow, lastTargetRow\n if (r.startRow === r.endRow) {\n // Single-row selection: Excel fills from the row directly above.\n srcRow = r.startRow - 1\n if (srcRow < 0) continue\n firstTargetRow = r.startRow\n lastTargetRow = r.startRow\n } else {\n srcRow = r.startRow\n firstTargetRow = r.startRow + 1\n lastTargetRow = r.endRow\n }\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcVal = oldData[srcRow] ? oldData[srcRow][col.key] : ''\n for (let ri = firstTargetRow; ri <= lastTargetRow; ri++) {\n if (!this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: srcRow, endRow: srcRow, startCol: r.startCol, endCol: r.endCol },\n targetRange: { startRow: firstTargetRow, endRow: lastTargetRow, startCol: r.startCol, endCol: r.endCol }\n })\n } else {\n let srcCol, firstTargetCol, lastTargetCol\n if (r.startCol === r.endCol) {\n // Single-column selection: Excel fills from the column directly to the left.\n srcCol = r.startCol - 1\n if (srcCol < 0) continue\n firstTargetCol = r.startCol\n lastTargetCol = r.startCol\n } else {\n srcCol = r.startCol\n firstTargetCol = r.startCol + 1\n lastTargetCol = r.endCol\n }\n const srcColDef = this.leafCols[srcCol]\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n const srcVal = srcColDef && oldData[ri] ? oldData[ri][srcColDef.key] : ''\n for (let ci = firstTargetCol; ci <= lastTargetCol; ci++) {\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: r.startRow, endRow: r.endRow, startCol: srcCol, endCol: srcCol },\n targetRange: { startRow: r.startRow, endRow: r.endRow, startCol: firstTargetCol, endCol: lastTargetCol }\n })\n }\n }\n\n if (!appliedRanges.length) return\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n this._emitDataChange(oldData, newData)\n for (const ar of appliedRanges) {\n this.$emit('fill', { sourceRange: ar.sourceRange, targetRange: ar.targetRange, values: newData })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n\n // See pasteAt's/commitFillDrag's _fireCellCallbacksForRange call — same\n // nextTick deferral reasoning (col.onInput/onSelect after the DOM settles).\n await this.$nextTick()\n for (const ar of appliedRanges) {\n this._fireCellCallbacksForRange(oldData, newData, ar.targetRange.startRow, ar.targetRange.endRow, ar.targetRange.startCol, ar.targetRange.endCol)\n }\n },\n\n fillDown() { return this._fillAxis('down') },\n fillRight() { return this._fillAxis('right') },\n\n // =========================================================\n // COPY / PASTE (3.4)\n // =========================================================\n copySelection() {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n this.clipboardMatrix = extractMatrix(this.data, this.leafCols, r)\n this.clipboardRange = r\n this.clipboardMode = 'copy'\n\n // Write to system clipboard as TSV\n const tsv = matrixToTSV(this.clipboardMatrix)\n if (navigator.clipboard) {\n navigator.clipboard.writeText(tsv).catch(() => {})\n }\n },\n\n cutSelection() {\n this.copySelection()\n this.clipboardMode = 'cut'\n },\n\n // Bulk cell writes (paste, fill handle, drag-move) set row values directly\n // and skip the single-cell paths (onComponentSelect / commitEdit /\n // setCheckboxValue / onDatePick) — so col.onSelect / col.onInput never\n // fire for them unless we replay it here for every cell whose value\n // actually changed in the range. select/treeSelect columns get onSelect\n // (matching the single-pick shape); everything else gets onInput —\n // fired immediately, no debounce (this is already a single bulk commit,\n // not live keystrokes).\n _fireCellCallbacksForRange(oldData, newData, startRow, endRow, startCol, endCol) {\n let fired = false\n for (let ri = startRow; ri <= endRow; ri++) {\n const oldRow = oldData[ri]\n const newRow = newData[ri]\n if (!newRow || newRow === oldRow) continue\n for (let ci = startCol; ci <= endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const oldVal = oldRow ? oldRow[col.key] : undefined\n const newVal = newRow[col.key]\n if (newVal === oldVal) continue\n if (this.isSelectLikeCol(col)) {\n if (typeof col.onSelect === 'function') {\n col.onSelect({ value: newVal, label: this.getSelectLabel(ci, newVal, ri) }, newRow, ri, col)\n fired = true\n }\n } else if (typeof col.onInput === 'function') {\n col.onInput(newVal, newRow, ri, col)\n fired = true\n }\n }\n }\n // See _scheduleOnInput — one forceUpdate covers the whole range instead of\n // one per cell (this can fire many callbacks for a large paste/fill range).\n if (fired) this.$forceUpdate()\n },\n\n async pasteAt(targetRow, targetCol, options = {}) {\n if (this.internalLocked) return\n const { mode = 'all', transpose = false } = options\n\n let matrix = null\n\n // Try system clipboard first\n if (navigator.clipboard) {\n try {\n const text = await navigator.clipboard.readText()\n if (text) matrix = tsvToMatrix(text)\n } catch (_) {}\n }\n\n // Fall back to internal clipboard\n if (!matrix && this.clipboardMatrix) {\n matrix = this.clipboardMatrix\n }\n\n if (!matrix) return\n\n if (transpose) matrix = transposeMatrix(matrix)\n\n // ── Excel-style selection broadcast / tiling ──────────────────────────\n // When pasting over a multi-cell selection and the clipboard matrix is\n // smaller than the selection, tile the matrix to fill the selection —\n // provided the selection dimensions are exact multiples of the matrix\n // dimensions (matches Excel's tiling rule exactly).\n // Cut-paste is never tiled (source must be cleared exactly once).\n const isCopyPaste = !this.clipboardMode || this.clipboardMode === 'copy'\n if (isCopyPaste) {\n const pr = this.primaryRange\n if (pr) {\n const sel = normalizeRange(pr)\n const selRows = sel.endRow - sel.startRow + 1\n const selCols = sel.endCol - sel.startCol + 1\n const matRows = matrix.length\n const matCols = matrix[0] ? matrix[0].length : 0\n\n // Only tile when selection is strictly larger than clipboard\n if ((selRows > matRows || selCols > matCols) &&\n selRows % matRows === 0 && selCols % matCols === 0) {\n matrix = tileMatrix(matrix, selRows, selCols)\n // Paste at selection origin, not just at the active cell\n targetRow = sel.startRow\n targetCol = sel.startCol\n }\n }\n }\n\n const oldData = this.data\n // pasteGrowsRows: pre-extend the working copy with blank rows so\n // applyMatrix's own data.length clamp naturally covers the full\n // pasted range instead of truncating it. `oldData` itself (used below\n // for _emitDataChange's undo/redo diff) stays untouched.\n let pasteBase = oldData\n let addedStartIndex = 0\n let addedCount = 0\n if (this.pasteGrowsRows) {\n const neededLength = targetRow + matrix.length\n if (neededLength > oldData.length) {\n const blankRows = []\n for (let i = oldData.length; i < neededLength; i++) {\n const row = {}\n this.leafCols.forEach(col => { row[col.key] = this._blankValueForCol(col) })\n if (this.pasteRowDefaults) {\n const extra = typeof this.pasteRowDefaults === 'function' ? this.pasteRowDefaults(i) : this.pasteRowDefaults\n if (extra) Object.assign(row, extra)\n }\n blankRows.push(row)\n }\n pasteBase = oldData.concat(blankRows)\n addedStartIndex = oldData.length\n addedCount = blankRows.length\n }\n }\n const { newData, affectedRange, skipped } = applyMatrix(\n pasteBase, this.leafCols, targetRow, targetCol, matrix, mode,\n (ri, ci) => this.isCellEditable(ri, ci),\n (ri, ci, v) => this.isCellValueValid(ri, ci, v),\n (ri, ci, v) => this._coerceCellValue(ci, this._applyColMask(ci, v))\n )\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n if (this.clipboardMode === 'cut' && this.clipboardRange) {\n // Clear source — ensure each source row is a new copy before mutation\n const src = this.clipboardRange\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci) && this.isCellValueValid(ri, ci, '')) {\n newData[ri][col.key] = ''\n }\n }\n }\n this.clipboardRange = null\n this.clipboardMode = null\n }\n\n this._emitDataChange(oldData, newData)\n // Fired once per paste operation (never per row), after 'input' — so by\n // the time hosts receive it, v-model already holds the final rows.\n // Lets hosts do a single aggregate action instead of reacting to\n // pasteRowDefaults' per-row calls.\n if (addedCount > 0) {\n this.$emit('rows-added', {\n startIndex: addedStartIndex,\n count: addedCount,\n rows: newData.slice(addedStartIndex, addedStartIndex + addedCount)\n })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n // Wait for Vue's reactivity queue to flush before firing onSelect — hosts\n // commonly react to the emitted 'input' with their own watcher (e.g. to\n // mirror the row into an external store); firing onSelect synchronously\n // would run it against that store's pre-paste, stale state.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, affectedRange.startRow, affectedRange.endRow, affectedRange.startCol, affectedRange.endCol)\n },\n\n ctxCopy() { this.hideContextMenu(); this.copySelection() },\n ctxCut() { this.hideContextMenu(); this.cutSelection() },\n ctxPaste() { this.hideContextMenu(); this.pasteAt(this.activeCell.row, this.activeCell.col) },\n ctxPasteSpecial(mode, transpose = false) {\n this.hideContextMenu()\n this.pasteAt(this.activeCell.row, this.activeCell.col, { mode, transpose })\n },\n\n // =========================================================\n // COLUMN DRAG-AND-DROP\n // =========================================================\n\n _computeDragOffsets(dropIndex) {\n const { sourceFirstLeaf, sourceLeafCount } = this.colDrag\n if (dropIndex < 0 || (dropIndex >= sourceFirstLeaf && dropIndex <= sourceFirstLeaf + sourceLeafCount)) return {}\n\n const previewCols = moveColumnNodeInTree(this.columns, sourceFirstLeaf, sourceLeafCount, dropIndex)\n const previewLeaves = flattenColumns(previewCols)\n\n const byKey = {}\n this.leafCols.forEach(c => { byKey[c.key] = c })\n\n const offsets = {}\n for (const zone of ['left', 'scroll', 'right']) {\n const inZone = zone === 'left'\n ? ci => this.isColumnPinnedLeft(ci)\n : zone === 'right'\n ? ci => this.isColumnPinnedRight(ci)\n : ci => this.isColumnScrollable(ci)\n\n const origPos = {}\n let acc = 0\n this.leafCols.forEach(col => {\n const ci = col._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) return\n origPos[col.key] = acc\n acc += this.getColW(ci)\n })\n\n const previewPos = {}\n acc = 0\n for (const col of previewLeaves) {\n const origCol = byKey[col.key]\n if (!origCol) continue\n const ci = origCol._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) continue\n previewPos[col.key] = acc\n acc += this.getColW(ci)\n }\n\n for (const key of Object.keys(origPos)) {\n offsets[key] = (previewPos[key] !== undefined ? previewPos[key] : origPos[key]) - origPos[key]\n }\n }\n return offsets\n },\n\n // Called once when the 5px movement threshold flips the drag active:\n // snapshots the original header geometry the whole drag will be measured\n // against, sizes the ghost from the grabbed header, and arms the CSS\n // transitions on every column element.\n _startColDrag() {\n const sb = this.$refs.scrollBody\n this._dragStartScrollLeft = sb ? sb.scrollLeft : 0\n const bounds = []\n if (this.$el) {\n this.$el.querySelectorAll('.eg-header-cell:not(.eg-header-group)').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n if (isNaN(fi)) return\n const rect = el.getBoundingClientRect()\n bounds.push({ fi, left: rect.left, right: rect.right, scrollable: this.isColumnScrollable(fi) })\n })\n }\n this._dragBounds = bounds\n this._dragScrollV = 0\n\n const hr = this._pendingDragHeaderRect\n if (hr && hr.width > 0) {\n this.colDrag.ghostWidth = Math.min(Math.round(hr.width), 320)\n this.colDrag.grabOffsetX = Math.min(\n Math.max(Math.round(this.colDrag.startX - hr.left), 12),\n this.colDrag.ghostWidth - 12\n )\n } else {\n this.colDrag.ghostWidth = 140\n this.colDrag.grabOffsetX = 70\n }\n\n this._setDragTransitions(true)\n },\n\n _setDragTransitions(on) {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = on ? COL_DRAG_TRANSITION : ''\n })\n },\n\n _applyDragTransforms(offsets) {\n if (!this.$el) return\n // Just retarget the transforms — the CSS transition set at drag start\n // animates each element from wherever it currently is. An explicit\n // translateX(0px) (not '') keeps returning columns gliding back instead\n // of snapping.\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n this.$el.querySelectorAll('[data-start]').forEach(el => {\n const start = parseInt(el.dataset.start)\n const end = parseInt(el.dataset.end)\n if (isNaN(start)) return\n let key = null\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n const dx = key ? (offsets[key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n },\n\n // Instant cleanup: transition and transform drop in the same style write,\n // so the (now transition-less) transform change never animates.\n _clearDragTransforms() {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = ''\n el.style.transform = ''\n })\n },\n\n handleColDragMove(e) {\n // Ghost follows the cursor with the original grab point preserved —\n // direct DOM writes, no Vue re-render per mousemove.\n const ghost = this.$refs.colDragGhost\n if (ghost) {\n ghost.style.left = (e.clientX - this.colDrag.grabOffsetX) + 'px'\n ghost.style.top = (e.clientY - 14) + 'px'\n }\n this._lastDragClientX = e.clientX\n this._refreshDragPreview(e.clientX)\n this._updateDragAutoScroll(e.clientX)\n },\n\n _refreshDragPreview(clientX) {\n const prevDrop = this.colDrag.dropIndex\n this._updateColDragDropIndex(clientX)\n if (this.colDrag.dropIndex !== prevDrop) {\n this._dragOffsets = this._computeDragOffsets(this.colDrag.dropIndex)\n this._applyDragTransforms(this._dragOffsets)\n }\n },\n\n _updateColDragDropIndex(clientX) {\n const bounds = this._dragBounds\n if (!bounds || !bounds.length) { this.colDrag.dropIndex = this.leafCols.length; return }\n\n // Boundaries come from the drag-start snapshot, shifted by how far the\n // body has scrolled since (pinned zones don't move with the scroll).\n // Live DOM rects are never read — they carry in-flight preview\n // transforms whose intermediate values would corrupt the math.\n const sb = this.$refs.scrollBody\n const scrollDelta = sb ? sb.scrollLeft - this._dragStartScrollLeft : 0\n const adjusted = bounds\n .map(b => ({\n fi: b.fi,\n left: b.scrollable ? b.left - scrollDelta : b.left,\n right: b.scrollable ? b.right - scrollDelta : b.right\n }))\n .sort((a, b) => a.left - b.left)\n\n let dropIndex = adjusted[adjusted.length - 1].fi + 1\n for (const { fi, left, right } of adjusted) {\n if (clientX < right) {\n dropIndex = clientX < (left + right) / 2 ? fi : fi + 1\n break\n }\n }\n this.colDrag.dropIndex = dropIndex\n },\n\n // Edge auto-scroll: dragging near the left/right edge of the visible\n // scrollable window (the part not covered by sticky pinned zones) scrolls\n // the body horizontally, speed proportional to how deep into the 40px\n // edge zone the cursor sits — so far-away drop targets are reachable.\n _updateDragAutoScroll(clientX) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._dragScrollV = 0; return }\n const rect = sb.getBoundingClientRect()\n const leftEdge = rect.left + this.getFrozenWidth()\n const rightEdge = rect.left + sb.clientWidth - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n const ZONE = 40\n let v = 0\n if (clientX < leftEdge + ZONE) v = -Math.ceil((leftEdge + ZONE - clientX) / 4)\n else if (clientX > rightEdge - ZONE) v = Math.ceil((clientX - (rightEdge - ZONE)) / 4)\n this._dragScrollV = Math.max(-20, Math.min(20, v))\n\n if (this._dragScrollV !== 0 && !this._dragScrollRAF) {\n const step = () => {\n this._dragScrollRAF = null\n if (!this.colDrag.active || this._dragScrollV === 0) return\n const body = this.$refs.scrollBody\n if (body) {\n const before = body.scrollLeft\n body.scrollLeft += this._dragScrollV\n // Re-derive the drop slot at the new scroll position even though\n // the cursor itself hasn't moved.\n if (body.scrollLeft !== before) this._refreshDragPreview(this._lastDragClientX)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopDragAutoScroll() {\n this._dragScrollV = 0\n if (this._dragScrollRAF) {\n cancelAnimationFrame(this._dragScrollRAF)\n this._dragScrollRAF = null\n }\n },\n\n commitColDrag() {\n const from = this.colDrag.sourceFirstLeaf\n const leafCount = this.colDrag.sourceLeafCount\n const to = this.colDrag.dropIndex\n\n if (from < 0 || to < 0 || leafCount < 1) { this._resetColDrag(true); return }\n if (to >= from && to <= from + leafCount) { this._resetColDrag(true); return }\n\n // KEEP the preview transforms in the DOM while Vue re-renders with the\n // new column order. After Vue updates left positions ($nextTick), we\n // instantly clear them — both steps run as microtasks before the browser\n // paints, so there is never a visible intermediate state.\n this._stopDragAutoScroll()\n clearTimeout(this._dragCleanupTimer)\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n\n const newCols = moveColumnNodeInTree(this.columns, from, leafCount, to)\n this.$emit('update:columns', newCols)\n this.$emit('column-reorder', { fromIndex: from, toIndex: to, columns: newCols })\n\n // Clear transforms after Vue re-renders new column left positions\n this.$nextTick(() => { this._clearDragTransforms() })\n },\n\n // animateBack: cancelled drags (Escape, drop on the source slot) glide the\n // columns back to their original places instead of snapping.\n _resetColDrag(animateBack = false) {\n const wasActive = this.colDrag.active\n this._stopDragAutoScroll()\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n clearTimeout(this._dragCleanupTimer)\n if (animateBack && wasActive && this.$el) {\n this._applyDragTransforms({}) // everything retargets to translateX(0) with transitions still on\n this._dragCleanupTimer = setTimeout(() => this._clearDragTransforms(), 230)\n } else {\n this._clearDragTransforms()\n }\n },\n\n // =========================================================\n // RESIZE (3.5)\n // =========================================================\n startColResize(e, ci) {\n // Under fitColumns, resizing a column turns IT into a fixed px\n // override (see commitResize/_applyFitColumns) — the rest keep\n // scaling proportionally to fill what's left, so this can reintroduce\n // horizontal scroll. That's an accepted tradeoff for letting a column\n // be pinned to an exact width while fitColumns is on.\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'col',\n index: ci,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getColW(ci),\n currentPos: e.clientX,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n // Manual resize turns a relative-unit width ('20%', ...) into a fixed px\n // override — it should no longer rescale on window resize.\n this.$delete(this.relativeColSpecs, ci)\n e.preventDefault()\n },\n\n startRowResize(e, ri) {\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'row',\n index: ri,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getRowH(ri),\n currentPos: e.clientY,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n e.preventDefault()\n },\n\n handleResizeMove(e) {\n if (this.resizing.type === 'col') {\n this.resizing.currentPos = e.clientX\n const newWidth = Math.max(20, this.resizing.startSize + (e.clientX - this.resizing.startX))\n this.$set(this.colWidths, this.resizing.index, newWidth)\n this._cellStyleCache = Object.create(null)\n } else {\n this.resizing.currentPos = e.clientY\n this._resizePreviewHeight = Math.max(10, this.resizing.startSize + (e.clientY - this.resizing.startY))\n // Batch $set + updateVirtualScroll to rAF — prevents O(n) cumulativeHeights rebuild\n // on every mousemove (200+ Hz). At most 60 rebuilds/sec instead of 200+.\n if (!this._resizeRAF) {\n this._resizeRAF = requestAnimationFrame(() => {\n this._resizeRAF = null\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n this.updateVirtualScroll()\n })\n }\n }\n },\n\n commitResize() {\n if (this._resizeRAF) {\n cancelAnimationFrame(this._resizeRAF)\n this._resizeRAF = null\n // Apply the last pending preview height immediately on commit\n if (this.resizing.type === 'row' && this._resizePreviewHeight !== undefined) {\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n }\n }\n if (this.resizing.type === 'col') {\n this._cellStyleCache = Object.create(null)\n const newSize = this.colWidths[this.resizing.index]\n if (this.fitColumns) {\n const col = this.leafCols[this.resizing.index]\n if (col) this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: this.resizing.index, newSize })\n } else {\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n const newSize = this.rowHeights[this.resizing.index]\n this.$emit('row-resize', { index: this.resizing.index, newSize })\n this.updateVirtualScroll()\n }\n this.resizing.active = false\n },\n\n // Double-click-the-header \"AutoFit column width\" — same idea as Excel/Sheets'\n // double-click-the-border shortcut. Under fitColumns, same override\n // treatment as a manual drag-resize (see commitResize/_applyFitColumns):\n // this column becomes a fixed px width excluded from the proportional\n // fit, the rest keep scaling in what's left.\n autoFitColumn(ci) {\n const col = this.leafCols[ci]\n if (!col) return\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n const newWidth = Math.max(50, maxLen * 8 + 16)\n this.$set(this.colWidths, ci, newWidth)\n this.$delete(this.relativeColSpecs, ci)\n this._cellStyleCache = Object.create(null)\n if (this.fitColumns) {\n this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: ci, newSize: newWidth })\n },\n\n _applyAutoWidth() {\n // fitColumns is the higher-priority width control — see its own JSDoc.\n if (!this.autoWidth || this.fitColumns || !this.leafCols.length) return\n this.leafCols.forEach((col, ci) => {\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n this.$set(this.colWidths, ci, Math.max(50, maxLen * 8 + 16))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n // fitColumns prop — ignores col.width as an absolute value, using it only\n // as a relative WEIGHT so every visible leaf column (pinned or not)\n // scales by one shared factor to exactly fill _relativeWidthContainer\n // (the same circular-dependency-safe reference %/CSS-length widths\n // already resolve against — see its declaration comment). Mirrors\n // naturalGridWidth's formula in reverse: that computed derives the\n // grid's natural width FROM the column widths; this derives the column\n // widths FROM the available width.\n //\n // Columns in fitColumnsOverrides (manually resized while fitColumns was\n // on — see commitResize) are excluded from the scaling pool and keep\n // their current colWidths px value untouched; the remaining columns\n // scale to fill whatever space is left after subtracting the overrides'\n // total width, which can go negative (horizontal scroll returns) if the\n // overrides alone exceed the available width.\n _applyFitColumns() {\n if (!this.fitColumns || !this.leafCols.length) return\n const containerWidth = this._relativeWidthContainer\n if (!containerWidth) return // not measured yet — re-run once hostContainerWidth arrives\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n const available = containerWidth - ROW_HEADER_WIDTH - outlineW - this.vScrollbarW - this.shellBorderW\n if (available <= 0) return\n\n const visible = []\n let totalWeight = 0\n let overriddenWidth = 0\n this.leafCols.forEach((col, ci) => {\n if (this.isColHidden(ci)) return\n if (this.fitColumnsOverrides[col.key]) {\n const w = this.colWidths[ci] !== undefined ? this.colWidths[ci] : this.resolveColWidth(col.width, containerWidth)\n overriddenWidth += w\n return\n }\n const w = this.resolveColWidth(col.width, containerWidth)\n visible.push({ ci, weight: w })\n totalWeight += w\n })\n if (!visible.length || totalWeight <= 0) return\n\n const remaining = available - overriddenWidth\n const scale = remaining > 0 ? remaining / totalWeight : 0\n visible.forEach(({ ci, weight }) => {\n this.$set(this.colWidths, ci, Math.max(FIT_COLUMNS_MIN_WIDTH, weight * scale))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n ctxHideRow() {\n const targetRow = this.contextMenu.row\n // Row header: row >= 0 AND col = -1 (no column context)\n const fromRowHeader = targetRow >= 0 && this.contextMenu.col < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenRows]\n if (fromRowHeader) {\n if (!newHidden.includes(targetRow)) newHidden.push(targetRow)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n if (!newHidden.includes(ri)) newHidden.push(ri)\n }\n }\n this.$emit('update:hiddenRows', newHidden)\n },\n\n ctxHideColumn() {\n const targetCol = this.contextMenu.col\n // Column header: col >= 0 AND row = -1 (no row context)\n const fromColHeader = targetCol >= 0 && this.contextMenu.row < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenColumns]\n if (fromColHeader) {\n if (!newHidden.includes(targetCol)) newHidden.push(targetCol)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n if (!newHidden.includes(ci)) newHidden.push(ci)\n }\n }\n this.$emit('update:hiddenColumns', newHidden)\n },\n\n ctxUnhideRows() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenRows', [])\n },\n\n ctxUnhideColumns() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenColumns', [])\n },\n\n ctxPinLeft() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'left')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxPinRight() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'right')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxUnpin() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$delete(this.internalPinnedColumns, col.key)\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n isColPinned(ci) {\n const col = this.leafCols[ci]\n return !!(col && this.internalPinnedColumns[col.key])\n },\n\n // =========================================================\n // DEV MODE — column/row config info (context menu → modal)\n // =========================================================\n _stringifyForInfo(value) {\n const seen = new WeakSet()\n return JSON.stringify(value, (key, val) => {\n if (typeof val === 'function') return 'ƒ ' + (val.name || 'anonymous') + '()'\n if (val && typeof val === 'object') {\n if (seen.has(val)) return '[Circular]'\n seen.add(val)\n }\n return val\n }, 2)\n },\n\n ctxShowColumnInfo() {\n const ci = this.contextMenu.col\n this.hideContextMenu()\n const col = this.leafCols[ci]\n if (!col) return\n const { _flatIndex, ...rest } = col\n this.infoModal = { title: `${this.t.columnInfo}: ${col.title || col.key}`, json: this._stringifyForInfo(rest) }\n },\n\n ctxShowRowInfo() {\n const ri = this.contextMenu.row\n this.hideContextMenu()\n const row = this._dataRaw[ri]\n if (!row) return\n this.infoModal = { title: `${this.t.rowInfo} #${ri + 1}`, json: this._stringifyForInfo(row) }\n },\n\n closeInfoModal() {\n this.infoModal = null\n },\n\n // =========================================================\n // MERGE CELLS (3.8)\n // =========================================================\n mergeCells(range) {\n if (this.internalLocked) return false\n const oldMerges = this.internalMerges.slice()\n const { merges: newMerges, conflict } = addMerge(range, this.internalMerges)\n if (conflict) {\n this.$emit('merge-error', { range, reason: 'Overlaps existing merge' })\n return false\n }\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n return true\n },\n\n unmergeCells(row, col) {\n if (this.internalLocked) return\n const oldMerges = this.internalMerges.slice()\n const newMerges = removeMergeAt(row, col, this.internalMerges)\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n },\n\n ctxMerge() {\n this.hideContextMenu()\n const pr = this.primaryRange\n if (!pr) return\n this.mergeCells(normalizeRange(pr))\n },\n\n ctxUnmerge() {\n this.hideContextMenu()\n this.unmergeCells(this.activeCell.row, this.activeCell.col)\n },\n\n // =========================================================\n // GROUPING (3.7)\n // =========================================================\n groupRows(startIndex, endIndex) {\n this.internalGroups = addGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n groupColumns(startIndex, endIndex) {\n this.internalGroups = addGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupRows(startIndex, endIndex) {\n this.internalGroups = removeGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupColumns(startIndex, endIndex) {\n this.internalGroups = removeGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n toggleGroup(group) {\n this.internalCollapsedGroups = toggleGroupCollapse(group, this.internalCollapsedGroups)\n this.$emit('update:collapsedGroups', [...this.internalCollapsedGroups])\n this.$emit('group-toggle', { group, collapsed: _isGroupCollapsed(group, this.internalCollapsedGroups) })\n this.updateVirtualScroll()\n },\n\n isGroupCollapsed(group) {\n return _isGroupCollapsed(group, this.internalCollapsedGroups)\n },\n\n expandAll() {\n this.internalCollapsedGroups = []\n this.$emit('update:collapsedGroups', [])\n this.updateVirtualScroll()\n },\n\n collapseAll() {\n this.internalCollapsedGroups = [...this.internalGroups]\n this.$emit('update:collapsedGroups', [...this.internalGroups])\n this.updateVirtualScroll()\n },\n\n // =========================================================\n // DRAG AND DROP (3.9)\n // =========================================================\n startDragMove(e, ri, ci) {\n const pr = this.primaryRange\n if (!pr) return\n this.dragMove = {\n active: true,\n sourceRange: normalizeRange(pr),\n startX: e.clientX,\n startY: e.clientY,\n targetRow: ri,\n targetCol: ci,\n copyMode: e.ctrlKey || e.metaKey\n }\n this.$emit('cell-drag-start', { sourceRange: this.dragMove.sourceRange })\n e.preventDefault()\n },\n\n handleDragMoveMove(e) {\n this.dragMove.copyMode = e.ctrlKey || e.metaKey\n this.$emit('cell-drag-move', {\n sourceRange: this.dragMove.sourceRange,\n targetRow: this.dragMove.targetRow,\n targetCol: this.dragMove.targetCol\n })\n },\n\n async commitDragMove(e) {\n if (!this.dragMove.active) return\n const { sourceRange, targetRow, targetCol, copyMode } = this.dragMove\n\n const targetRange = {\n startRow: targetRow,\n startCol: targetCol,\n endRow: targetRow + (sourceRange.endRow - sourceRange.startRow),\n endCol: targetCol + (sourceRange.endCol - sourceRange.startCol)\n }\n\n let proceed = true\n if (this.beforeDrop) {\n proceed = await Promise.resolve(this.beforeDrop(sourceRange, targetRange))\n }\n\n let oldData = null\n let newData = null\n if (proceed) {\n oldData = this.data\n const matrix = extractMatrix(oldData, this.leafCols, sourceRange)\n newData = applyMatrix(oldData, this.leafCols, targetRow, targetCol, matrix, 'all', (ri, ci) => this.isCellEditable(ri, ci)).newData\n\n if (!copyMode) {\n // Clear source — ensure each source row is a new copy before mutation\n for (let ri = sourceRange.startRow; ri <= sourceRange.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = sourceRange.startCol; ci <= sourceRange.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) newData[ri][col.key] = ''\n }\n }\n }\n this._emitDataChange(oldData, newData)\n this.$emit('cell-drop', { sourceRange, targetRange })\n }\n\n this.dragMove = { active: false, sourceRange: null, startX: 0, startY: 0, targetRow: -1, targetCol: -1, copyMode: false }\n this.isMouseDown = false\n\n if (proceed) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n // =========================================================\n // EXCEL EXPORT (3.11)\n // =========================================================\n async exportToExcel(options = {}) {\n const {\n filename = 'export.xlsx',\n sheetName = 'Sheet1',\n scope = 'all',\n includeHidden = false\n } = options\n\n this.$emit('export-start')\n this.exportState = { active: true }\n\n const workerPayload = {\n data: this.data,\n columns: this.columns,\n // width resolved to a concrete px number (getColW handles px/%/other CSS\n // units) — excelExport.js's char-width math needs a real number, and a\n // worker has no DOM to resolve a raw '20%'/'10em' spec itself.\n leafCols: this.leafCols.map((col, ci) => ({ ...col, width: this.getColW(ci) })),\n merges: this.internalMerges,\n hiddenCols: [...this.effectiveHiddenColSet],\n hiddenRows: [...this.hiddenRowSet],\n range: scope === 'selection' ? this.primaryRange : null,\n filename,\n sheetName,\n scope,\n includeHidden\n }\n\n try {\n const worker = createExcelExportWorker()\n if (worker) {\n await this._exportViaWorker(worker, workerPayload)\n } else {\n // Fallback: synchronous export on main thread\n _exportToExcel({\n ...workerPayload,\n hiddenColSet: this.effectiveHiddenColSet,\n hiddenRowSet: this.hiddenRowSet\n })\n }\n this.$emit('export-success')\n } catch (err) {\n this.$emit('export-error', err)\n } finally {\n this.exportState = { active: false }\n }\n },\n\n /**\n * Print all (visible) grid data as a plain, theme-independent HTML\n * table — not the styled data-grid itself. Honors hidden columns/rows,\n * merged cells, and footer totals, same sources `exportToExcel()` uses.\n * `type: 'action'` columns (buttons — add/remove row, etc.) are always\n * excluded: they're interactive UI, not printable data.\n */\n printGrid() {\n const rowIndices = []\n for (let ri = 0; ri < this.totalRows; ri++) {\n if (!this.hiddenRowSet.has(ri)) rowIndices.push(ri)\n }\n const printExcludedColSet = new Set(this.effectiveHiddenColSet)\n this.leafCols.forEach(col => { if (col.type === 'action') printExcludedColSet.add(col._flatIndex) })\n const html = buildPrintTableHtml({\n columns: this.columns,\n leafCols: this.leafCols,\n rowIndices,\n merges: this.internalMerges,\n hiddenColSet: printExcludedColSet,\n // Resolved px width per column (same getColW() exportToExcel() uses)\n // so print columns stay proportional to their on-screen width.\n colWidths: this.leafCols.map((col, ci) => this.getColW(ci)),\n getCellText: (ri, ci) => {\n const col = this.leafCols[ci]\n if (col && col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? '✓' : ''\n return String(this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri))\n },\n showFooter: this.hasFooter,\n getFooterText: ci => this.getColumnTotalDisplay(ci),\n printTitle: this.printTitle\n })\n this._openPrintFrame(html)\n },\n\n // Hidden same-origin <iframe> instead of window.open(): can't be\n // popup-blocked, and works the same when the host app itself runs\n // inside an iframe. srcdoc's document is fully self-contained (its own\n // <style>, nothing inherited from the host page), so the printed table\n // never picks up the grid's current theme.\n _openPrintFrame(html) {\n const iframe = document.createElement('iframe')\n iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden;'\n document.body.appendChild(iframe)\n const cleanup = () => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe) }\n iframe.onload = () => {\n const win = iframe.contentWindow\n win.addEventListener('afterprint', cleanup)\n win.focus()\n win.print()\n // Fallback in case `afterprint` never fires (observed on some\n // browsers when the print dialog is dismissed a certain way).\n setTimeout(cleanup, 2000)\n }\n iframe.srcdoc = html\n },\n\n _exportViaWorker(worker, payload) {\n return new Promise((resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, buffer, filename, message } = e.data\n if (type === 'result') {\n // Trigger browser download from the main thread (DOM required)\n const blob = new Blob([new Uint8Array(buffer)], {\n type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n URL.revokeObjectURL(url)\n finish(resolve)\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Export worker xatosi'))\n\n worker.postMessage(payload)\n })\n },\n\n // =========================================================\n // EXCEL IMPORT (3.12)\n // =========================================================\n async importFromFile(file, options = {}) {\n this.importState = { active: true, percent: 5, message: \"Fayl o'qilmoqda...\" }\n\n try {\n let result\n const worker = createExcelImportWorker()\n\n if (worker) {\n result = await this._importViaWorker(worker, file, options)\n } else {\n // Fallback: chunked async on main thread\n result = await _importFromFile(\n file,\n this.columns,\n options,\n (percent, message) => { this.importState = { active: true, percent, message } }\n )\n }\n\n const { columns, merges } = result\n // Freeze each row so Vue 2 skips deep Object.defineProperty observation.\n // Vue 2 checks Object.isExtensible() — frozen objects are skipped entirely.\n // All edit paths already use spread ({ ...row }) so frozen rows are safe.\n const data = result.data.map(row => Object.freeze(row))\n this.$emit('input', data)\n this._scheduleBufferWrite(data)\n this.$emit('update:columns', columns)\n\n const hasMerges = merges && merges.length > 0\n this.internalMerges = hasMerges ? merges : []\n this.$emit('update:merges', hasMerges ? merges : [])\n this.$emit('update:collapsedGroups', [])\n this.$emit('update:hiddenRows', [])\n this.$emit('import-success', { data, columns, merges })\n\n // Reset scroll state and re-derive virtual scroll after DOM settles.\n // Without this, stale scrollTop + old rowHeights cause blank bands on scroll.\n this.rowHeights = {}\n this.scrollTop = 0\n this.scrollLeft = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) {\n this.$refs.scrollBody.scrollTop = 0\n this.$refs.scrollBody.scrollLeft = 0\n }\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = 0\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = 0\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = 0\n this.updateVirtualScroll()\n })\n } catch (err) {\n this.$emit('import-error', err)\n } finally {\n this.importState = { active: false, percent: 0, message: '' }\n }\n },\n\n _importViaWorker(worker, file, options) {\n return new Promise(async (resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, percent, message, data, columns, merges } = e.data\n if (type === 'progress') {\n this.importState = { active: true, percent, message }\n } else if (type === 'result') {\n finish(resolve, { data, columns, merges })\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Import worker xatosi'))\n\n try {\n // Transfer the ArrayBuffer zero-copy into the worker\n const buffer = await file.arrayBuffer()\n worker.postMessage(\n { buffer, existingColumns: this.columns, options },\n [buffer]\n )\n } catch (err) {\n finish(reject, err)\n }\n })\n },\n\n // =========================================================\n // UNDO / REDO (3.13)\n // =========================================================\n\n // Push one entry onto the non-reactive history stack.\n // Truncates the redo tail, caps at 100 entries.\n _pushHistory(entry) {\n if (this._historyIndex < this._history.length - 1) {\n this._history.splice(this._historyIndex + 1)\n }\n this._history.push(entry)\n if (this._history.length > 100) {\n this._history.shift()\n // historyIndex stays the same — it now points to the new last entry\n } else {\n this._historyIndex++\n }\n this.historyVersion++\n },\n\n // Emit 'input' and record a data diff entry (only changed row indices).\n // Use slice() + per-row spread in callers so unchanged rows keep the\n // same reference — then the identity check here catches only real changes.\n _emitDataChange(oldData, newData) {\n if (!this._historySkip) {\n const indices = []\n const beforeRows = []\n const afterRows = []\n const len = Math.max(oldData.length, newData.length)\n for (let i = 0; i < len; i++) {\n if (oldData[i] !== newData[i]) {\n indices.push(i)\n beforeRows.push(oldData[i])\n afterRows.push(newData[i])\n }\n }\n if (indices.length > 0) {\n // rowCount lets undo() truncate the array back down when this change\n // added rows (e.g. pasteGrowsRows) — a no-op for every change that\n // doesn't touch row count, since rowCount then matches on both sides.\n this._pushHistory({\n type: 'data',\n before: { indices, rows: beforeRows, rowCount: oldData.length },\n after: { indices, rows: afterRows, rowCount: newData.length }\n })\n }\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n },\n\n // Emit 'update:merges' and record a merges diff entry.\n _emitMergeChange(oldMerges, newMerges) {\n if (!this._historySkip) {\n this._pushHistory({\n type: 'merges',\n before: oldMerges,\n after: newMerges\n })\n }\n this.$emit('update:merges', newMerges)\n },\n\n undo() {\n if (this.internalLocked) return\n if (this._historyIndex < 0) return\n const entry = this._history[this._historyIndex--]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.before.indices.length; i++) {\n newData[entry.before.indices[i]] = entry.before.rows[i]\n }\n // Assigning `undefined` to a trailing index doesn't shrink the\n // array — truncate explicitly when this entry added rows (e.g.\n // pasteGrowsRows), so undo actually removes them instead of\n // leaving `undefined` rows at the tail.\n if (entry.before.rowCount != null && entry.before.rowCount < newData.length) {\n newData.length = entry.before.rowCount\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.before.slice()\n this.$emit('update:merges', entry.before.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n redo() {\n if (this.internalLocked) return\n if (this._historyIndex >= this._history.length - 1) return\n const entry = this._history[++this._historyIndex]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.after.indices.length; i++) {\n newData[entry.after.indices[i]] = entry.after.rows[i]\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.after.slice()\n this.$emit('update:merges', entry.after.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n // =========================================================\n // TOOLBAR (fullscreen / lock / export / import / custom buttons)\n // =========================================================\n\n // Public, ref-callable: returns keyboard focus to the grid. Meant for\n // hosts whose action-cell `handler` opens a modal/popup outside the\n // grid's control — call this once that modal closes, otherwise the\n // browser leaves focus on document.body (its subtree was removed) and\n // arrow-key navigation goes dead until a cell is clicked again.\n focus() {\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Public, ref-callable: formats a number with a custom thousands\n // separator (decimal point stays '.', no forced/padded decimals). Any\n // value that isn't a finite number — already-formatted text like\n // \"6 000 000,00\", arbitrary strings, etc. — is returned unchanged, so\n // hosts can call this on raw cell values without checking the type first.\n formatNumber(value, separator) {\n return _formatNumber(value, separator)\n },\n\n // =========================================================\n // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by\n // localSyncKey (optionally namespaced by tenant), only when the page is\n // being left (not per-edit).\n // =========================================================\n\n // Shared by the localSyncKey and tenant watchers above — either one\n // changing means effectiveSyncKey now points at a (possibly) different\n // version history, so the badge/version-list state must be reloaded\n // from scratch rather than left stale.\n _reloadLocalSyncForKeyChange() {\n this.localSyncAvailable = false\n if (this.effectiveSyncKey) this._loadLocalVersionsOnMount()\n },\n\n // Internal, called once from mounted(): populates the toolbar \"unseen\n // local versions\" dot and emits local-sync-available if any versions\n // already exist for this key (e.g. saved in an earlier visit).\n async _loadLocalVersionsOnMount() {\n if (!this.localSyncKey) return\n await this._promoteBufferIfAny()\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n if (!versions.length) return\n this.localSyncAvailable = true\n this.$emit('local-sync-available', {\n key: this.localSyncKey,\n versions: versions.length,\n mostRecentUpdatedAt: versions[0].updatedAt\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n }\n },\n\n // Debounced (800ms), called from the grid's own real-change points\n // (_emitDataChange, import, undo, redo — the exact same \"user worked\"\n // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,\n // for the same reason spelled out on the localSyncKey prop's JSDoc: a\n // host loading rows from an API must never look like a user edit).\n //\n // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no\n // hash, no history) at a moment with no teardown race at all — the user\n // is actively still on the page. This is what actually fixes reload/\n // close data loss: a version-store write ATTEMPTED at the leave-signal\n // has no guarantee of finishing before an actual page teardown (verified\n // directly — even a trivial, unrelated IndexedDB write failed to survive\n // a real page.reload() in Chromium), but a buffer write that already\n // completed minutes or seconds earlier, during ordinary editing, needs no\n // such guarantee — it's already durably there. _promoteBufferIfAny()\n // turns it into a real version next time the grid mounts.\n _scheduleBufferWrite(data) {\n if (!this.localSyncKey) return\n const key = this.effectiveSyncKey // captured now, not re-read 800ms later —\n // a host that swaps localSyncKey/tenant mid-edit (rare, but now\n // reactive, see the watchers below) must not have this land under the\n // NEW key with the OLD key's data.\n clearTimeout(this._bufferWriteTimer)\n this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)\n },\n\n async _writeBufferNow(key, data) {\n if (!key) return\n try {\n await putBuffer(key, data)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Called once from _loadLocalVersionsOnMount(), before this key's version\n // list is read — folds a leftover buffer (from an edit that was saved\n // during the PREVIOUS visit but never made it into 'versions' — the\n // reload/close case this whole mechanism exists for) into a real version.\n // No time pressure here, so this uses the Worker-backed hashRowsAsync()\n // like any other non-leave-signal path.\n async _promoteBufferIfAny() {\n try {\n const buffered = await getBuffer(this.effectiveSyncKey)\n if (!buffered) return\n const hash = await hashRowsAsync(buffered.data)\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, buffered.data, hash, this.localSyncMaxVersions\n )\n await clearBuffer(this.effectiveSyncKey)\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: buffered.data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the\n // passive leave-triggers can't afford a Worker round-trip.\n _onVisibilityChangeForLocalSync() {\n if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)\n },\n\n _onPageHideForLocalSync() {\n this._flushLocalVersionOnHide(false, true)\n },\n\n // The core save-on-leave path — replaces a per-edit autosave (see the\n // localSyncKey prop's JSDoc for why: an autosave tied to \"data changed\"\n // can't distinguish a user's edit from a host loading rows from an API,\n // and would silently overwrite a user's saved work the moment a host\n // does the latter). Triggered from mounted()'s visibilitychange/pagehide\n // listeners and beforeDestroy() — see those for the three trigger points.\n //\n // Content-hashes the current `data` and either touches an existing local\n // version with the same hash (just refreshes its timestamp — \"ustiga\n // yoziladi, alohida versiya qilinmaydi\") or creates a brand-new one,\n // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's\n // saveOrTouchVersion()). Never throws — IndexedDB failures become\n // local-sync-error.\n //\n // `force` skips the \"nothing changed since the last flush\" short-circuit\n // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)\n // fire often on an idle tab and must NOT wake IndexedDB for no reason, so\n // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,\n // host/modal-initiated \"checkpoint now\" action — passes force so it never\n // silently does nothing: even unchanged data still gets its existing\n // version's timestamp refreshed via the same hash-dedup path, rather than\n // the caller seeing no effect at all.\n //\n // `sync` picks hashRows() (main thread) over the Worker-based\n // hashRowsAsync(). This was a real, observed bug, not a theoretical one:\n // a Worker round-trip (postMessage out, worker computes, postMessage\n // back) is one more hop through the event loop than a page that's\n // actually being torn down (as opposed to merely hidden — e.g. a tab\n // switch, where the page stays alive indefinitely and the extra hop\n // costs nothing) can be relied on to survive. visibilitychange/pagehide/\n // beforeDestroy all fire in exactly that \"might be torn down any moment\"\n // window, so they hash synchronously to remove that hop — the whole\n // flush then only has the unavoidable async part (the IndexedDB write\n // itself) left, giving it the best realistic chance to land before\n // teardown. saveLocalVersionNow() (an explicit host/modal action with no\n // teardown racing it) keeps the Worker, so hashing a large `data` array\n // doesn't block the main thread during ordinary use.\n async _flushLocalVersionOnHide(force = false, sync = false) {\n if (!this.localSyncKey) return\n if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush\n const data = this.data\n // Fires independently of the version-save attempt below, started as\n // early as possible — see _scheduleBufferWrite()'s comment. This is\n // the save that's actually likely to survive an imminent teardown;\n // the version-store write below is best-effort on top of it, reliable\n // for the common case (tab switch/background, page stays alive).\n clearTimeout(this._bufferWriteTimer)\n this._writeBufferNow(this.effectiveSyncKey, data)\n try {\n const hash = sync ? hashRows(data) : await hashRowsAsync(data)\n if (this.data !== data) return // data changed again mid-hash — a later flush will catch it\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, data, hash, this.localSyncMaxVersions\n )\n this._lastFlushedDataRef = data\n // The version store now has this content durably — the buffer's job\n // for it is done. Best-effort like everything else here: if this\n // doesn't complete before teardown, _promoteBufferIfAny() finds the\n // (now-redundant but harmless, dedup makes it a no-op touch) buffer\n // on next mount anyway.\n clearBuffer(this.effectiveSyncKey).catch(() => {})\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Public, ref-callable: runs the save-on-leave flow immediately, for a\n // host that wants its own explicit \"save checkpoint\" button instead of\n // waiting for a page-hide. Always goes through IndexedDB (force — see\n // _flushLocalVersionOnHide()), but still through the same hash-dedup\n // logic, so repeated calls with unchanged data don't spam duplicate\n // versions — they just refresh the existing matching version.\n async saveLocalVersionNow() {\n await this._flushLocalVersionOnHide(true)\n },\n\n // Public, ref-callable: lightweight metadata for every saved version of\n // this localSyncKey, newest first — no row data (use getLocalVersion(id)\n // for that). Powers the built-in modal's list; also usable to build a\n // custom UI.\n async listLocalVersions() {\n if (!this.localSyncKey) return []\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return []\n }\n },\n\n // Public, ref-callable: the full { id, data, createdAt, updatedAt } for\n // one saved version, WITHOUT applying it to v-model — the pure \"Read\"\n // for a custom UI (as opposed to restoreLocalVersion(), which reads AND\n // commits in one step).\n async getLocalVersion(id) {\n try {\n return await getVersion(id)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return null\n }\n },\n\n // Public, ref-callable: writes one saved version back into v-model\n // (input/update:data) — the modal's \"Restore\" button calls this too.\n async restoreLocalVersion(id) {\n try {\n const version = await this.getLocalVersion(id)\n if (!version) return\n this.$emit('input', version.data)\n this.$emit('update:data', version.data)\n this._lastFlushedDataRef = version.data // what's now on screen is already what's stored\n this.localSyncAvailable = false\n this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })\n this.closeLocalSyncModal()\n } catch (error) {\n this.$emit('local-sync-error', { action: 'restore', error })\n }\n },\n\n // Public, ref-callable: deletes one saved version — the modal's \"Delete\"\n // button calls this too (after its own inline confirm step).\n async deleteLocalVersion(id) {\n try {\n await deleteVersion(id)\n if (this.localSyncModal.selectedId === id) {\n this.localSyncModal.selectedId = null\n this.localSyncModal.selectedVersion = null\n }\n this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)\n this.$emit('local-sync-clear', { key: this.localSyncKey, id })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'clear', error })\n }\n },\n\n // Public, ref-callable: opens the local-sync modal (also reachable via\n // the toolbar button), loading the version list fresh each time.\n async openLocalSyncModal() {\n if (!this.localSyncKey) return\n this.localSyncModal.visible = true\n this.localSyncAvailable = false // the version(s) have now been seen\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Internal: modal's list-selection handler — loads the selected\n // version's full data for the nested-grid preview (EgLocalSyncModal.vue).\n async _selectLocalSyncModalVersion(id) {\n this.localSyncModal.selectedId = id\n this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null\n },\n\n // Modal's \"Save\" button — same as saveLocalVersionNow(), then refreshes\n // the modal's own list/selection in place so the user sees it happened.\n async _onLocalSyncModalSave() {\n await this.saveLocalVersionNow()\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Modal's \"Delete\" button (after its own inline confirm) — deletes the\n // given version, then selects the next-most-recent one if the deleted\n // one was selected.\n async _onLocalSyncModalDelete(id) {\n await this.deleteLocalVersion(id)\n if (this.localSyncModal.selectedId == null) {\n const remaining = this.localSyncModal.versions\n await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)\n }\n },\n\n // Public, ref-callable.\n closeLocalSyncModal() {\n this.localSyncModal.visible = false\n },\n\n toggleLock() {\n this.internalLocked = !this.internalLocked\n this.$emit('update:locked', this.internalLocked)\n this.$emit('lock-change', { locked: this.internalLocked })\n this._flashLock(this.internalLocked)\n },\n\n // Pops a big lock/unlock icon in the center of the grid and fades it\n // out — pure visual feedback, mirrors the video-player OSD pattern.\n // Triggered by the internalLocked watcher, so it covers every path that\n // can change the lock state (toolbar click, toggleLock(), :locked.sync).\n _flashLock(locked) {\n this.lockFlashLocked = locked\n this.lockFlashVisible = true\n this.$nextTick(() => {\n const el = this.$refs.lockFlashIconEl\n if (!el) return\n anime.remove(el)\n anime.set(el, { opacity: 0, scale: 0.5 })\n anime.timeline()\n .add({ targets: el, opacity: 1, scale: 1.12, duration: 180, easing: 'easeOutBack' })\n .add({ targets: el, scale: 1, duration: 120, easing: 'easeOutQuad' })\n .add({ targets: el, opacity: 0, duration: 350, easing: 'easeInQuad' }, '+=250')\n .finished.then(() => { this.lockFlashVisible = false })\n })\n },\n\n toolbarHas(key) {\n return this._toolbarList.indexOf(key) !== -1\n },\n\n toolbarDisabled(key) {\n return this._toolbarDisabledList.indexOf(key) !== -1\n },\n\n setSize(newSize) {\n if (!SIZE_CONFIG[newSize] || newSize === this.internalSize) return\n this.internalSize = newSize\n this.$emit('update:size', newSize)\n this.$emit('size-change', { size: newSize })\n },\n\n setTheme(newTheme) {\n if (THEME_LIST.indexOf(newTheme) === -1 || newTheme === this.internalTheme) return\n this.internalTheme = newTheme\n this.$emit('update:theme', newTheme)\n this.$emit('theme-change', { theme: newTheme })\n },\n\n sizeTitle(key) {\n return { small: this.t.sizeSmall, middle: this.t.sizeMiddle, large: this.t.sizeLarge }[key] || key\n },\n\n toggleFullscreen() {\n this.isFullscreen = !this.isFullscreen\n if (this.isFullscreen) {\n this._prevBodyOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n document.addEventListener('keydown', this._onFullscreenKeyDown)\n } else {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n this.$emit('fullscreen-change', { fullscreen: this.isFullscreen })\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n _onFullscreenKeyDown(e) {\n if (e.key === 'Escape' && this.isFullscreen) this.toggleFullscreen()\n },\n\n toolbarExportClick() {\n this.exportToExcel()\n },\n\n toolbarImportClick() {\n if (this.internalLocked) return\n const input = this.$refs.toolbarImportInput\n if (input) input.click()\n },\n\n onToolbarImportFile(e) {\n const file = e.target.files && e.target.files[0]\n e.target.value = ''\n if (!file || this.internalLocked) return\n this.importFromFile(file)\n },\n\n onToolbarButtonClick(btn, event) {\n if (typeof btn.onClick === 'function') btn.onClick(event, this)\n }\n }\n}\n\nexport default ExcelGridComponent\n</script>\n\n<style scoped>\n.eg-shell {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n\n.eg-shell-fullscreen {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n\n.eg-wrapper {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-toolbar {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-group {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n\n.eg-toolbar-group + .eg-toolbar-group {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-spacer {\n flex: 1 1 auto;\n}\n\n.eg-toolbar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged {\n position: relative;\n}\n.eg-toolbar-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-btn:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-btn-active {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n\n.eg-toolbar-select {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n\n.eg-toolbar-select:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n.eg-toolbar-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n\n.eg-toolbar-select:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-segmented {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n\n.eg-toolbar-segment {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon { width: 12px; height: 12px; }\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon { width: 15px; height: 15px; }\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon { width: 18px; height: 18px; }\n\n.eg-toolbar-icon {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n\n.eg-icon-undo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); }\n.eg-icon-redo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); }\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); }\n.eg-icon-import { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); }\n.eg-icon-print { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); }\n.eg-icon-local-sync { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); }\n.eg-icon-lock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); }\n.eg-icon-unlock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); }\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n.eg-icon-compress{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); }\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-middle { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-large { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); }\n\n.eg-container {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n\n.eg-header-area {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n\n.eg-header-cell {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n\n.eg-header-cell:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-header-group { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default)); }\n\n.eg-sort-icon {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon { opacity: 0.65; }\n.eg-sort-icon:hover { opacity: 1; }\n.eg-sort-icon.eg-sort-icon-active {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default)); }\n\n.eg-filter-input,\n.eg-filter-select {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input:focus,\n.eg-filter-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n\n.eg-col-resize-handle {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n\n.eg-body-area {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer,\n.eg-scroll-col-footer,\n.eg-pinned-right-footer {\n position: relative;\n}\n\n.eg-footer-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total {\n font-weight: 700;\n}\n\n.eg-row {\n position: relative;\n}\n\n.eg-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n\n.eg-locked .eg-cell { cursor: not-allowed; }\n\n.eg-cell-value {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right { justify-content: flex-end; }\n.eg-cell-align-center { justify-content: center; }\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value { text-align: right; }\n.eg-cell-align-center .eg-cell-value { text-align: center; }\n.eg-cell--has-suffix.eg-cell-select::after { display: none; }\n.eg-cell-select-trigger-wrap {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n\n.eg-cell-selected {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n\n.eg-cell-fill-preview {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n\n.eg-cell-copy {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n\n.eg-cell-merged-hidden {\n display: none !important;\n}\n\n.eg-cell-invalid {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n\n.eg-cell-input {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n\n.eg-row-header-cell {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-row-resize-handle {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n\n.eg-fill-handle {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n\n.eg-context-menu {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu {\n min-width: 110px;\n}\n\n.eg-context-item {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n\n.eg-context-item:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n\n.eg-context-separator {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n\n.eg-context-submenu-parent::after { content: ' ▶'; float: right; }\n\n.eg-context-submenu {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-context-submenu-parent:hover .eg-context-submenu { display: block; }\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-info-modal {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-info-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n\n.eg-info-modal-title {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-info-modal-close {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n\n.eg-info-modal-close:hover { color: var(--eg-color-text, var(--eg-color-text-default)); }\n\n.eg-info-modal-body {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.eg-group-btn {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n\n.eg-group-btn.collapsed { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default)); }\n\n.eg-drag-ghost {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging,\n.eg-container.eg-col-dragging * {\n cursor: grabbing !important;\n}\n\n.eg-resize-line {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header, .eg-frozen-col-body, .eg-frozen-col-footer { position: relative; }\n.eg-pinned-right-header, .eg-pinned-right-footer { position: relative; }\n\n.eg-frozen-col-header::after, .eg-frozen-col-body::after, .eg-frozen-col-footer::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge::after,\n.eg-frozen-col-body.eg-shadow-edge::after,\n.eg-frozen-col-footer.eg-shadow-edge::after {\n opacity: 1;\n}\n\n.eg-pinned-right-body {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n\n.eg-pinned-right-header::before, .eg-pinned-right-body::before, .eg-pinned-right-footer::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge::before,\n.eg-pinned-right-body.eg-shadow-edge::before,\n.eg-pinned-right-footer.eg-shadow-edge::before {\n opacity: 1;\n}\n\n.eg-pinned-right-header {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n\n.eg-import-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n\n.eg-lock-flash {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n\n.eg-import-dialog {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n\n.eg-import-title {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n\n.eg-import-bar-wrap {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n\n.eg-import-bar {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n\n.eg-import-msg {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n\n.eg-import-pct {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n@keyframes eg-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n.eg-skeleton {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer {\n 0% { background-position: 100% 50%; }\n 100% { background-position: 0 50%; }\n}\n@media (prefers-reduced-motion: reduce) {\n .eg-skeleton-block, .eg-loading-spinner { animation: none; }\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date::after { display: none; }\n.eg-cell-date::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap { justify-content: flex-end; }\n.eg-cell-align-center .eg-cell-action-wrap { justify-content: center; }\n.eg-validation-tooltip {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu,\n.eg-size-small .eg-context-item {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input,\n.eg-size-small .eg-filter-select {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu,\n.eg-size-large .eg-context-item {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input,\n.eg-size-large .eg-filter-select {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon {\n width: 13px;\n height: 13px;\n}\n</style>\n\n<style>\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px; }\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default)); }\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0); }\n.eg-select-btn--clear:hover { background: #fff0f0; }\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden; }\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default)); }\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default; }\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed; }\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); }\n</style>\n"]}, media: undefined })
19803
- ,inject("data-v-5dda8f14_1", { source: "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n}\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n}\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n}\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px;\n}\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n}\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default));\n}\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n}\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0);\n}\n.eg-select-btn--clear:hover { background: #fff0f0;\n}\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden;\n}\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default;\n}\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n}\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed;\n}\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default));\n}\n", map: {"version":3,"sources":["/Users/macmini/Projects/Realsoft/vue2-excel-grid/src/components/ExcelGrid.vue"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAo5QA;;;;;;;;;;;;;;;;iDAgBA;AACA;EACA,kBAAA;EACA,qBAAA;EACA,gBAAA;EACA,2DAAA;EACA,oBAAA;AACA;AACA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,WAAA;EACA,gBAAA;EACA,gBAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,oEAAA;EACA,eAAA;EACA,iBAAA;EACA,yDAAA;EACA,wDAAA;AACA;AACA,2BAAA,oEAAA;AAAA;AACA;EACA,oEAAA;EACA,sFAAA;AACA;AACA,+BAAA,gFAAA,EAAA,eAAA,EAAA,qEAAA;AAAA;AACA,2BAAA,OAAA,EAAA,gBAAA,EAAA,uBAAA,EAAA,mBAAA;AAAA;AACA,yBAAA,qEAAA;AAAA;AACA,mBAAA,cAAA,EAAA,qEAAA,EAAA,eAAA;AAAA;AACA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,wEAAA;EACA,gBAAA;EACA,iBAAA;EACA,cAAA;AACA;AACA;EACA,kBAAA;EACA,SAAA;EACA,OAAA;EACA,eAAA;EACA,YAAA;EACA,eAAA;AACA;AACA;EACA,gBAAA;EACA,yFAAA;EACA,gBAAA;EACA,MAAA;EACA,oEAAA;EACA,UAAA;AACA;AACA;EACA,WAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,gBAAA;EACA,2DAAA;EACA,aAAA;EACA,yDAAA;EACA,oEAAA;EACA,oBAAA;AACA;AACA;EACA,oEAAA;EACA,sFAAA;AACA;AACA;EACA,aAAA;EACA,uBAAA;EACA,QAAA;EACA,iBAAA;EACA,2DAAA;EACA,eAAA;EACA,mBAAA;EACA,sBAAA;EACA,iBAAA;EACA,yDAAA;AACA;AACA;iCACA,oFAAA;AAAA;AACA,4BAAA,gBAAA,EAAA,6DAAA;AAAA;AACA;kCACA,kGAAA;AAAA;AACA;EACA,kBAAA;EACA,kBAAA;EACA,2DAAA;EACA,qEAAA;EACA,iBAAA;AACA;AACA;EACA,eAAA;EACA,gBAAA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,sEAAA;EACA,yDAAA;EACA,2DAAA;EACA,iBAAA;EACA,oBAAA;EACA,mBAAA;EACA,sBAAA;EACA,cAAA;AACA;AACA;EACA,aAAA;EACA,yBAAA;EACA,oBAAA;EACA,4EAAA;AACA;AACA;EACA,gBAAA;EACA,wEAAA;EACA,yDAAA;EACA,iBAAA;EACA,eAAA;EACA,eAAA;EACA,yDAAA;AACA;AACA,uBAAA,oFAAA;AAAA;AACA,wBAAA,sCAAA,EAAA,6CAAA;AAAA;AACA,8BAAA,mBAAA;AAAA;AACA;;;;wBAIA;AACA;EACA,eAAA;EACA,iBAAA;AACA;AACA;EACA,eAAA;EACA,iBAAA;AACA;AACA;EACA,UAAA;EACA,WAAA;EACA,kBAAA;EACA,cAAA;EACA,qBAAA;EACA,eAAA;AACA;AACA,gBAAA;AACA;EACA,oBAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,cAAA;EACA,eAAA;EACA,qEAAA;EACA,eAAA;AACA;AACA,wBAAA,kBAAA;AAAA;AACA,eAAA;AACA;EACA,kBAAA;EACA,oBAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,eAAA;AACA;AACA;EACA,kBAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;AACA;AACA;EACA,kBAAA;EACA,cAAA;EACA,WAAA;EACA,YAAA;EACA,sBAAA;EACA,0EAAA;EACA,4BAAA;EACA,2BAAA;EACA,oBAAA;EACA,4FAAA;EACA,yDAAA;EACA,yFAAA;AACA;AACA,sCAAA,oEAAA;AAAA;AACA;EACA,wEAAA;EACA,+SAAA;EACA,wBAAA;EACA,oEAAA;AACA;AACA,sDAAA,sFAAA;AAAA;AACA;4CACA,eAAA;AAAA;AACA,iDAAA,sFAAA,EAAA,8EAAA;AAAA;AACA;gCACA,mBAAA;AAAA;AACA,yDAAA,8GAAA,EAAA,0GAAA;AAAA","file":"ExcelGrid.vue","sourcesContent":["<template>\n <div\n class=\"eg-shell\"\n :class=\"['eg-size-' + internalSize, 'eg-theme-' + internalTheme, { 'eg-shell-fullscreen': isFullscreen, 'eg-locked': internalLocked }]\"\n :style=\"shellStyle\"\n >\n <!-- Toolbar -->\n <div v-if=\"_toolbarVisible\" class=\"eg-toolbar\">\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canUndo || toolbarDisabled('undoRedo')\"\n :title=\"t.undo\"\n @click=\"undo\"\n ><span class=\"eg-toolbar-icon eg-icon-undo\"></span></button>\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canRedo || toolbarDisabled('undoRedo')\"\n :title=\"t.redo\"\n @click=\"redo\"\n ><span class=\"eg-toolbar-icon eg-icon-redo\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('export')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('export')\"\n :title=\"t.export\"\n @click=\"toolbarExportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-export\"></span></button>\n <button\n v-if=\"toolbarHas('import')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || toolbarDisabled('import')\"\n :title=\"t.import\"\n @click=\"toolbarImportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-import\"></span></button>\n <input\n v-if=\"toolbarHas('import')\"\n ref=\"toolbarImportInput\"\n type=\"file\"\n accept=\".xlsx,.xls,.csv\"\n style=\"display: none\"\n @change=\"onToolbarImportFile\"\n >\n <button\n v-if=\"toolbarHas('print')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('print')\"\n :title=\"t.print\"\n @click=\"printGrid\"\n ><span class=\"eg-toolbar-icon eg-icon-print\"></span></button>\n <button\n v-if=\"localSyncKey && toolbarHas('localSync')\"\n type=\"button\"\n class=\"eg-toolbar-btn eg-toolbar-btn-badged\"\n :disabled=\"toolbarDisabled('localSync')\"\n :title=\"localSyncAvailable ? t.localSyncAvailable : t.localSync\"\n @click=\"openLocalSyncModal\"\n ><span class=\"eg-toolbar-icon eg-icon-local-sync\"></span\n ><span v-if=\"localSyncAvailable\" class=\"eg-toolbar-badge\" aria-hidden=\"true\"></span></button>\n </div>\n\n <slot name=\"toolbar-extra\" :locked=\"internalLocked\" :fullscreen=\"isFullscreen\"></slot>\n\n <div v-if=\"toolbarButtons.length\" class=\"eg-toolbar-group\">\n <button\n v-for=\"btn in toolbarButtons\"\n :key=\"btn.key\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :title=\"btn.title\"\n @click=\"onToolbarButtonClick(btn, $event)\"\n ><span class=\"eg-toolbar-icon\" :style=\"btn.icon ? { maskImage: 'url(' + btn.icon + ')', webkitMaskImage: 'url(' + btn.icon + ')' } : null\">{{ btn.icon ? '' : (btn.label || '') }}</span></button>\n </div>\n\n <div class=\"eg-toolbar-spacer\"></div>\n\n <div v-if=\"toolbarHas('theme')\" class=\"eg-toolbar-group\">\n <select\n class=\"eg-toolbar-select\"\n :value=\"internalTheme\"\n :disabled=\"toolbarDisabled('theme')\"\n :title=\"t.theme\"\n @change=\"setTheme($event.target.value)\"\n >\n <option value=\"excel\">Excel</option>\n <option value=\"material\">Material</option>\n <option value=\"antd\">Ant Design</option>\n <option value=\"fluent\">Fluent</option>\n <option value=\"carbon\">Carbon</option>\n </select>\n </div>\n\n <div v-if=\"toolbarHas('size')\" class=\"eg-toolbar-segmented\" role=\"radiogroup\" :aria-label=\"t.size\">\n <button\n v-for=\"key in sizeOptions\"\n :key=\"key\"\n type=\"button\"\n role=\"radio\"\n class=\"eg-toolbar-btn eg-toolbar-segment\"\n :class=\"['eg-toolbar-segment-' + key, { 'eg-toolbar-btn-active': internalSize === key }]\"\n :aria-checked=\"internalSize === key\"\n :disabled=\"toolbarDisabled('size')\"\n :title=\"sizeTitle(key)\"\n @click=\"setSize(key)\"\n ><span class=\"eg-toolbar-icon\" :class=\"'eg-icon-size-' + key\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('lock')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': internalLocked }\"\n :disabled=\"toolbarDisabled('lock')\"\n :title=\"internalLocked ? t.unlock : t.lock\"\n @click=\"toggleLock\"\n ><span class=\"eg-toolbar-icon\" :class=\"internalLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"></span></button>\n <button\n v-if=\"toolbarHas('fullscreen')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': isFullscreen }\"\n :disabled=\"toolbarDisabled('fullscreen')\"\n :title=\"isFullscreen ? t.exitFullscreen : t.fullscreen\"\n @click=\"toggleFullscreen\"\n ><span class=\"eg-toolbar-icon\" :class=\"isFullscreen ? 'eg-icon-compress' : 'eg-icon-expand'\"></span></button>\n </div>\n </div>\n\n <div\n class=\"eg-wrapper\"\n :class=\"'eg-size-' + internalSize\"\n :style=\"wrapperStyle\"\n tabindex=\"0\"\n @keydown=\"onKeyDown\"\n @keypress=\"onKeyPress\"\n ref=\"wrapper\"\n >\n <!-- Context menu -->\n <div\n v-if=\"contextMenu.visible\"\n key=\"eg-context-menu\"\n ref=\"contextMenuEl\"\n class=\"eg-context-menu\"\n :style=\"{ top: contextMenu.y + 'px', left: (contextMenuPositioned ? contextMenu.x : -9999) + 'px', visibility: contextMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" @click=\"ctxCopy\">{{ t.copy }}</div>\n <div class=\"eg-context-item\" @click=\"ctxCut\">{{ t.cut }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPaste\">{{ t.paste }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item eg-context-submenu-parent\">\n {{ t.pasteSpecial }}\n <div class=\"eg-context-submenu\">\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all')\">{{ t.pasteAll }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('values')\">{{ t.pasteValues }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('formats')\">{{ t.pasteFormats }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all', true)\">{{ t.transpose }}</div>\n </div>\n </div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxMerge\">{{ t.mergeCells }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnmerge\">{{ t.unmergeCells }}</div>\n <div class=\"eg-context-separator\"></div>\n <div v-if=\"contextMenu.row >= 0\" class=\"eg-context-item\" @click=\"ctxHideRow\">{{ t.hideRows }}</div>\n <div v-if=\"contextMenu.col >= 0\" class=\"eg-context-item\" @click=\"ctxHideColumn\">{{ t.hideColumns }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideRows\">{{ t.unhideRows }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideColumns\">{{ t.unhideColumns }}</div>\n <template v-if=\"contextMenu.row < 0 && contextMenu.col >= 0\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxPinLeft\">{{ t.pinLeft }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPinRight\">{{ t.pinRight }}</div>\n <div class=\"eg-context-item\" v-if=\"isColPinned(contextMenu.col)\" @click=\"ctxUnpin\">{{ t.unpin }}</div>\n </template>\n <template v-if=\"devMode && (contextMenu.col >= 0 || contextMenu.row >= 0)\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.col >= 0\" @click=\"ctxShowColumnInfo\">{{ t.columnInfo }}</div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.row >= 0\" @click=\"ctxShowRowInfo\">{{ t.rowInfo }}</div>\n </template>\n </div>\n\n <!-- Sort popup menu (header sort icon) -->\n <div\n v-if=\"sortMenu.visible\"\n key=\"eg-sort-menu\"\n ref=\"sortMenuEl\"\n class=\"eg-context-menu eg-sort-menu\"\n :style=\"{ top: sortMenu.y + 'px', left: (sortMenuPositioned ? sortMenu.x : -9999) + 'px', visibility: sortMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('asc') }\" @click=\"sortMenuPickAsc\">{{ t.sortMenuAsc }}</div>\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('desc') }\" @click=\"sortMenuPickDesc\">{{ t.sortMenuDesc }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"sortMenuPickClear\">{{ t.sortMenuClear }}</div>\n </div>\n\n <!-- devMode: column/row info modal -->\n <div v-if=\"infoModal\" key=\"eg-info-modal-backdrop\" class=\"eg-info-modal-backdrop\" @click=\"closeInfoModal\">\n <div class=\"eg-info-modal\" @click.stop>\n <div class=\"eg-info-modal-header\">\n <span class=\"eg-info-modal-title\">{{ infoModal.title }}</span>\n <button type=\"button\" class=\"eg-info-modal-close\" @click=\"closeInfoModal\">×</button>\n </div>\n <pre class=\"eg-info-modal-body\">{{ infoModal.json }}</pre>\n </div>\n </div>\n\n <!-- Scroll container -->\n <div class=\"eg-container\" :class=\"{ 'eg-col-dragging': colDrag.active }\" ref=\"container\" @scroll=\"onScroll\" @click=\"hideFloatingMenus\">\n\n <!-- ===== HEADER AREA ===== -->\n <div class=\"eg-header-area\" :style=\"headerAreaStyle\">\n\n <!-- Corner cell -->\n <div\n class=\"eg-corner\"\n :style=\"cornerStyle\"\n @click=\"selectAll\"\n ></div>\n\n <!-- Row group outline gutter (left) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-gutter\" :style=\"rowOutlineGutterStyle\"></div>\n\n <!-- Frozen columns header -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, true)\">\n <div\n :key=\"'fh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, true)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'ffilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable columns header -->\n <div class=\"eg-scroll-col-header\" ref=\"scrollColHeader\" :style=\"scrollColHeaderStyle\">\n <!-- Inner width = scrollable columns only: this strip renders no pinned cells\n (they live in the sibling zones), and scrollableContentWidth keeps its max\n scrollLeft identical to the body's (see scrollColHeaderStyle). -->\n <div class=\"eg-scroll-col-header-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: (headerRowHeight * headerDepth + _filterRowH) + 'px', position: 'relative' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, false)\">\n <div\n :key=\"'sh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, false)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'sfilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns header -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellInZone(cell, rowIdx, 'right')\">\n <div\n :key=\"'prh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyleForZone(cell, rowIdx, 'right')\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prfilter-' + col._flatIndex\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(col._flatIndex)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- ===== BODY AREA ===== -->\n <div class=\"eg-body-area\" :style=\"bodyAreaStyle\">\n\n <!-- Row number header column -->\n <div class=\"eg-row-header-col\" :style=\"rowHeaderColStyle\">\n <!-- Scrollable row headers -->\n <div class=\"eg-scroll-row-headers\" :style=\"scrollRowHeadersStyle\" ref=\"scrollRowHeaders\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'srh-' + ri\"\n class=\"eg-row-header-cell\"\n :style=\"getRowHeaderStyle(ri)\"\n @mousedown=\"onRowHeaderMouseDown($event, ri)\"\n @contextmenu.prevent=\"onRowContextMenu($event, ri)\"\n >\n {{ ri + 1 }}\n <div class=\"eg-row-resize-handle\" @mousedown.stop=\"startRowResize($event, ri)\"></div>\n </div>\n </template>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Row group outline gutter (left side of rows) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-col\" :style=\"rowOutlineColStyle\">\n <template v-for=\"group in rowGroups\">\n <div\n :key=\"'rg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getRowGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n <!-- Main scroll body -->\n <div class=\"eg-scroll-body\" ref=\"scrollBody\" :style=\"scrollBodyStyle\" @scroll=\"onBodyScroll\">\n <!-- display:flex puts the two sticky pinned zones side by side at y=0 (as\n blocks they'd stack vertically, pushing pinned-right below the rows), and\n lets margin-left:auto give pinned-right its natural place at the inner's\n RIGHT edge — sticky right:0 only ever pulls an element back toward its\n natural position, so from x=0 (block flow) it would never stick at all.\n .eg-main-cells is position:absolute and doesn't participate. -->\n <div class=\"eg-scroll-body-inner\" :style=\"{ width: scrollContentWidth + 'px', height: totalVirtualHeight + 'px', position: 'relative', display: 'flex' }\">\n\n <!-- Pinned left column body -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'fc-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'fc-cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Main data cells -->\n <div class=\"eg-main-cells\" :style=\"mainCellsStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative', width: scrollableContentWidth + 'px' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'row-' + ri\"\n class=\"eg-row\"\n :class=\"{ 'eg-row--even': ri % 2 === 0 }\"\n :style=\"getRowStyle(ri)\"\n :data-row=\"ri\"\n >\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input\n v-else\n ref=\"editInput\"\n class=\"eg-cell-input\"\n v-model=\"editValue\"\n @input=\"onEditInput\"\n @blur=\"commitEdit\"\n @keydown.stop=\"onEditKeyDown\"\n />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Pinned Right column body -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'pr-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\">\n <template v-for=\"col in pinnedRightCols\">\n <div\n :key=\"'pr-cell-' + ri + '-' + col._flatIndex\"\n class=\"eg-cell\"\n :data-fi=\"col._flatIndex\"\n :class=\"getCellClasses(ri, col._flatIndex)\"\n :style=\"[getCellStyle(ri, col._flatIndex), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, col._flatIndex)\"\n @mouseover=\"onCellMouseOver($event, ri, col._flatIndex)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, col._flatIndex)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, col._flatIndex)\"\n >\n <div v-if=\"isMergedAway(ri, col._flatIndex)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, col._flatIndex)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, col._flatIndex)\"\n :disabled=\"!isCellEditable(ri, col._flatIndex)\"\n @change=\"(v) => setCheckboxValue(ri, col._flatIndex, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, col._flatIndex, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Fill handle (shown on primary range) -->\n <div\n v-if=\"fillHandleVisible\"\n class=\"eg-fill-handle\"\n :style=\"fillHandleStyle\"\n @mousedown.stop=\"startFillDrag\"\n ></div>\n\n <!-- Drag move ghost border -->\n <div\n v-if=\"dragMove.active\"\n class=\"eg-drag-ghost\"\n :style=\"dragGhostStyle\"\n ></div>\n </div>\n </div>\n\n <!-- Column group outline row (top, above body) -->\n <div v-if=\"colGroups.length\" class=\"eg-col-outline-row\" :style=\"colOutlineRowStyle\">\n <template v-for=\"group in colGroups\">\n <div\n :key=\"'cg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getColGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n </div><!-- end body-area -->\n\n <!-- ===== FOOTER AREA (column totals) ===== -->\n <div v-if=\"hasFooter\" class=\"eg-footer-area\" :style=\"footerAreaStyle\">\n\n <!-- \"Jami\" label cell (mirrors corner cell) -->\n <div class=\"eg-footer-label\" :style=\"footerLabelStyle\">{{ t.total }}</div>\n\n <!-- Frozen columns footer -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div :key=\"'ff-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable footer (horizontally synced with body via scrollFooter ref) -->\n <div class=\"eg-scroll-col-footer\" ref=\"scrollFooter\" :style=\"scrollColHeaderStyle\">\n <div class=\"eg-scroll-col-footer-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: footerRowHeight + 'px', position: 'relative' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div :key=\"'sf-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns footer -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prf-' + col._flatIndex\"\n class=\"eg-footer-cell\"\n :class=\"getFooterCellClass(col._flatIndex)\"\n :style=\"getFooterCellStyle(col._flatIndex)\"\n >{{ getColumnTotalDisplay(col._flatIndex) }}</div>\n </div>\n </div>\n </div><!-- end footer-area -->\n\n <!-- Resize indicator line -->\n <div v-if=\"resizing.active\" class=\"eg-resize-line\" :style=\"resizeLineStyle\"></div>\n\n <!-- Loading overlay (host-driven, e.g. during an async data fetch) -->\n <div v-if=\"loading\" class=\"eg-loading-overlay\">\n <template v-if=\"columns && columns.length\">\n <div class=\"eg-skeleton\">\n <div class=\"eg-skeleton-row eg-skeleton-row-header\" :style=\"{ height: headerRowHeight + 'px' }\">\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-h-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n <div\n v-for=\"r in 20\"\n :key=\"'sk-r-' + r\"\n class=\"eg-skeleton-row\"\n :style=\"{ height: _rowH + 'px' }\"\n >\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-c-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n </div>\n </template>\n <template v-else>\n <div class=\"eg-loading-spinner\"></div>\n </template>\n </div>\n </div><!-- end container -->\n\n <!-- Import progress overlay -->\n <div v-if=\"importState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl import qilinmoqda</div>\n <div class=\"eg-import-bar-wrap\">\n <div class=\"eg-import-bar\" :style=\"{ width: importState.percent + '%' }\"></div>\n </div>\n <div class=\"eg-import-msg\">{{ importState.message }}</div>\n <div class=\"eg-import-pct\">{{ importState.percent }}%</div>\n </div>\n </div>\n\n <!-- Export progress overlay -->\n <div v-if=\"exportState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl export qilinmoqda...</div>\n <div class=\"eg-export-spinner\"></div>\n </div>\n </div>\n\n <!-- Local sync modal (localSyncKey feature) -->\n <eg-local-sync-modal\n v-if=\"localSyncKey\"\n :visible=\"localSyncModal.visible\"\n :grid-options=\"_selfComponentDef\"\n :columns=\"_localSyncPreviewColumns\"\n :versions=\"localSyncModal.versions\"\n :selected-id=\"localSyncModal.selectedId\"\n :selected-version=\"localSyncModal.selectedVersion\"\n :locale=\"locale\"\n :theme=\"internalTheme\"\n @close=\"closeLocalSyncModal\"\n @select=\"_selectLocalSyncModalVersion\"\n @save=\"_onLocalSyncModalSave\"\n @restore=\"restoreLocalVersion\"\n @clear=\"_onLocalSyncModalDelete\"\n />\n\n <!-- Lock/unlock state feedback — brief centered icon flash -->\n <div v-if=\"lockFlashVisible\" class=\"eg-lock-flash\" aria-hidden=\"true\">\n <span\n ref=\"lockFlashIconEl\"\n class=\"eg-lock-flash-icon\"\n :class=\"lockFlashLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"\n ></span>\n </div>\n\n <!-- Select dropdown overlay (flat select) -->\n <eg-select\n v-if=\"selectDropdown && !selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n <!-- Tree select dropdown overlay -->\n <eg-tree-select\n v-if=\"selectDropdown && selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :children-key=\"selectDropdown.keys.childrenKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n\n <!-- Date picker overlay -->\n <eg-date-picker\n v-if=\"datePickerOverlay\"\n ref=\"datePickerComp\"\n :value=\"datePickerOverlay.value\"\n :format=\"datePickerOverlay.format\"\n :locale=\"locale\"\n :trigger-rect=\"datePickerOverlay.rect\"\n @pick=\"onDatePick\"\n @typing=\"onDateTyping\"\n @close=\"closeDatePicker\"\n />\n\n <!-- Validation error tooltip (cellValidator) -->\n <div\n v-if=\"invalidCell && invalidCell.rect\"\n class=\"eg-validation-tooltip\"\n :style=\"invalidTooltipStyle\"\n >{{ invalidCell.message }}</div>\n\n <!-- Column drag ghost — a header-shaped copy of the grabbed column that follows\n the cursor with the original grab point preserved (position updated via\n direct DOM in handleColDragMove) -->\n <div\n v-if=\"colDrag.active\"\n ref=\"colDragGhost\"\n class=\"eg-col-drag-ghost\"\n :style=\"{\n left: (colDrag.startX - colDrag.grabOffsetX) + 'px',\n top: (colDrag.startY - 14) + 'px',\n width: colDrag.ghostWidth + 'px',\n height: _headerRowH + 'px'\n }\"\n >{{ colDrag.ghostLabel }}</div>\n\n </div>\n </div>\n</template>\n\n<script>\nimport {\n normalizeRange, isCellInRange, isCellInAnyRange,\n buildRange, singleCellRange, getPrimaryRange\n} from '../utils/selection.js'\nimport { flattenColumns, buildHeaderRows, columnTreeDepth, moveColumnNodeInTree } from '../utils/headerTree.js'\nimport { calcVisibleRows, buildCumulativeHeights, findVisibleRange, getRowTop, getRowHeight } from '../utils/virtualScroll.js'\nimport { generateFillValues } from '../utils/fillHandle.js'\nimport {\n matrixToTSV, tsvToMatrix, extractMatrix, transposeMatrix, applyMatrix, tileMatrix\n} from '../utils/clipboard.js'\nimport {\n mergeHasConflict, findMergeAtCell, isMergeAnchor, addMerge,\n removeMergeAt, resolveMergeAnchor\n} from '../utils/merge.js'\nimport {\n addGroup, removeGroup, toggleGroupCollapse, isGroupCollapsed as _isGroupCollapsed,\n getCollapsedRowIndices, getCollapsedColIndices\n} from '../utils/outline.js'\nimport { exportToExcel as _exportToExcel } from '../utils/excelExport.js'\nimport { buildPrintTableHtml } from '../utils/printTable.js'\nimport { importFromFile as _importFromFile } from '../utils/excelImport.js'\nimport { createExcelImportWorker, createExcelExportWorker } from '../utils/workerFactory'\nimport { getMessages } from '../utils/i18n.js'\nimport EgCheckbox from './EgCheckbox.vue'\nimport anime from 'animejs'\nimport EgSelect from './EgSelect.vue'\nimport EgTreeSelect from './EgTreeSelect.vue'\nimport EgDatePicker from './EgDatePicker.vue'\nimport EgLocalSyncModal from './EgLocalSyncModal.vue'\nimport { parseDateValue, formatDate, DEFAULT_DATE_FORMAT } from '../utils/dateUtils.js'\nimport { formatNumber as _formatNumber } from '../utils/numberFormat.js'\nimport { Mask, MaskInput } from '../utils/mask.js'\nimport { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'\nimport { hashRows, hashRowsAsync } from '../utils/dataHash.js'\n\nconst SIZE_CONFIG = {\n small: { row: 22, header: 24, footer: 24, filter: 26 },\n middle: { row: 28, header: 28, footer: 28, filter: 32 },\n large: { row: 36, header: 36, footer: 36, filter: 40 }\n}\nconst ROW_HEADER_WIDTH = 50\nconst DEFAULT_COL_WIDTH = 100\nconst OUTLINE_GUTTER = 20\nconst FIT_COLUMNS_MIN_WIDTH = 40\nconst ROW_LIST_EDGE_PAD = 8 // birinchi/oxirgi qator bilan tepa/pastki border orasidagi bo'sh joy (px)\nconst FILL_DRAG_EDGE_ZONE = 24 // px from .eg-scroll-body's edge that triggers continuous auto-scroll while fill-dragging\nconst FILL_DRAG_MAX_SPEED = 18 // px/frame, at the very edge (scales down to 0 at the zone's outer boundary)\n\n// Classifies a col.width spec so resolveColWidth() knows how to turn it into a\n// concrete px number. Plain numbers/px strings resolve instantly with no DOM\n// work; '%' resolves via arithmetic against the grid's own measured viewport\n// width; any other CSS length (em, rem, vw, calc(), ...) needs a DOM probe.\nfunction parseColWidthSpec(spec) {\n if (typeof spec === 'number' && !isNaN(spec)) return { type: 'px', value: spec }\n if (typeof spec === 'string') {\n const trimmed = spec.trim()\n if (/^\\d+(\\.\\d+)?$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?px$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?%$/.test(trimmed)) return { type: 'percent', value: parseFloat(trimmed) }\n if (trimmed) return { type: 'css', value: trimmed }\n }\n return { type: 'px', value: DEFAULT_COL_WIDTH }\n}\n// Default toolbar tool list when `toolbar` is `true` or an object without\n// its own `list` — every tool group. Like `size`, the theme picker mirrors\n// its own `internalTheme` state and works standalone; `:theme.sync` is only\n// needed if the host wants to read the current theme back, not a prerequisite\n// for the picker to function.\nconst DEFAULT_TOOLBAR_LIST = ['undoRedo', 'export', 'import', 'print', 'localSync', 'size', 'lock', 'fullscreen', 'theme']\n// Column drag live-preview transition (AG Grid-like feel). Native CSS transitions\n// retarget smoothly from whatever intermediate position an element is currently\n// at when the drop slot changes mid-flight — no JS animation library races.\nconst COL_DRAG_TRANSITION = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)'\nconst THEME_LIST = ['excel', 'material', 'antd', 'fluent', 'carbon']\n\n// Kept as a named const (rather than an inline `export default {}`) so the\n// local-sync preview modal can receive the plain component definition\n// itself as a prop — see `_selfComponentDef` below. Importing ExcelGrid.vue\n// from EgLocalSyncModal.vue to nest a grid inside it would be a circular\n// import (ExcelGrid.vue -> EgLocalSyncModal.vue -> ExcelGrid.vue); passing\n// this reference down avoids that entirely, and avoids passing `this.$options`\n// (an instance's merged/runtime-contaminated options, not a clean definition).\nconst ExcelGridComponent = {\n name: 'ExcelGrid',\n\n components: { EgCheckbox, EgSelect, EgTreeSelect, EgDatePicker, EgLocalSyncModal },\n\n // v-model → :data + @input\n model: { prop: 'data', event: 'input' },\n\n props: {\n /** Array of row objects keyed by columns[].key */\n data: { type: Array, default: () => [] },\n\n /**\n * Column definitions (may be nested via children[]).\n *\n * `width` accepts anything the CSS `width` property accepts, not just a\n * raw px number:\n * - `Number` (e.g. `150`) or `'150px'` — fixed pixel width (fast path, no\n * DOM measurement).\n * - `'20%'` — relative to the grid's own rendered viewport width. Stays\n * responsive: recalculated whenever the grid's container is resized,\n * exactly like a real CSS percentage — until the column is manually\n * drag-resized (or auto-fit), at which point it becomes a fixed px\n * override and stops rescaling.\n * - Any other CSS length (`'10em'`, `'5rem'`, `'20vw'`, `calc(...)`, ...)\n * — resolved once via a hidden probe element measured against the\n * grid's own font-size/layout. `'auto'`/`'fit-content'` have no\n * intrinsic width this way and fall back to the 100px default; use the\n * `autoWidth` prop or double-click auto-fit for real content-fit sizing.\n *\n * `mask` — input mask for the plain-text editable input (engine adapted\n * from the `maska` library, vendored in `src/utils/mask.js`), applied\n * while typing and to every other value-entry path (paste, fill handle,\n * custom edit slots). Ignored by `select`/`treeSelect`/`date`/`checkbox`/\n * `action` columns, which use their own pickers. The masked/formatted\n * text is what gets written to `data` — display, storage, and export\n * all see the same string.\n * - `String` — e.g. `'(##) ###-##-##'` (`#` digit, `@` letter, `*`\n * alphanumeric by default). Use non-digit separators (spaces, dashes,\n * parens, letters) between token runs — a literal made of digits\n * (e.g. `'998##'`) can misalign against a raw/pasted value whose own\n * digits happen to coincide with the literal's; type the value through\n * the live cell editor or keep the fixed prefix outside the mask\n * (as static UI, e.g. a `#prefix-{key}` slot) if that's a concern.\n * - `Array<String>` — dynamic mask, picked by input length, e.g.\n * `['#-#', '##-##']`.\n * - `Object` — full maska options passthrough (`{ mask, tokens, eager,\n * reversed, ... }`) for custom tokens or advanced behavior.\n *\n * @example\n * columns: [\n * { key: 'id', title: '#', width: 60 },\n * { key: 'name', title: 'Name', width: '30%' },\n * { key: 'note', title: 'Note', width: '12em' },\n * { key: 'phone', title: 'Phone', mask: '(##) ###-##-##' }\n * ]\n */\n columns: { type: Array, default: () => [] },\n\n /** Indices of hidden leaf columns */\n hiddenColumns: { type: Array, default: () => [] },\n\n /** Indices of hidden rows */\n hiddenRows: { type: Array, default: () => [] },\n\n /**\n * Dynamic row visibility: (row, rowIndex) => boolean. Return false to\n * hide that row — same visual effect as `hiddenRows`/collapsed groups\n * (row is not rendered, but stays in `data`; nothing is deleted). Feeds\n * the same unification point as every other row-hiding mechanism, so it\n * automatically applies to virtual scroll, keyboard navigation, and\n * Excel export with no extra wiring.\n *\n * @example\n * // Hide inactive rows\n * <excel-grid :row-visible=\"(row) => row.status !== 'inactive'\" />\n * @example\n * // Hide every other row (by position, not by content)\n * <excel-grid :row-visible=\"(row, rowIndex) => rowIndex % 2 === 0\" />\n */\n rowVisible: { type: Function, default: null },\n\n /** Initial merge descriptors [{ startRow, startCol, endRow, endCol }] */\n merges: { type: Array, default: () => [] },\n\n /** Collapsed group descriptors [{ type, startIndex, endIndex }] */\n collapsedGroups: { type: Array, default: () => [] },\n\n /**\n * Height of the grid container in px.\n *\n * Omit it (or pass `null`) for auto-height: the grid sizes itself to fit\n * the header + filter row + all rows + footer for the current `data`\n * length, capped at 70% of the viewport height (`window.innerHeight`) —\n * beyond that it stops growing and scrolls internally, same as a fixed\n * `height` would. Recomputes reactively as `data` grows/shrinks and on\n * window resize. Does not apply in fullscreen mode (already sized to the\n * viewport there) and does not include the toolbar's own height.\n *\n * Pass an explicit `Number` to opt back into the old fixed-height behavior.\n */\n height: { type: Number, default: null },\n\n /**\n * Width of the grid container (px or a CSS length string).\n * Only applies while `widthMode` is `'full'` — in `'auto'` mode the grid\n * sizes itself to its columns and this prop is ignored.\n */\n width: { type: [Number, String], default: '100%' },\n\n /**\n * How the grid decides its overall width.\n *\n * - `'full'` (default) — the grid takes the `width` prop's value\n * (default `100%` of the host container), regardless of how little\n * space its columns actually need. Current/legacy behavior.\n * - `'auto'` — the grid hugs its content (max-content): its width is\n * the row-number gutter + the sum of all visible column widths\n * (live: reacts to column resize, hide/show, and `col.visible`),\n * plus the vertical scrollbar's width when rows overflow the height.\n * Capped at `max-width: 100%` — when the columns are wider than the\n * host, the grid stops at the host's edge and scrolls horizontally\n * inside, exactly like `'full'`. The `width` prop is ignored.\n *\n * Fullscreen mode always fills the viewport, whatever this is set to.\n *\n * @example\n * <excel-grid v-model=\"rows\" :columns=\"columns\" width-mode=\"auto\" />\n */\n widthMode: {\n type: String,\n default: 'full',\n validator: (v) => ['full', 'auto'].includes(v)\n },\n\n /**\n * Called before drag-drop: (sourceRange, targetRange) => bool\n * Return false to cancel the drop.\n */\n beforeDrop: { type: Function, default: null },\n\n /**\n * UI locale for the context menu.\n * Supported: 'en' | 'ru' | 'uz' | 'uz-Cyrl'\n */\n locale: { type: String, default: 'en' },\n\n /**\n * Pinned columns map: { [colKey]: 'left' | 'right' }\n * Pinned-left columns render as a fixed zone on the left.\n * Pinned-right columns render as a fixed zone on the right.\n * Emit 'update:pinnedColumns' to support .sync modifier.\n */\n pinnedColumns: { type: Object, default: () => ({}) },\n\n /**\n * Dynamic cell editability: (row, rowIndex, col, colIndex) => boolean\n * Overrides column-level editable when provided.\n * row = plain data object, col = column definition.\n */\n cellEditable: { type: Function, default: null },\n\n /**\n * Cell value validation: (value, row, rowIndex, col, colIndex) => boolean\n * Return false to reject the write — the cell keeps its old value and\n * an error tooltip is shown anchored to that cell.\n */\n cellValidator: { type: Function, default: null },\n\n /**\n * Error tooltip text for a rejected value.\n * Function: (value, row, rowIndex, col, colIndex) => string\n * String: same message for every rejected cell.\n * Default: locale's \"invalidValue\" message.\n */\n cellValidationMessage: { type: [Function, String], default: null },\n\n /**\n * Bulk-write strictness (paste / fill handle / Delete).\n * false (default): invalid cells in the affected range are skipped,\n * the rest of the operation still applies.\n * true: if ANY cell in the affected range fails validation, the whole\n * bulk operation is rejected — nothing is written.\n */\n strictBulkValidation: { type: Boolean, default: false },\n\n /**\n * Paste auto-grows rows: false (default) — pasting a clipboard matrix\n * taller than the remaining rows below the paste target clips to the\n * existing rows, same as before. true — missing rows are appended to\n * `data` to fit the full pasted range (Excel/Sheets-style). New rows'\n * columns not covered by the pasted matrix get a type-aware blank value\n * (`type: 'checkbox'` → false, everything else → ''). Columns are never\n * auto-created — pasting more columns than exist still clips.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows />\n */\n pasteGrowsRows: { type: Boolean, default: false },\n\n /**\n * Extra fields merged into rows auto-created by pasteGrowsRows, beyond\n * the type-aware column blanks. Rows only ever get values for `columns`\n * keys — anything else your app tracks per row (an id, a visibility\n * flag `rowVisible` depends on, a nested list, etc.) is NOT set unless\n * provided here, since the grid has no way to know about it.\n * Object: same fields merged into every new row.\n * Function(newRowIndex) => Object: computed per new row (e.g. a fresh\n * uuid per row). `newRowIndex` is the row's index in the resulting\n * `data` array.\n * Applied before the pasted values are written, so pasted cells always\n * win over any column-key present here.\n * For a single aggregate action after ALL rows from one paste have been\n * created and written to v-model, listen for the `rows-added` event\n * instead of doing it here (this runs once per row, before the commit).\n * @type {Object|Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows\n * :paste-row-defaults=\"() => ({ uuid: uuidv4(), state: 1 })\" />\n */\n pasteRowDefaults: { type: [Object, Function], default: null },\n\n /** Visual size of the grid. Affects row/header height and font sizes. */\n size: { type: String, default: 'middle' },\n\n /** Auto-fit all column widths to content (same as double-clicking each resize handle). Re-applies on data/columns change. */\n autoWidth: { type: Boolean, default: false },\n\n /**\n * Forces every visible leaf column to fit within a single viewport —\n * no horizontal scroll. Column widths are still resolved (`col.width`,\n * `%`/`px`/other CSS lengths, `DEFAULT_COL_WIDTH` when omitted) but only\n * as RELATIVE WEIGHTS: all resolved widths are scaled by one shared\n * factor so their sum exactly fills the available width, keeping each\n * column's proportion to the others. Each column still gets at least\n * `40px` — if there are enough columns that even that minimum doesn't\n * fit, horizontal scroll returns for that (rare) case rather than\n * shrinking columns illegibly thin.\n *\n * Highest-priority width control: overrides `col.width` as an absolute\n * value and `autoWidth`. Manual column resize (drag handle) and\n * double-click auto-fit ARE both allowed — either one turns that column\n * into a fixed px width, excluded from the fit; the rest keep scaling\n * proportionally to fill what's left, which can reintroduce horizontal\n * scroll if the fixed column(s) alone exceed the viewport. Pinned\n * columns count toward the fit too — the goal is the whole row fitting,\n * regardless of pin state.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" fit-columns />\n */\n fitColumns: { type: Boolean, default: false },\n\n /**\n * Allow columns to be reordered by dragging their headers.\n * On drop, emits 'update:columns' (compatible with .sync) and 'column-reorder'.\n */\n columnDraggable: { type: Boolean, default: true },\n\n /**\n * Controls the built-in toolbar above the grid — visibility as a whole,\n * and (optionally) which individual tool groups appear. Controlled\n * entirely by the parent — no in-component toggle for the bar itself.\n *\n * @type {Boolean|{visible?: Boolean, list?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>, disabled?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>}}\n * @default false\n *\n * Two forms:\n * - `Boolean` — `false` (default) hides the toolbar entirely; `true`\n * shows it with the default tool list — every group, including\n * `'theme'` (it mirrors its own internal state like `size` does;\n * `:theme.sync` is optional, only needed if the parent wants to read\n * the current theme back).\n * - `Object { visible, list, disabled }` — fine-grained control:\n * - `visible` {Boolean} — defaults to `true` when an object is given\n * (passing an object is itself an opt-in to show the toolbar);\n * set explicitly to `false` to hide it while still describing a\n * `list` you intend to reuse later.\n * - `list` {Array<String>} — which tool groups render, using the\n * identifiers above. Unknown/misspelled strings are silently\n * ignored. Omitting `list` falls back to the same default set as\n * the `true` shorthand. **Order in `list` does not change the\n * toolbar's layout** — groups always render in a fixed position\n * (undo/redo → export/import/print → #toolbar-extra slot → toolbarButtons\n * → spacer → theme → size → lock/fullscreen); `list` is purely an\n * inclusion filter.\n * - `disabled` {Array<String>} — which tool groups render grayed out\n * and unclickable while staying **visible** (same identifiers as\n * `list`) — distinct from `list`, which removes a tool entirely.\n * Scoped to the toolbar button itself: it doesn't block the\n * underlying keyboard shortcut (only `'undoRedo'` has one —\n * Ctrl+Z/Ctrl+Y) or a ref method call (`undo()`, `exportToExcel()`,\n * etc.), matching how `list` also only hides the button, not the\n * shortcut/method. Omitting `disabled` disables nothing (default).\n *\n * `toolbarButtons` and the `#toolbar-extra` slot are independent of\n * this prop — they render whenever content is provided, regardless of\n * `list`/`disabled`.\n *\n * @example\n * // Whole default set\n * <excel-grid toolbar />\n * @example\n * // Only undo/redo and fullscreen\n * <excel-grid :toolbar=\"{ list: ['undoRedo', 'fullscreen'] }\" />\n * @example\n * // Toggle visibility from a data property while keeping a fixed list\n * <excel-grid :toolbar=\"{ visible: showToolbar, list: ['export', 'import'] }\" />\n * @example\n * // Visible but grayed out — e.g. import disabled while a prior import is processing\n * <excel-grid :toolbar=\"{ disabled: ['import', 'size'] }\" />\n * @example\n * // Read the current theme back (optional — the picker works without it too)\n * <excel-grid toolbar :theme.sync=\"myTheme\" />\n */\n toolbar: { type: [Boolean, Object], default: false },\n\n /** Extra custom toolbar buttons: [{ key, icon, title, onClick(event, gridApi) }] */\n toolbarButtons: { type: Array, default: () => [] },\n\n /**\n * Print (`printGrid()`/toolbar \"Print\" tugmasi) chiqishida jadval tepasida,\n * markazda ko'rinadigan sarlavha. `null` (standart) — sarlavha umuman\n * render qilinmaydi.\n * @property {String} title - sarlavha matni (bo'sh/undefined bo'lsa render qilinmaydi)\n * @property {Object} [style] - Vue :style uslubidagi obyekt (camelCase),\n * standart markazlashtirilgan/qalin uslub USTIGA qo'shiladi\n * @property {String} [class] - qo'shimcha CSS klass nomi(lari)\n * @example\n * <excel-grid :print-title=\"{ title: 'Talabalar ro\\'yxati', style: { color: '#1a73e8' } }\" />\n */\n printTitle: { type: Object, default: null },\n\n /**\n * Locks the entire grid: blocks all edits (typing, paste, fill, delete,\n * checkbox, select/treeSelect) and structural changes (resize, column\n * reorder, merge/unmerge, undo/redo). Independent from cellEditable/\n * col.editable, which remain per-cell rules. Emit 'update:locked' to\n * support the .sync modifier.\n */\n locked: { type: Boolean, default: false },\n\n /**\n * Host-driven loading state (e.g. while an async data fetch is in\n * flight). When `true`, only the grid body (`.eg-container`) is\n * covered — the toolbar stays visible and interactive. If `columns`\n * is non-empty, a shimmering skeleton matching each column's width is\n * shown; otherwise a plain centered spinner is shown. Purely visual —\n * doesn't block edits/interaction the way `locked` does (combine both\n * if that's also needed).\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :loading=\"isFetching\" />\n */\n loading: { type: Boolean, default: false },\n\n /**\n * Data-driven per-row styling: `(row, rowIndex) => styleObject`. The\n * returned style object is applied to every cell in that row (across\n * the frozen/scrollable/pinned-right zones alike), giving the row a\n * continuous horizontal background/style without any host-side CSS.\n * Selection/active/fill-preview/readonly cell highlighting keeps\n * winning visually — those already use `!important`, which always\n * beats a plain (non-`!important`) inline style. Doesn't affect the\n * row-number header column (mirrors Excel: conditional formatting\n * colors data cells, not the row gutter). Return `null`/`undefined`\n * for rows that shouldn't get a custom style.\n * @type {Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :row-style=\"(row) => row.status === 'error' ? { background: '#fde2e2' } : null\" />\n */\n rowStyle: { type: Function, default: null },\n\n /** Show \"Column info\" / \"Row info\" items in the context menu (dev/debug aid). */\n devMode: { type: Boolean, default: false },\n\n /**\n * Visual theme of the grid.\n * Built-in: 'excel' | 'material' | 'antd' | 'fluent' | 'carbon'\n * Default is the classic Excel 365 look. Use 'material' for Material Design 3.\n * Mirrors the `size` prop's convention: an internal `internalTheme` state\n * mirrors this prop and is what the toolbar's theme picker actually reads\n * from/writes to (via `setTheme()`), so the picker works standalone even\n * without `:theme.sync` — `.sync` is only needed to reflect the current\n * theme back into the host's own state.\n */\n theme: { type: String, default: 'excel' },\n\n /**\n * Shows a filter row directly beneath the column headers (inside the\n * sticky header area). Each leaf column gets a filter control matched to\n * its `type`:\n * - default (no type / 'date' / 'progress'): text search input,\n * matched case-insensitively against the column's *displayed* value\n * (so 'date' matches the formatted string, not the raw stored value).\n * - 'select' / 'treeSelect' *with a static `col.options` array*: a\n * native <select> populated from those options (tree options are\n * flattened, indented by depth), matched by exact raw value.\n * - 'select' / 'treeSelect' with function-based `col.options` (options\n * computed per-row): falls back to the text control, since a single\n * fixed option list can't be resolved outside a specific row.\n * - 'checkbox': a tri-state <select> — All / Checked / Unchecked.\n * - 'action': no filter control (not a data-bound column).\n *\n * Rows that don't match every active filter are hidden using the same\n * mechanism as `hiddenRows` — virtual scroll, keyboard navigation, and\n * `exportToExcel()` all skip them automatically, with no separate\n * filtering code path to keep in sync.\n *\n * Set `col.filterable: false` to omit the filter control for one column\n * (its filter cell renders empty). Set\n * `col.filterFn(cellValue, filterValue, row, rowIndex, column)` to\n * override the default match logic for that column entirely.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row />\n */\n filterRow: { type: Boolean, default: false },\n\n /**\n * Current filter values, keyed by column `key`. Optional — the filter\n * row works standalone via internal state (`internalFilters`) even\n * without `.sync`, mirroring the `size`/`theme` prop convention. Bind\n * `:filters.sync` only if the host needs to read or restore the current\n * filter state.\n * @type {Object}\n * @default {}\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row :filters.sync=\"myFilters\" />\n */\n filters: { type: Object, default: () => ({}) },\n\n /**\n * Shows a sort icon (kebab/\"more actions\" glyph) in every leaf column\n * header. Clicking it opens a popup menu — A → Z / Z → A / Clear sort —\n * matched to Excel's own column-header dropdown. Picking a different\n * column's option replaces the active sort — single-column sort only.\n *\n * Sorting **physically reorders** the `data` array (like a real\n * spreadsheet sort) and emits it through the normal v-model/`input`\n * event — it is recorded in the undo/redo history exactly like any\n * other data edit, so `undo()` restores the pre-sort row order.\n * \"Clear sort\" (or `clearSort()`) both clears the indicator **and**\n * restores the row order from immediately before the first sort of\n * this session — switching columns or direction in between doesn't\n * move that reference point. If rows were added/removed since sorting\n * (row count no longer matches), the restore is skipped and only the\n * indicator clears, to avoid silently dropping/duplicating rows.\n *\n * Comparison is matched to the column's `type`: plain/`'progress'`\n * columns compare the raw value (numeric-aware); `'date'` compares the\n * parsed date chronologically, not the formatted string; `'select'` /\n * `'treeSelect'` compare the displayed option label; `'checkbox'`\n * compares the boolean state. Empty/`null` values always sort last,\n * regardless of direction. `'action'` columns are never sortable.\n *\n * Set `col.sortable: false` to omit the icon for one column. Set\n * `col.sortFn(rowA, rowB, column) => number` to fully override the\n * comparison for that column.\n *\n * Blocked (no-op) while the grid is `locked`, or while any merged\n * cells or row outline groups exist — both are anchored to row\n * *position*, which a reorder would silently invalidate.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable />\n */\n sortable: { type: Boolean, default: false },\n\n /**\n * Current sort state — `{ key, direction }` (`direction` is `'asc'` or\n * `'desc'`) or `null` when unsorted. Optional — sorting works standalone\n * via internal state (`internalSort`) even without `.sync`, mirroring\n * the `size`/`theme`/`filters` prop convention. Bind `:sort.sync` only\n * if the host needs to read or restore the current sort state.\n * @type {Object}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable :sort.sync=\"mySort\" />\n */\n sort: { type: Object, default: null },\n\n /**\n * Unique key that opts the grid into local version history: when the page\n * is being left (tab hidden/closed, or this component unmounted — NOT on\n * every edit), the current `v-model` is content-hashed (via a Web Worker\n * when available, so the main thread stays responsive right as the page\n * may be unloading) and saved to the browser's IndexedDB as a **version**,\n * keyed by this string. If the hash matches an already-saved version for\n * this key, that version's timestamp is refreshed instead of creating a\n * duplicate — only genuinely different content produces a new version.\n * Also adds a toolbar button ('localSync' — see the `toolbar` prop's\n * `list`/`disabled`) that opens a modal listing saved versions, each with\n * a read-only preview and **Restore** / **Delete** actions, plus a\n * **Save** button for an explicit checkpoint.\n *\n * Two different grid instances must use different keys — the same key\n * means \"the same version history\" (concurrent tabs interleave). Omit\n * this prop entirely to opt out completely: no IndexedDB access happens\n * at all, and the toolbar button never renders.\n *\n * See also `localSyncMaxVersions`, the ref-callable methods\n * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,\n * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,\n * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the\n * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`\n * events — [details](/local-sync).\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" toolbar />\n */\n localSyncKey: { type: String, default: null },\n\n /**\n * Optional tenant/organization identifier for multi-tenant hosts (SaaS\n * apps where the same browser/device is used across different\n * organizations). When set, it's combined with `localSyncKey`\n * internally (`${tenant}::${localSyncKey}`) so each tenant gets a fully\n * isolated IndexedDB version history — switching tenants on the same\n * device never shows another tenant's saved drafts. Omit it (default)\n * for single-tenant hosts; behavior is then identical to before this\n * prop existed.\n *\n * Has no effect without `localSyncKey` — it only changes how that key\n * is namespaced internally, it doesn't opt into the feature by itself.\n * Reactive: changing `tenant` (e.g. after an async \"current\n * organization\" load) reloads that tenant's own version list/buffer,\n * the same way a `localSyncKey` that starts empty and becomes truthy\n * later does. Avoid `::` inside `tenant`/`localSyncKey` values\n * themselves — it's the internal separator between the two.\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :tenant=\"currentOrgId\" toolbar />\n */\n tenant: { type: String, default: null },\n\n /**\n * Maximum number of local versions kept per `localSyncKey` (per\n * `tenant`, when set). Once a new version pushes the count past this,\n * the oldest version(s) (by `createdAt`) are deleted automatically\n * (FIFO). Has no effect without `localSyncKey`.\n * @type {Number}\n * @default 10\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :local-sync-max-versions=\"20\" />\n */\n localSyncMaxVersions: { type: Number, default: 10 }\n },\n\n data() {\n return {\n // Selection\n activeCell: { row: 0, col: 0 },\n selectionRanges: [], // [{ startRow, startCol, endRow, endCol }]\n isMouseDown: false,\n mouseDownRange: null,\n isCtrlDrag: false,\n\n // Edit\n editingCell: null, // { row, col }\n editValue: '',\n editOriginalValue: '',\n\n // Scroll\n scrollTop: 0,\n scrollLeft: 0,\n headerViewportWidth: null,\n // The grid's DOM parent's width — the reference for relative (%, css)\n // column widths, in BOTH widthMode values (see _relativeWidthContainer).\n // In 'auto' mode the shell's width is DERIVED from column widths, so\n // resolving a relative column width against the shell's own measured\n // viewport (headerViewportWidth) is circular (each resolve nudges the\n // shell size, which nudges the next resolve...). 'full' mode isn't\n // circular that way, but headerViewportWidth (scrollBody.clientWidth)\n // turned out to have its OWN instability source: a vertical scrollbar\n // toggling on/off flips it by the scrollbar's width even though the\n // shell's own outer size never changes — confirmed via a real host\n // report (repeated brief re-render bursts hurting CLS) that 'auto'\n // mode didn't reproduce. The parent's width doesn't depend on the\n // grid's own rendered size OR its internal scrollbar state, so using\n // it everywhere sidesteps both instability sources uniformly.\n hostContainerWidth: null,\n // Real vertical-scrollbar width measured off the scroll body (0 for\n // macOS overlay scrollbars) — widthMode:'auto' adds it to the natural\n // width so the scrollbar never eats into the last column.\n vScrollbarW: 0,\n // .eg-shell's horizontal border total (border-box). Measured at mount —\n // themes can restyle it — so widthMode:'auto' accounts for it instead\n // of shaving those pixels off the last column.\n shellBorderW: 2,\n\n // Auto-height (height prop omitted) — 70vh cap, kept in sync on resize\n windowInnerH: typeof window !== 'undefined' ? window.innerHeight : 800,\n\n // Virtual scroll\n virtualStartIndex: 0,\n virtualEndIndex: 0,\n virtualOffsetTop: 0,\n totalVirtualHeight: 0,\n\n // Column widths (flat index → px)\n colWidths: {},\n\n // Flat index → raw CSS width spec, for columns whose width is a relative\n // unit ('20%', '10em', ...) and therefore needs re-resolving whenever the\n // grid's viewport width changes. Cleared for a column once it's manually\n // resized/auto-fit — see startColResize/autoFitColumn/_applyAutoWidth.\n relativeColSpecs: {},\n\n // col.key → true, for columns manually resized while fitColumns is on.\n // Such a column becomes a fixed px width (excluded from the proportional\n // fit); the rest keep scaling among themselves to fill what's left — see\n // _applyFitColumns(). Keyed by col.key (not flat index) so it survives\n // column reorders, same convention as relativeColSpecs preservation.\n fitColumnsOverrides: {},\n\n // Row heights (row index → px)\n rowHeights: {},\n\n // Import/export progress\n importState: { active: false, percent: 0, message: '' },\n exportState: { active: false },\n\n // localSyncKey feature — modal state: the version list (metadata only)\n // plus which one is selected/previewed. `selectedVersion` holds that\n // version's full { id, data, createdAt, updatedAt } for the nested-grid\n // preview (see EgLocalSyncModal.vue).\n localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },\n // True when one or more versions already existed at mount and the user\n // hasn't opened the modal yet — drives the toolbar button's \"unseen\n // local versions\" dot. See _loadLocalVersionsOnMount() for the full lifecycle.\n localSyncAvailable: false,\n\n resizing: {\n active: false,\n type: null, // 'col' | 'row'\n index: -1,\n startX: 0,\n startY: 0,\n startSize: 0,\n currentPos: 0\n },\n\n // Fill handle\n fillDrag: {\n active: false,\n sourceRange: null,\n currentRow: -1,\n currentCol: -1\n },\n\n // Drag-move\n dragMove: {\n active: false,\n sourceRange: null,\n startX: 0,\n startY: 0,\n targetRow: -1,\n targetCol: -1,\n copyMode: false\n },\n\n // Clipboard (internal)\n clipboardMatrix: null,\n clipboardRange: null,\n clipboardMode: null, // 'copy' | 'cut'\n\n // Context menu\n contextMenu: { visible: false, x: 0, y: 0, row: -1, col: -1 },\n // false while the menu's real size hasn't been measured yet — kept hidden\n // (not painted) so the flip-to-fit repositioning in _positionContextMenu()\n // doesn't cause a visible jump.\n contextMenuPositioned: false,\n\n // Sort popup menu (header sort icon click) — { visible, x, y, key }\n sortMenu: { visible: false, x: 0, y: 0, key: null },\n sortMenuPositioned: false,\n\n // devMode — column/row config info modal ({ title, json } | null)\n infoModal: null,\n\n // Select dropdown\n selectDropdown: null, // { row, col, options, currentValue, rect }\n\n // Date picker overlay\n datePickerOverlay: null, // { row, col, format, value, rect }\n\n // Cell value validation (cellValidator)\n invalidCell: null, // { row, col, message, rect }\n\n // Merges (controlled via prop + update:merges)\n internalMerges: [],\n\n // Toolbar (controlled via prop + update:locked / update:size / update:theme)\n internalLocked: false,\n internalSize: 'middle',\n internalTheme: 'excel',\n // Pinned columns — merges col.pin (columns config default) with pinnedColumns prop.\n // context-menu pin/unpin updates this directly so .sync is not required.\n internalPinnedColumns: {},\n // Brief centered icon feedback whenever the lock state actually changes\n // (toolbar click, toggleLock(), or a host-driven :locked.sync change).\n lockFlashVisible: false,\n lockFlashLocked: false,\n isFullscreen: false,\n // Bumped whenever _history/_historyIndex change so canUndo/canRedo stay reactive.\n historyVersion: 0,\n\n // Groups\n internalGroups: [], // [{ type, startIndex, endIndex }]\n internalCollapsedGroups: [],\n\n // Header\n headerDepth: 1,\n headerRows: [],\n\n // Leaf columns cache\n leafCols: [],\n\n // Footer column totals (flat col index → number | null)\n columnTotals: {},\n\n // Filter row (controlled via prop + update:filters)\n internalFilters: {},\n // Row indices currently hidden by an active filter — merged into\n // hiddenRowSet, so virtual scroll/export/navigation need no separate path.\n filteredRowIndices: [],\n\n // Sort (controlled via prop + update:sort) — { key, direction } | null\n internalSort: null,\n\n // Column drag-and-drop state\n colDrag: {\n pending: false, // mousedown received, waiting for movement threshold\n active: false, // drag in progress\n sourceFirstLeaf: -1, // first leaf flat-index of the dragged node\n sourceLeafCount: 1, // number of leaves in the dragged node (1=leaf, N=group)\n dropIndex: -1, // insert-before flat index (0..N)\n startX: 0,\n startY: 0,\n ghostLabel: '',\n ghostWidth: 0, // ghost mirrors the grabbed header's real width (capped)\n grabOffsetX: 0 // cursor offset inside the grabbed header — keeps the grab point under the cursor\n },\n }\n },\n\n computed: {\n t() { return getMessages(this.locale) },\n\n // localSyncKey feature — the actual IndexedDB storage key. idbStore.js\n // itself knows nothing about \"tenants\"; this is the one place that\n // namespaces localSyncKey by tenant when one is given, so every\n // storage-layer call site below just uses this instead of\n // this.localSyncKey directly. null whenever localSyncKey itself is\n // unset — tenant alone doesn't opt into the feature.\n effectiveSyncKey() {\n if (!this.localSyncKey) return null\n return this.tenant ? `${this.tenant}::${this.localSyncKey}` : this.localSyncKey\n },\n\n pinnedLeftSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'left') s.add(col._flatIndex) })\n return s\n },\n\n pinnedRightSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'right') s.add(col._flatIndex) })\n return s\n },\n\n pinnedLeftCols() { return this.leafCols.filter(col => this.pinnedLeftSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n pinnedRightCols() { return this.leafCols.filter(col => this.pinnedRightSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n\n hasPinnedLeft() { return this.pinnedLeftSet.size > 0 },\n hasPinnedRight() { return this.pinnedRightSet.size > 0 },\n\n hasFooter() { return this.leafCols.some(col => this.isColumnTotalVisible(col)) },\n\n pinnedLeftWidth() {\n let w = 0\n for (const col of this.pinnedLeftCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n pinnedRightWidth() {\n let w = 0\n for (const col of this.pinnedRightCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n totalRows() { return this.data.length },\n\n // See the `toolbar` prop's JSDoc for the full Boolean|Object contract.\n _toolbarVisible() {\n if (typeof this.toolbar === 'boolean') return this.toolbar\n return !!this.toolbar && this.toolbar.visible !== false\n },\n _toolbarList() {\n if (this.toolbar && Array.isArray(this.toolbar.list)) return this.toolbar.list\n return DEFAULT_TOOLBAR_LIST\n },\n // Same key set as _toolbarList, but for the disabled (grayed out, still visible) state\n // instead of visibility — see toolbar prop JSDoc.\n _toolbarDisabledList() {\n if (this.toolbar && Array.isArray(this.toolbar.disabled)) return this.toolbar.disabled\n return []\n },\n\n // Columns shown in the local-sync modal's preview table — action columns\n // are pure interactive UI (buttons), not printable/previewable data,\n // same exclusion printGrid()/exportToExcel() already apply.\n _localSyncPreviewColumns() {\n return this.leafCols.filter(col => col.type !== 'action')\n },\n\n // Passed to EgLocalSyncModal so it can render the snapshot preview as a\n // real, nested ExcelGrid instance — see the comment on ExcelGridComponent\n // above for why this is the module-level const and not `this.$options`.\n _selfComponentDef() {\n return ExcelGridComponent\n },\n\n _sizeConf() { return SIZE_CONFIG[this.internalSize] || SIZE_CONFIG.middle },\n\n sizeOptions() { return Object.keys(SIZE_CONFIG) },\n _rowH() { return this._sizeConf.row },\n _headerRowH() { return this._sizeConf.header },\n _footerRowH() { return this._sizeConf.footer },\n _filterRowH() { return this.filterRow ? this._sizeConf.filter : 0 },\n\n headerRowHeight() { return this._headerRowH },\n footerRowHeight() { return this._footerRowH },\n\n // Pre-built cumulative height array — O(n) once, then cached by Vue.\n // findVisibleRange() uses binary search against this cached array → O(log n) per scroll.\n // Invalidated only when row count, custom heights, or hidden rows change.\n cumulativeHeights() {\n return buildCumulativeHeights(this.totalRows, this._rowH, this.rowHeights, this.hiddenRowSet, ROW_LIST_EDGE_PAD, ROW_LIST_EDGE_PAD)\n },\n\n // height prop omitted → fit header + filter row + all (visible) rows + footer exactly.\n _autoContentHeight() {\n const headerH = this._headerRowH * this.headerDepth + this._filterRowH\n const bodyH = this.cumulativeHeights[this.totalRows] || 0\n const footerH = this.hasFooter ? this._footerRowH : 0\n return headerH + bodyH + footerH\n },\n\n // Resolved grid-area height: explicit `height` prop wins as-is (old\n // behavior); omitted → auto-fit content, capped at 70% of the viewport.\n effectiveHeight() {\n if (this.height != null) return this.height\n return Math.min(this._autoContentHeight, this.windowInnerH * 0.7)\n },\n\n hiddenColSet() { return new Set(this.hiddenColumns) },\n\n hiddenRowSet() {\n const s = new Set(this.hiddenRows)\n // Also add rows hidden by collapsed row groups\n const collapsed = getCollapsedRowIndices(this.internalGroups, this.internalCollapsedGroups)\n collapsed.forEach(i => s.add(i))\n // Also add rows hidden by the filter row (see filteredRowIndices/_recomputeRowFilters)\n this.filteredRowIndices.forEach(i => s.add(i))\n // Also add rows the host's rowVisible(row, rowIndex) rejects — a static/\n // dynamic rule (status, parity, ...) independent of the toggleable\n // hiddenRows list and the interactive filter row.\n if (typeof this.rowVisible === 'function') {\n const rows = this.data\n for (let i = 0; i < rows.length; i++) {\n if (this.rowVisible(rows[i], i) === false) s.add(i)\n }\n }\n return s\n },\n\n hiddenColFromGroupSet() {\n return getCollapsedColIndices(this.internalGroups, this.internalCollapsedGroups)\n },\n\n effectiveHiddenColSet() {\n const s = new Set(this.hiddenColSet)\n this.hiddenColFromGroupSet.forEach(i => s.add(i))\n // col.visible === false — a schema-level hide, independent of the\n // toggleable hiddenColumns list. \"Show All Columns\" never reveals it.\n // Boolean or Function(column, colIndex) => boolean — like rowVisible (see\n // hiddenRowSet above), the function form is called fresh on every\n // evaluation of this computed, so it stays reactive to whatever host\n // state its closure reads even though leafCols itself (built by\n // flattenColumns' `{...col}` shallow clone) is only rebuilt when the\n // columns prop reference changes. A function value survives that clone\n // unchanged (functions copy by reference), so the closure keeps running\n // fresh — no need to restructure columns into a computed just to react.\n this.leafCols.forEach(col => {\n const v = typeof col.visible === 'function' ? col.visible(col, col._flatIndex) : col.visible\n if (v === false) s.add(col._flatIndex)\n })\n return s\n },\n\n rowGroups() { return this.internalGroups.filter(g => g.type === 'row') },\n colGroups() { return this.internalGroups.filter(g => g.type === 'column') },\n\n visibleRowRange() {\n const rows = []\n for (let i = this.virtualStartIndex; i <= this.virtualEndIndex; i++) rows.push(i)\n return rows\n },\n\n primaryRange() {\n return getPrimaryRange(this.selectionRanges, this.activeCell)\n },\n\n fillHandleVisible() {\n return this.primaryRange && !this.editingCell\n },\n\n fillHandleStyle() {\n if (!this.primaryRange) return {}\n const r = normalizeRange(this.primaryRange)\n // O(1) lookup via cumulativeHeights instead of O(endRow) getRowTop loop\n const bottom = this.cumulativeHeights[r.endRow + 1] || 0\n const right = this.getColLeft(r.endCol + 1)\n return {\n position: 'absolute',\n width: '6px',\n height: '6px',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n border: '1px solid white',\n bottom: (this.totalVirtualHeight - bottom + 1) + 'px',\n right: (this.scrollContentWidth - right + 1) + 'px',\n cursor: 'crosshair',\n zIndex: 10\n }\n },\n\n\n invalidTooltipStyle() {\n if (!this.invalidCell || !this.invalidCell.rect) return {}\n const { rect } = this.invalidCell\n return {\n position: 'fixed',\n top: (rect.bottom + 4) + 'px',\n left: rect.left + 'px',\n zIndex: 9999\n }\n },\n\n dragGhostStyle() {\n if (!this.dragMove.active || this.dragMove.targetRow < 0) return {}\n const r = normalizeRange(this.dragMove.sourceRange)\n const rows = r.endRow - r.startRow\n const cols = r.endCol - r.startCol\n // O(1) lookup via cumulativeHeights instead of O(targetRow) getRowTop loop\n const top = this.cumulativeHeights[this.dragMove.targetRow] || 0\n const left = this.getColLeft(this.dragMove.targetCol)\n const width = this.getRangeWidth(this.dragMove.targetCol, this.dragMove.targetCol + cols)\n const height = this.getRangeHeight(this.dragMove.targetRow, this.dragMove.targetRow + rows)\n return {\n position: 'absolute',\n top: top + 'px',\n left: left + 'px',\n width: width + 'px',\n height: height + 'px',\n border: '2px dashed #1a73e8',\n pointerEvents: 'none',\n zIndex: 20\n }\n },\n\n // widthMode:'auto' — the grid's natural (max-content) width: row-number\n // gutter + outline gutter (when row groups exist) + every visible leaf\n // column's current width + the measured vertical scrollbar width (0 when\n // rows fit / overlay scrollbars). Fully reactive: column resize,\n // hide/show, col.visible, and group changes all re-derive it.\n naturalGridWidth() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return ROW_HEADER_WIDTH + outlineW + this.getColLeft(this.leafCols.length) + this.vScrollbarW + this.shellBorderW\n },\n\n // Reference width for resolving relative (%, css) column widths — always\n // hostContainerWidth (the grid's DOM parent), in both widthMode values.\n // See hostContainerWidth's declaration comment for why headerViewportWidth\n // (the grid's own scrollBody.clientWidth) isn't used for this in either\n // mode: circular in 'auto', and independently unstable in 'full' (a\n // vertical scrollbar toggling flips it without the shell's outer size\n // ever changing).\n _relativeWidthContainer() {\n return this.hostContainerWidth\n },\n\n // The CSS width both the shell and the wrapper use in non-fullscreen mode.\n _hostWidth() {\n if (this.widthMode === 'auto') return this.naturalGridWidth + 'px'\n return typeof this.width === 'number' ? this.width + 'px' : this.width\n },\n\n wrapperStyle() {\n if (this.isFullscreen) {\n return {\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n }\n const style = {\n width: this._hostWidth,\n height: this.effectiveHeight + 'px',\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n shellStyle() {\n if (this.isFullscreen) return {}\n const style = {\n width: this._hostWidth,\n display: 'flex',\n flexDirection: 'column'\n }\n // The cap that keeps a too-wide column set at the host's edge\n // (internal horizontal scroll takes over from there).\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n canUndo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex >= 0\n },\n\n canRedo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex < this._history.length - 1\n },\n\n headerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n position: 'sticky',\n top: 0,\n zIndex: 30,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n cornerStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: (this._headerRowH * this.headerDepth + this._filterRowH) + 'px',\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderBottom: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0\n }\n },\n\n rowOutlineGutterStyle() {\n return { width: OUTLINE_GUTTER + 'px', flexShrink: 0 }\n },\n\n frozenColHeaderStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n scrollColHeaderStyle() {\n // Once measured, lock the header to the body's exact clientWidth (flex: 0 0 <px>)\n // instead of growing independently via flex:1 — this guarantees identical\n // scrollWidth-clientWidth (max scrollLeft) on both, so syncing scrollLeft never clamps\n // one before the other. (padding-right can't do this: clientWidth always includes\n // padding, so padding never actually shrinks it.)\n //\n // The pinned zones must be subtracted from the measured clientWidth: in the\n // body they live INSIDE .eg-scroll-body as sticky overlays (so clientWidth\n // already spans them), but in the header/footer rows they are separate flex\n // siblings rendered BEFORE/AFTER this strip. Without the subtraction the\n // header/footer rows end up frozenWidth+pinnedRightWidth wider than the\n // container, giving .eg-container (overflow:auto) its own second horizontal\n // scrollbar. With it, this strip's viewport exactly matches the body's\n // scrollable window between the sticky zones, and — together with the strip\n // inner width being scrollableContentWidth — both max scrollLefts stay equal.\n // A falsy 0 (not just null/undefined) means \"not really measured yet\" — e.g. the grid\n // mounted while its container was display:none (a hidden v-show tab). Locking to '0 0 0px'\n // in that case would collapse the header/footer strip to invisible; fall back to the same\n // natural flex:1 used pre-measurement instead. The ResizeObserver in mounted() re-measures\n // once the container becomes visible, replacing this with a real px lock.\n if (this.headerViewportWidth) {\n const w = Math.max(\n 0,\n this.headerViewportWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n )\n return { flex: '0 0 ' + w + 'px', overflow: 'hidden' }\n }\n return { flex: 1, overflow: 'hidden' }\n },\n\n bodyAreaStyle() {\n // Fullscreen: let flexbox fill the viewport dynamically (mirrors\n // _containerH()'s fullscreen branch, which reads the resulting\n // clientHeight back for virtual-scroll math) — an explicit height\n // here would fight that.\n if (this.isFullscreen) {\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: 1,\n position: 'relative'\n }\n }\n // Fixed/auto height: an explicit px height (matching _containerH()'s\n // non-fullscreen formula) instead of flex:1 growth. flex:1 here only\n // ever has ONE sibling competing for space mathematically, so it\n // should be exact — but nested flex cross-axis stretch (this box's\n // height flows down to .eg-scroll-body/.eg-row-header-col via\n // align-items:stretch) is vulnerable to sub-pixel rounding between\n // browser-rendered header/footer row heights and the integer px\n // this component assumes, occasionally landing 1px short. That 1px\n // shortfall is enough to make .eg-scroll-body's overflow:auto show a\n // vertical scrollbar even when content should fit exactly (reported\n // with a single data row and no `height` prop — content height ==\n // container height, so any shortfall trips overflow instantly).\n // flex: '0 0 auto' overrides the .eg-body-area class rule's own\n // `flex: 1` (flex-basis: 0%) — with a non-auto flex-basis, flexbox\n // ignores this element's own `height` entirely for main-axis sizing,\n // so basis must explicitly go back to `auto` for `height` to apply.\n const h = this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - (this.hasFooter ? this._footerRowH : 0)\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: '0 0 auto',\n height: Math.max(h, 0) + 'px',\n position: 'relative'\n }\n },\n\n rowHeaderColStyle() {\n return {\n width: ROW_HEADER_WIDTH + 'px',\n minWidth: ROW_HEADER_WIDTH + 'px',\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n position: 'sticky',\n left: 0,\n zIndex: 20,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n rowOutlineColStyle() {\n return {\n width: OUTLINE_GUTTER + 'px',\n minWidth: OUTLINE_GUTTER + 'px',\n flexShrink: 0,\n position: 'relative',\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))'\n }\n },\n\n scrollBodyStyle() {\n return {\n flex: 1,\n overflow: 'auto',\n position: 'relative',\n overflowAnchor: 'none' /* prevent Chrome scroll anchoring from fighting virtual scroll translateY */\n }\n },\n\n frozenColBodyStyle() {\n const w = this.getFrozenWidth()\n return {\n position: 'sticky',\n left: 0,\n width: w + 'px',\n flexShrink: 0,\n zIndex: 10,\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))',\n overflow: 'hidden'\n }\n },\n\n mainCellsStyle() {\n const left = this.getFrozenWidth()\n return {\n position: 'absolute',\n left: left + 'px',\n top: 0,\n bottom: 0\n }\n },\n\n scrollContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && !this.pinnedRightSet.has(i)) w += this.getColW(i)\n }\n // Extra right padding so pinned-right zone doesn't cover the last scrollable column\n if (this.hasPinnedRight) w += this.pinnedRightWidth\n return w\n },\n\n scrollableContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && this.isColumnScrollable(i)) w += this.getColW(i)\n }\n return w\n },\n\n pinnedRightBodyStyle() {\n return {\n position: 'sticky',\n right: 0,\n // Natural position must be the inner's right edge for sticky right:0 to\n // engage (see the .eg-scroll-body-inner flex comment in the template).\n marginLeft: 'auto',\n width: this.pinnedRightWidth + 'px',\n zIndex: 10,\n background: 'white',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightHeaderStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n scrollRowHeadersStyle() {\n return { flex: 1, overflow: 'hidden', position: 'relative', overflowAnchor: 'none' }\n },\n\n footerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n flexShrink: 0,\n position: 'sticky',\n bottom: 0,\n zIndex: 30,\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n // box-shadow (not a real border, like .eg-header-area's own\n // separator) — a real border would add an unaccounted extra 1px to\n // the footer's layout height (nothing in the height math knows\n // about it), throwing off effectiveHeight/_autoContentHeight and\n // tripping a phantom vertical scrollbar on .eg-scroll-body even\n // when content should fit exactly.\n boxShadow: '0 -2px 0 0 var(--eg-color-border-strong, var(--eg-color-border-strong-default))'\n }\n },\n\n footerLabelStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: this._footerRowH + 'px',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderTop: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0,\n fontSize: 'var(--eg-font-size-header, var(--eg-font-size-header-default))',\n fontWeight: 600\n }\n },\n\n frozenColFooterStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightFooterStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n colOutlineRowStyle() {\n return {\n position: 'absolute',\n top: -OUTLINE_GUTTER + 'px',\n left: 0,\n right: 0,\n height: OUTLINE_GUTTER + 'px'\n }\n },\n\n resizeLineStyle() {\n if (!this.resizing.active) return {}\n if (this.resizing.type === 'col') {\n return {\n position: 'absolute',\n top: 0,\n left: (this.resizing.currentPos - (this.resizing.containerLeft || 0)) + 'px',\n width: '2px',\n height: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n } else {\n return {\n position: 'absolute',\n left: 0,\n top: (this.resizing.currentPos - (this.resizing.containerTop || 0)) + 'px',\n height: '2px',\n width: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n }\n }\n },\n\n watch: {\n size: {\n immediate: true,\n handler(v) { this.internalSize = v || 'middle' }\n },\n\n theme: {\n immediate: true,\n handler(v) { this.internalTheme = v || 'excel' }\n },\n\n // Runs before the `columns`/`data` watchers below (both call\n // _recomputeRowFilters()) so internalFilters already reflects the prop\n // by the time either of them fires during the initial immediate pass.\n filters: {\n immediate: true,\n handler(v) { this.internalFilters = v ? { ...v } : {} }\n },\n\n internalFilters() { this._scheduleFilterRecompute() },\n\n filterRow() {\n this._recomputeRowFilters()\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n sort: {\n immediate: true,\n handler(v) { this.internalSort = v ? { key: v.key, direction: v.direction } : null }\n },\n\n internalSize() {\n this.rowHeights = {}\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n autoWidth(v) {\n if (v) this._applyAutoWidth()\n },\n\n fitColumns(v) {\n if (v) {\n this.fitColumnsOverrides = {}\n this._applyFitColumns()\n } else {\n // Back to normal per-column resolution (same as the columns\n // watcher's non-fit branch, without keyToWidth — fitColumns already\n // discarded any remembered manual-resize widths when it turned on).\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n this.fitColumnsOverrides = {}\n this._applyAutoWidth()\n }\n },\n\n columns: {\n immediate: true,\n handler(cols) {\n // Build key→width map from current state before rebuilding leafCols.\n // This preserves user-resized widths across column reorders: when columns\n // are moved, _flatIndex values shift but col.key stays the same.\n const keyToWidth = {}\n const keyToSpec = {}\n ;(this.leafCols || []).forEach(col => {\n if (this.colWidths[col._flatIndex] !== undefined) {\n keyToWidth[col.key] = this.colWidths[col._flatIndex]\n }\n if (this.relativeColSpecs[col._flatIndex] !== undefined) {\n keyToSpec[col.key] = this.relativeColSpecs[col._flatIndex]\n }\n })\n\n this.leafCols = flattenColumns(cols)\n this.headerDepth = columnTreeDepth(cols)\n this.headerRows = buildHeaderRows(cols, this.headerDepth)\n this._maskCache = {}\n\n if (this.fitColumns) {\n // fitColumns is the highest-priority width control — it overrides\n // even remembered manual-resize widths (keyToWidth above), so it\n // deliberately does NOT participate in that preservation logic.\n // fitColumnsOverrides (per-column fixed-width overrides set by\n // manually resizing while fitColumns is on) IS preserved, by key,\n // dropping any keys that no longer exist in the new column set.\n this.colWidths = {}\n this.relativeColSpecs = {}\n const validKeys = new Set(this.leafCols.map(c => c.key))\n const newOverrides = {}\n Object.keys(this.fitColumnsOverrides).forEach(k => {\n if (validKeys.has(k)) newOverrides[k] = true\n })\n this.fitColumnsOverrides = newOverrides\n this._applyFitColumns()\n } else {\n // Rebuild colWidths by new flat index, using col.key to look up saved widths\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n if (keyToWidth[col.key] !== undefined) {\n newColWidths[col._flatIndex] = keyToWidth[col.key]\n if (keyToSpec[col.key] !== undefined) newRelativeSpecs[col._flatIndex] = keyToSpec[col.key]\n } else {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n }\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n }\n\n this.computeColumnTotals()\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this._applyAutoWidth()\n\n // Merge col.pin entries for columns not yet tracked in internalPinnedColumns.\n // Existing entries (from prop or prior runtime pin/unpin) are not overwritten.\n const pinFromCols = this._colPinMap(this.leafCols)\n Object.keys(pinFromCols).forEach(key => {\n if (!(key in this.internalPinnedColumns)) this.$set(this.internalPinnedColumns, key, pinFromCols[key])\n })\n }\n },\n pinnedColumns: {\n immediate: true,\n handler(v) {\n // Rebuild: col.pin is the base default, prop value wins on conflict.\n this.internalPinnedColumns = { ...this._colPinMap(this.leafCols), ...(v || {}) }\n }\n },\n merges: {\n immediate: true,\n handler(v) { this.internalMerges = v ? [...v] : [] }\n },\n collapsedGroups: {\n immediate: true,\n handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }\n },\n // `immediate: true` guarantees this fires once, synchronously, right at\n // setup regardless of `v` — that's the one reliable signal that THIS\n // particular call is the initial sync, not a real host-driven change\n // (e.g. via `:locked.sync`). Every call after the first is a genuine\n // change, so only those flash. (Watching internalLocked itself for this\n // instead — the previous approach — can't tell \"initial value happened\n // to be true\" from \"the user's first-ever real toggle\": both look like\n // \"the first time internalLocked actually changed value\".)\n locked: {\n immediate: true,\n handler(v) {\n this.internalLocked = !!v\n if (this._lockPropSynced) this._flashLock(this.internalLocked)\n this._lockPropSynced = true\n }\n },\n data: {\n immediate: true,\n handler(newData) {\n // Keep _dataRaw in sync so template uses fresh data without triggering dependArray\n this._dataRaw = newData || []\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this.computeColumnTotals()\n // Force re-render so cell values refresh even when virtualScroll indices don't change\n // (e.g. editing a cell: same row count, same scroll position, only content changed)\n this.$forceUpdate()\n this._applyAutoWidth()\n }\n },\n totalRows() { this.updateVirtualScroll() },\n hiddenRowSet() { this.updateVirtualScroll() },\n totalVirtualHeight() { this.$nextTick(this.syncHeaderViewportWidth) },\n // widthMode:'auto' — the shell resizes with its columns (resize, hide/\n // show), so the header/body viewport lock must be re-measured after the\n // DOM picks up the new width. (The runaway-loop safety net lives in\n // _widthSyncChainOk(), gating the actual colWidths mutation inside\n // syncHeaderViewportWidth() — not here — since that single choke point\n // catches every trigger path, not just this one.)\n naturalGridWidth() {\n if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)\n },\n widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },\n\n // localSyncKey/tenant feature — mounted() only calls\n // _loadLocalVersionsOnMount() once, at mount time. Without these\n // watchers, a key/tenant that starts empty (a host conditionally\n // passing `:local-sync-key=\"ready ? id : null\"`, or a `tenant` only\n // known after an async \"current organization\" load) and later becomes\n // truthy would never load its version list, never promote a leftover\n // buffer, and never show the toolbar badge — the feature would look\n // \"broken\" for the rest of that page's lifetime even though everything\n // else works. Both share _reloadLocalSyncForKeyChange() since either one\n // changing means the effective storage key changed.\n localSyncKey(newKey, oldKey) {\n if (newKey === oldKey) return\n this._reloadLocalSyncForKeyChange()\n },\n tenant(newTenant, oldTenant) {\n if (newTenant === oldTenant) return\n this._reloadLocalSyncForKeyChange()\n }\n },\n\n created() {\n // Non-reactive row data reference — avoids Vue 2 dependArray(data) O(n) loop.\n // Vue 2 calls dependArray() for EVERY `data[ri]` access in the template,\n // iterating all 50,000 rows each time. With 1200 cells per render that's\n // 60 million iterations per render (~600ms). _dataRaw bypasses this.\n this._dataRaw = this.data || []\n\n // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes\n this._cellStyleCache = Object.create(null)\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n // rAF handles for scroll/resize throttle\n this._scrollRAF = null\n this._resizeRAF = null\n\n // Undo/redo history — non-reactive to avoid Vue overhead.\n // Each entry: { type: 'data'|'merges', before: {...}, after: {...} }\n // 'data' stores only diff rows: { indices: number[], rows: object[] }\n this._history = []\n this._historyIndex = -1\n this._historySkip = false // true while undo/redo is in progress\n\n // Column drag preview offsets — non-reactive so direct DOM updates bypass Vue re-renders.\n // Keys are column keys, values are pixel offsets.\n this._dragOffsets = {}\n\n // Column drag internals (all non-reactive):\n // _dragBounds — snapshot of every visible leaf header's ORIGINAL viewport\n // rect taken once at drag start; drop-index math reads only this snapshot\n // (adjusted by scrollLeft delta), never live DOM rects — live rects carry\n // in-flight CSS transforms and give unstable intermediate positions.\n this._dragBounds = null\n this._dragStartScrollLeft = 0\n this._pendingDragHeaderRect = null\n this._lastDragClientX = 0\n this._dragScrollV = 0 // px/frame edge auto-scroll velocity (0 = idle)\n this._dragScrollRAF = null\n this._dragCleanupTimer = null\n\n // Fill-handle drag edge auto-scroll (see _updateFillDragAutoScroll) — same\n // idle-when-0 convention as _dragScrollV/_dragScrollRAF above, just for\n // both axes since a fill can extend down OR right.\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragScrollRAF = null\n this._fillDragLastEvent = null\n\n // localSyncKey — the last `data` reference a version was actually saved\n // for. Initialized to the current (mount-time) value so an untouched grid\n // that's merely tab-switched away from doesn't spam a \"nothing happened\"\n // version on every hide — only a REAL edit (which replaces this array\n // reference, see _emitDataChange) makes the next hide event flush.\n this._lastFlushedDataRef = this.data\n },\n\n mounted() {\n this.updateVirtualScroll()\n this.$nextTick(this.syncHeaderViewportWidth)\n document.addEventListener('mousemove', this.onGlobalMouseMove)\n document.addEventListener('mouseup', this.onGlobalMouseUp)\n document.addEventListener('keydown', this.onGlobalKeyDown)\n document.addEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.addEventListener('resize', this.syncHeaderViewportWidth)\n window.addEventListener('scroll', this._onWindowScrollForDropdown, true)\n // Self-heals the \"mounted while display:none\" case (e.g. a v-show tab that isn't\n // active yet) — scrollBody.clientWidth reads 0 at mount, and nothing but a real\n // window resize would otherwise ever re-measure it. ResizeObserver also fires when\n // the container regains real dimensions (tab becomes visible), so headers/footer\n // (scrollColHeaderStyle) get correctly re-locked with no host code changes needed.\n // rAF-batched like onBodyScroll's _scrollRAF, to avoid thrashing during a live drag-resize.\n if (typeof ResizeObserver !== 'undefined' && this.$refs.scrollBody) {\n this._scrollBodyResizeObserver = new ResizeObserver(() => {\n if (this._roRAF) return\n this._roRAF = requestAnimationFrame(() => {\n this._roRAF = null\n this.syncHeaderViewportWidth()\n })\n })\n this._scrollBodyResizeObserver.observe(this.$refs.scrollBody)\n // Also observe the grid's own DOM parent — needed in BOTH widthMode\n // values to keep hostContainerWidth (relative column widths' reference\n // in both modes) fresh when the host's layout changes.\n if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)\n }\n this._loadLocalVersionsOnMount()\n // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).\n // 'visibilitychange'->hidden is the primary trigger: it fires for tab\n // switches, minimizing, AND reliably before actual tab close/navigation on\n // both desktop and mobile, while the page is still very likely alive —\n // giving the async worker-hash + IndexedDB write a real chance to finish.\n // 'pagehide' is a backstop for the cases visibilitychange misses; it's\n // idempotent with visibilitychange (same content -> same hash -> no-op\n // beyond a timestamp touch) so firing both is harmless.\n document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.addEventListener('pagehide', this._onPageHideForLocalSync)\n },\n\n updated() {\n // After Vue re-renders (e.g., virtual scroll adds new rows during drag),\n // apply missing transforms to newly rendered cells that don't have one yet.\n // The transform lands with transitions disabled (a fresh cell must appear\n // in place, not fly in from its untransformed position); the drag\n // transition is re-armed on the next frame.\n if (!this.colDrag.active || !this.$el) return\n const offsets = this._dragOffsets\n if (!offsets || !Object.keys(offsets).length) return\n const fresh = []\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n if (el.style.transform) return\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transition = 'none'\n el.style.transform = `translateX(${dx}px)`\n fresh.push(el)\n })\n if (fresh.length) {\n requestAnimationFrame(() => {\n if (!this.colDrag.active) return\n fresh.forEach(el => { el.style.transition = COL_DRAG_TRANSITION })\n })\n }\n },\n\n beforeDestroy() {\n clearTimeout(this._filterRecomputeTimer)\n clearTimeout(this._onInputTimer)\n clearTimeout(this._dragCleanupTimer)\n clearTimeout(this._widthSyncChainResetTimer)\n clearTimeout(this._bufferWriteTimer)\n if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)\n if (this._fillDragScrollRAF) cancelAnimationFrame(this._fillDragScrollRAF)\n document.removeEventListener('mousemove', this.onGlobalMouseMove)\n document.removeEventListener('mouseup', this.onGlobalMouseUp)\n document.removeEventListener('keydown', this.onGlobalKeyDown)\n document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.removeEventListener('resize', this.syncHeaderViewportWidth)\n window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)\n document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.removeEventListener('pagehide', this._onPageHideForLocalSync)\n // Best-effort — the component may be torn down (SPA route change) without\n // the page itself unloading, which visibilitychange/pagehide won't catch.\n // sync: true — see _flushLocalVersionOnHide()'s comment; a component\n // being destroyed is exactly the same \"might not survive an async hop\"\n // situation as the other two triggers.\n this._flushLocalVersionOnHide(false, true)\n if (this.isFullscreen) {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (this._scrollBodyResizeObserver) { this._scrollBodyResizeObserver.disconnect(); this._scrollBodyResizeObserver = null }\n if (this._roRAF) { cancelAnimationFrame(this._roRAF); this._roRAF = null }\n },\n\n methods: {\n // =========================================================\n // VIRTUAL SCROLL\n // =========================================================\n // In fullscreen, .eg-scroll-body's real height comes from flexbox filling\n // the viewport (which can be far taller than the `height` prop the host\n // originally passed for the fixed-size layout) — measure it directly\n // instead of trusting `height`, otherwise virtual scroll only ever\n // renders enough rows for the old fixed size, leaving the rest of the\n // (visually stretched) container blank.\n _containerH() {\n if (this.isFullscreen && this.$refs.scrollBody) return this.$refs.scrollBody.clientHeight\n const footerH = this.hasFooter ? this._footerRowH : 0\n return this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - footerH\n },\n\n // Same fullscreen-aware measurement as _containerH(), but mirrors PageUp/\n // PageDown's pre-existing (footer-agnostic) row-span formula exactly for\n // the non-fullscreen case, so normal-mode behavior is unchanged.\n _pageRowSpan() {\n const h = this.isFullscreen && this.$refs.scrollBody\n ? this.$refs.scrollBody.clientHeight\n : (this.effectiveHeight - this._headerRowH * this.headerDepth - this._filterRowH)\n return Math.floor(h / this._rowH)\n },\n\n updateVirtualScroll() {\n // Use binary search against the Vue-cached cumulativeHeights → O(log n) per scroll.\n // overscan=8 (≈224px at default row height) gives enough buffer for fast scroll\n // without blank-band artifacts between rAF updates.\n const result = findVisibleRange(\n this.scrollTop, Math.max(this._containerH(), 100), this.totalRows, this.cumulativeHeights, 8\n )\n this.virtualStartIndex = result.startIndex\n this.virtualEndIndex = result.endIndex\n this.virtualOffsetTop = result.offsetTop\n this.totalVirtualHeight = result.totalHeight\n },\n\n onBodyScroll(e) {\n const target = e.target\n // Sync sibling scroll containers immediately (visual alignment, no layout trigger)\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = target.scrollLeft\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = target.scrollLeft\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = target.scrollTop\n\n // Close dropdown on scroll — fixed-position dropdown drifts away from the moving cell\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n\n // Throttle reactive state updates + virtual scroll recalc to one rAF per frame.\n // Read scrollTop from the DOM ref at rAF time (not from the stale event closure)\n // so that multiple scroll events arriving between frames still resolve to the\n // final position — eliminates the flickering caused by render/scroll mismatch.\n if (!this._scrollRAF) {\n this._scrollRAF = requestAnimationFrame(() => {\n this._scrollRAF = null\n const sb = this.$refs.scrollBody\n if (!sb) return\n this.scrollTop = sb.scrollTop\n this.scrollLeft = sb.scrollLeft\n this.updateVirtualScroll()\n })\n }\n },\n\n onScroll(e) {},\n\n // .eg-scroll-col-header (overflow:hidden, synced via JS) has no native scrollbar, but\n // .eg-scroll-body (overflow:auto) does — its real vertical scrollbar eats into its\n // clientWidth. If both containers just grow via flex:1, the header ends up wider than\n // the body, so its max scrollLeft is smaller and syncing clamps it before the body\n // reaches its rightmost scroll position — column borders desync right at the scroll\n // end. Locking the header to the body's exact measured clientWidth (scrollColHeaderStyle)\n // makes both max scrollLeft values identical.\n syncHeaderViewportWidth() {\n // Auto-height's 70vh cap tracks the real viewport — keep it current on resize.\n if (typeof window !== 'undefined' && this.windowInnerH !== window.innerHeight) {\n this.windowInnerH = window.innerHeight\n }\n const el = this.$refs.scrollBody\n if (!el) return\n // headerViewportWidth is still tracked in both modes (scrollColHeaderStyle/\n // virtual scroll need the grid's OWN viewport width) — but no longer\n // feeds relative column-width resolution at all (see\n // _relativeWidthContainer/hostContainerWidth's comments for why: it's\n // circular in 'auto' and independently scrollbar-toggle-unstable in\n // 'full'). _widthSyncChainOk() still guards the assignment itself,\n // since it's reactive (scrollColHeaderStyle reads it) — at fractional\n // page-zoom levels el.clientWidth can genuinely oscillate by ±1px\n // indefinitely (a vertical scrollbar toggling right at that subpixel\n // rounding boundary), which would otherwise re-render on every flip\n // forever even with nothing left downstream to actually resolve.\n if (this.headerViewportWidth !== el.clientWidth && this._widthSyncChainOk()) {\n this.headerViewportWidth = el.clientWidth\n }\n // Reference for relative (%, css) column widths, in BOTH widthMode\n // values — see hostContainerWidth's declaration comment. Measured from\n // the grid's DOM PARENT (not the grid itself, and not its scrollBody),\n // which is stable regardless of the grid's own rendered size or\n // internal scrollbar state — UNLESS the host's own layout also\n // auto-sizes around the grid (e.g. the parent shrink-wraps to its\n // content), which recreates a similar cycle outside our control.\n // _widthSyncChainOk() guards the assignment itself against that\n // residual case — the single choke point both a resized parent (this\n // block) and a resized shell (the naturalGridWidth watcher, via a\n // fresh call to this method, 'auto' mode only) funnel through.\n if (this.$el && this.$el.parentElement) {\n const pw = this.$el.parentElement.clientWidth\n if (pw !== this.hostContainerWidth && this._widthSyncChainOk()) {\n this.hostContainerWidth = pw\n if (this.fitColumns) this._applyFitColumns()\n else this._resolveRelativeColWidths()\n }\n }\n const sbw = el.offsetWidth - el.clientWidth\n if (sbw >= 0 && sbw !== this.vScrollbarW) this.vScrollbarW = sbw\n // Shell border total (see naturalGridWidth) — offsetWidth-clientWidth\n // is exactly the horizontal borders here (the shell itself never scrolls).\n const shell = this.$el\n if (shell && shell.offsetWidth) {\n const bw = shell.offsetWidth - shell.clientWidth\n if (bw >= 0 && bw !== this.shellBorderW) this.shellBorderW = bw\n }\n // Window resize while fullscreen changes the real available row-viewport\n // height too (see _containerH()) — recompute which rows are rendered.\n // Same for auto-height (height prop omitted): the 70vh cap just moved.\n if (this.isFullscreen || this.height == null) this.updateVirtualScroll()\n },\n\n // Runaway width-measurement guard — used in BOTH widthMode values.\n // In 'auto' mode, hostContainerWidth re-resolving colWidths is DESIGNED\n // to settle in 1-2 calls, but a host layout that also auto-sizes AROUND\n // the grid can recreate a circular dependency outside our control. In\n // 'full' mode there's no such circularity by construction, but\n // el.clientWidth is still a real browser measurement, and at fractional\n // page-zoom levels it can genuinely oscillate by ±1px indefinitely\n // (scrollbar toggling right at the rounding boundary, or ResizeObserver's\n // own well-known \"callback reads a size that changes again\" footgun).\n // Either way the symptom is the same: syncHeaderViewportWidth keeps\n // getting re-triggered forever. Caps consecutive calls instead of\n // chasing forever; 400ms of quiet resets the count, so a legitimately\n // slow (but finite) settle is never penalized — only a genuinely\n // runaway chain trips it.\n _widthSyncChainOk() {\n this._widthSyncChainCount = (this._widthSyncChainCount || 0) + 1\n clearTimeout(this._widthSyncChainResetTimer)\n this._widthSyncChainResetTimer = setTimeout(() => { this._widthSyncChainCount = 0 }, 400)\n if (this._widthSyncChainCount > 30) {\n if (!this._widthSyncChainWarned) {\n this._widthSyncChainWarned = true\n // eslint-disable-next-line no-console\n console.warn('[vue2-excel-grid] widthMode=\"' + this.widthMode + '\": column width measurement did not stabilize after 30 consecutive re-measures (possibly fractional browser zoom, or a host layout that also auto-sizes around the grid) — freezing at the last measured size instead of re-rendering forever.')\n }\n return false // stop chasing; keep the last measured/resolved widths instead of looping forever\n }\n return true\n },\n\n // =========================================================\n // HELPERS\n // =========================================================\n isRowHidden(ri) { return this.hiddenRowSet.has(ri) },\n isColHidden(ci) { return this.effectiveHiddenColSet.has(ci) },\n\n getColW(ci) {\n return this.colWidths[ci] !== undefined ? this.colWidths[ci] : (this.leafCols[ci] ? this.resolveColWidth(this.leafCols[ci].width, this._relativeWidthContainer) : DEFAULT_COL_WIDTH)\n },\n\n // Turns a col.width spec (number, 'Npx', 'N%', or any other CSS length like\n // 'Nem'/'calc(...)') into a concrete px number. Percent is resolved against\n // containerWidth (see _relativeWidthContainer — always hostContainerWidth,\n // in both widthMode values); other CSS units are resolved via a hidden\n // DOM probe (_measureCssWidth).\n resolveColWidth(spec, containerWidth) {\n const parsed = parseColWidthSpec(spec)\n if (parsed.type === 'px') return Math.max(20, parsed.value)\n if (parsed.type === 'percent') {\n if (!containerWidth) return DEFAULT_COL_WIDTH\n return Math.max(20, (parsed.value / 100) * containerWidth)\n }\n const measured = this._measureCssWidth(parsed.value)\n return Math.max(20, measured || DEFAULT_COL_WIDTH)\n },\n\n // Measures an arbitrary CSS width string (em/rem/vw/calc/...) by applying it\n // to a hidden probe element, so it inherits ambient font-size for em/rem.\n // Parented in the grid's DOM PARENT (not the grid root or its scrollBody)\n // in both widthMode values, matching _relativeWidthContainer — a\n // %/calc(%...) value would otherwise resolve against the grid's own\n // rendered size, which is either circular ('auto') or independently\n // scrollbar-toggle-unstable ('full'). Falls back to the grid root if\n // there's no parent (e.g. a detached/test mount). Returns null before\n // mount (no DOM yet).\n _measureCssWidth(cssValue) {\n if (typeof document === 'undefined' || !this.$el || !this.$el.appendChild) return null\n const parent = this.$el.parentElement || this.$el\n if (!this._widthProbeEl || this._widthProbeEl.parentNode !== parent) {\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n const el = document.createElement('div')\n el.style.position = 'absolute'\n el.style.visibility = 'hidden'\n el.style.height = '0'\n el.style.overflow = 'hidden'\n el.style.pointerEvents = 'none'\n parent.appendChild(el)\n this._widthProbeEl = el\n }\n this._widthProbeEl.style.width = cssValue\n return this._widthProbeEl.offsetWidth\n },\n\n // Re-resolves every column whose width spec is relative (%, em, vw, ...)\n // against the current _relativeWidthContainer — called whenever that\n // reference width changes (initial mount + window resize), so\n // percent/relative columns stay responsive like real CSS, matching a\n // plain numeric/px width's stability.\n _resolveRelativeColWidths() {\n const specs = this.relativeColSpecs\n const keys = Object.keys(specs)\n if (!keys.length) return\n keys.forEach(ci => {\n this.$set(this.colWidths, ci, this.resolveColWidth(specs[ci], this._relativeWidthContainer))\n })\n this._cellStyleCache = Object.create(null)\n },\n\n getRowH(ri) {\n if (this.isRowHidden(ri)) return 0\n return this.rowHeights[ri] || this._rowH\n },\n\n getColLeft(ci) {\n let left = 0\n for (let i = 0; i < ci && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) left += this.getColW(i)\n }\n return left\n },\n\n getFrozenWidth() {\n return this.hasPinnedLeft ? this.pinnedLeftWidth : 0\n },\n\n // Scroll-position-aware pinned-column edge shadow (mirrors Ant Design/AG Grid's own\n // convention): the shadow only appears while there's actually scrolled-away content\n // hiding under the pinned zone on that side, not as a permanent decoration — a static\n // shadow reads as background noise and the user stops noticing it exactly when it\n // matters (mid-scroll). Read scrollLeft (already tracked reactively, updated every\n // rAF tick in onBodyScroll) so template :class bindings re-evaluate on every scroll frame.\n hasHiddenScrollLeft() {\n return this.getFrozenWidth() > 0 && this.scrollLeft > 0\n },\n hasHiddenScrollRight() {\n if (!this.hasPinnedRight) return false\n const sb = this.$refs.scrollBody\n if (!sb) return false\n return this.scrollLeft < sb.scrollWidth - sb.clientWidth - 1\n },\n\n isColumnPinnedLeft(ci) {\n return this.pinnedLeftSet.has(ci)\n },\n\n isColumnPinnedRight(ci) {\n return this.pinnedRightSet.has(ci)\n },\n\n isColumnScrollable(ci) {\n return !this.isColumnPinnedLeft(ci) && !this.isColumnPinnedRight(ci)\n },\n\n getZoneColLeft(ci, zone) {\n let left = 0\n if (zone === 'left') {\n for (const col of this.pinnedLeftCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else if (zone === 'right') {\n for (const col of this.pinnedRightCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else {\n for (let i = 0; i < this.leafCols.length; i++) {\n const col = this.leafCols[i]\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex) && this.isColumnScrollable(col._flatIndex)) {\n left += this.getColW(col._flatIndex)\n }\n }\n }\n return left\n },\n\n isCellInZone(cell, rowIdx, zone) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n const z = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n return z === zone\n },\n\n getHeaderCellStyleForZone(cell, rowIndex, zone) {\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z !== zone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getRangeWidth(startCol, endCol) {\n let w = 0\n for (let i = startCol; i <= endCol && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) w += this.getColW(i)\n }\n return w\n },\n\n getRangeHeight(startRow, endRow) {\n let h = 0\n for (let i = startRow; i <= endRow && i < this.totalRows; i++) {\n if (!this.isRowHidden(i)) h += this.getRowH(i)\n }\n return h\n },\n\n getCellValue(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return ''\n const row = this._dataRaw[ri]\n if (!row) return ''\n const v = row[col.key]\n return v !== undefined ? v : ''\n },\n\n isEditing(ri, ci) {\n return this.editingCell && this.editingCell.row === ri && this.editingCell.col === ci\n },\n\n isMergedAway(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return false\n const nm = normalizeRange(merge)\n return !(nm.startRow === ri && nm.startCol === ci)\n },\n\n getMergeSpan(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return { rowspan: 1, colspan: 1 }\n const nm = normalizeRange(merge)\n return {\n rowspan: nm.endRow - nm.startRow + 1,\n colspan: nm.endCol - nm.startCol + 1\n }\n },\n\n isSelected(ri, ci) {\n return isCellInAnyRange(ri, ci, this.selectionRanges)\n },\n\n isActiveCell(ri, ci) {\n return this.activeCell.row === ri && this.activeCell.col === ci\n },\n\n isCopyHighlighted(ri, ci) {\n if (!this.clipboardRange || this.clipboardMode !== 'copy') return false\n return isCellInRange(ri, ci, this.clipboardRange)\n },\n\n // =========================================================\n // STYLES\n // =========================================================\n getRowStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowStyleCache[key]) return this._rowStyleCache[key]\n const style = { height: h + 'px', minHeight: h + 'px' }\n this._rowStyleCache[key] = style\n return style\n },\n\n getRowHeaderStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowHeaderStyleCache[key]) return this._rowHeaderStyleCache[key]\n const style = { height: h + 'px' }\n this._rowHeaderStyleCache[key] = style\n return style\n },\n\n getCellStyle(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n const nm = merge ? normalizeRange(merge) : null\n let w = this.getColW(ci)\n let h = this.getRowH(ri)\n\n if (nm && nm.startRow === ri && nm.startCol === ci) {\n w = this.getRangeWidth(nm.startCol, nm.endCol)\n h = this.getRangeHeight(nm.startRow, nm.endRow)\n }\n\n // Position cells with the same absolute-left arithmetic the header uses\n // (getZoneColLeft), instead of relying on flex layout to accumulate widths —\n // flex and a JS integer sum can drift apart by a few px over many columns,\n // desyncing the body's column borders from the header's.\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n\n // Cache by dimensions+position — same (w,h,left) → same object ref → Vue 2 skips DOM style writes\n const key = w + '_' + h + '_' + left\n if (this._cellStyleCache[key]) return this._cellStyleCache[key]\n const style = { position: 'absolute', left: left + 'px', top: '0px', width: w + 'px', minWidth: w + 'px', height: h + 'px' }\n this._cellStyleCache[key] = style\n return style\n },\n\n // rowStyle prop — deliberately NOT merged into getCellStyle()'s return value:\n // that object is cached/shared by (w,h,left) across unrelated rows/cells (see\n // comment above), so mutating it here would leak one row's style onto every\n // other cell that happens to share the same cache key. Kept as a second,\n // uncached :style array entry instead (see template) — cheap, and correctly\n // reactive to the row's actual data instead of just its geometry.\n getRowCustomStyle(ri) {\n if (!this.rowStyle) return null\n return this.rowStyle(this._dataRaw[ri], ri) || null\n },\n\n getCellClasses(ri, ci) {\n const col = this.leafCols[ci]\n const active = this.isActiveCell(ri, ci)\n // Non-active selected cell: border only on edges NOT shared with another\n // selected neighbor, so a multi-cell range gets one continuous outline\n // around its outer perimeter (Excel-style) instead of each cell only\n // getting a flat background tint. Works for disjoint multi-range\n // selections too (e.g. ctrl+click rows) since it's a pure neighbor check.\n const rangeSelected = this.isSelected(ri, ci) && !active\n return {\n 'eg-cell-active': active,\n 'eg-cell-selected': rangeSelected,\n 'eg-cell-range-top': rangeSelected && !this.isSelected(ri - 1, ci),\n 'eg-cell-range-bottom': rangeSelected && !this.isSelected(ri + 1, ci),\n 'eg-cell-range-left': rangeSelected && !this.isSelected(ri, ci - 1),\n 'eg-cell-range-right': rangeSelected && !this.isSelected(ri, ci + 1),\n 'eg-cell-fill-preview': this.isInFillPreview(ri, ci),\n 'eg-cell-copy': this.isCopyHighlighted(ri, ci),\n 'eg-cell-merged-hidden': this.isMergedAway(ri, ci),\n 'eg-cell-select': !!(col && this.isSelectLikeCol(col)),\n 'eg-cell-date': !!(col && col.type === 'date'),\n 'eg-cell-checkbox': !!(col && col.type === 'checkbox'),\n 'eg-cell-action': !!(col && col.type === 'action'),\n 'eg-cell-readonly': !this.isCellEditable(ri, ci),\n 'eg-cell-invalid': !!(this.invalidCell && this.invalidCell.row === ri && this.invalidCell.col === ci),\n 'eg-cell--has-suffix': !!(col && this.hasSuffixSlot(col)),\n 'eg-cell--drag-source': this.colDrag.active && ci >= this.colDrag.sourceFirstLeaf && ci < this.colDrag.sourceFirstLeaf + this.colDrag.sourceLeafCount,\n 'eg-cell-align-right': !!(col && col.align === 'right'),\n 'eg-cell-align-center': !!(col && col.align === 'center')\n }\n },\n\n isInFillPreview(ri, ci) {\n if (!this.fillDrag.active || !this.fillDrag.sourceRange) return false\n const src = this.fillDrag.sourceRange\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n if (targetRow > src.endRow) {\n return ri > src.endRow && ri <= targetRow && ci >= src.startCol && ci <= src.endCol\n }\n if (targetCol > src.endCol) {\n return ci > src.endCol && ci <= targetCol && ri >= src.startRow && ri <= src.endRow\n }\n return false\n },\n\n getHeaderCellStyle(cell, rowIndex, isFrozen) {\n const zone = isFrozen ? 'left' : 'scroll'\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n if (isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (!inZone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getHeaderDragTransform(cell) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking, no re-render on offset change\n let key\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n } else {\n key = cell.column.key\n }\n if (!key) return null\n const dx = this._dragOffsets[key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n getCellDragTransform(ci) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking\n const col = this.leafCols[ci]\n if (!col) return null\n const dx = this._dragOffsets[col.key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n // =========================================================\n // FOOTER (column totals)\n // =========================================================\n\n // col.totalConfig.visible (boolean) takes priority over col.showTotal when present.\n isColumnTotalVisible(col) {\n if (col.totalConfig && typeof col.totalConfig.visible === 'boolean') return col.totalConfig.visible\n return !!col.showTotal\n },\n\n // Columns with a visible total are implicitly numeric — computeColumnTotals()\n // only sums typeof === 'number' cells, so a value written as a string (every\n // edit path hands over a string: the built-in text <input>, pasted TSV text,\n // fill-handle/drag-move source values) would silently drop out of the sum\n // with no error. Coerce user-entered values that fully parse as a number into\n // an actual Number for these columns; anything else (blank, non-numeric text)\n // passes through unchanged so it still fails validation/renders as typed.\n _coerceCellValue(ci, value) {\n if (typeof value !== 'string') return value\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return value\n const trimmed = value.trim()\n if (trimmed === '' || isNaN(Number(trimmed))) return value\n return Number(trimmed)\n },\n\n // Default value for a column on a row auto-created by pasteGrowsRows.\n _blankValueForCol(col) {\n return col.type === 'checkbox' ? false : ''\n },\n\n // One `Mask` instance per masked flat column index, reused across calls\n // (maska memoizes internally too, but this avoids re-parsing col.mask on\n // every keystroke/paste/fill). Invalidated whenever columns changes (see\n // the `columns` watcher, which resets `_maskCache = {}`).\n _getColMask(ci) {\n if (!this._maskCache) this._maskCache = {}\n if (this._maskCache[ci] !== undefined) return this._maskCache[ci]\n const col = this.leafCols[ci]\n const spec = col && col.mask\n let mask = null\n if (spec) {\n const options = (typeof spec === 'string' || Array.isArray(spec)) ? { mask: spec } : spec\n mask = new Mask(options)\n }\n this._maskCache[ci] = mask\n return mask\n },\n\n // Reformats a value through col.mask, if the column has one. Used at every\n // path that writes a value without going through the live MaskInput-driven\n // editor input (commit of a custom edit slot, paste, fill handle/double-fill)\n // — the live-typing case is handled by MaskInput itself (see startEditing).\n // Idempotent: masking an already-masked value returns it unchanged.\n _applyColMask(ci, value) {\n const mask = this._getColMask(ci)\n if (!mask || value === '' || value === null || value === undefined) return value\n if (typeof value !== 'string' && typeof value !== 'number') return value\n return mask.masked(value)\n },\n\n computeColumnTotals() {\n const totals = {}\n const rows = this._dataRaw || []\n this.leafCols.forEach(col => {\n if (!this.isColumnTotalVisible(col)) return\n let sum = 0\n let hasNumber = false\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n const v = row ? row[col.key] : undefined\n if (typeof v === 'number' && isFinite(v)) {\n sum += v\n hasNumber = true\n }\n }\n totals[col._flatIndex] = hasNumber ? Math.round(sum * 1e6) / 1e6 : null\n })\n this.columnTotals = totals\n },\n\n getColumnTotalDisplay(ci) {\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return ''\n const raw = this.columnTotals[ci]\n const cfg = col.totalConfig\n if (cfg && typeof cfg.formatter === 'function') {\n return cfg.formatter(raw, this._dataRaw, col, ci)\n }\n return raw === null || raw === undefined ? '-' : raw\n },\n\n getFooterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n const style = {\n position: 'absolute',\n left: left + 'px',\n top: '0px',\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px'\n }\n const col = this.leafCols[ci]\n const extra = col && col.totalConfig && col.totalConfig.style\n return extra ? { ...style, ...extra } : style\n },\n\n getFooterCellClass(ci) {\n const col = this.leafCols[ci]\n if (!col) return null\n const classList = col.totalConfig && col.totalConfig.classList\n const base = this.isColumnTotalVisible(col) ? 'eg-footer-cell-total' : null\n if (!base) return classList || null\n return classList ? [base, classList] : base\n },\n\n isCellVisible(cell, rowIndex, isFrozen) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (inZone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n return isFrozen ? this.isColumnPinnedLeft(ci) : this.isColumnScrollable(ci)\n },\n\n getRowGroupBtnStyle(group) {\n const top = getRowTop(group.startIndex, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD) - this.scrollTop\n return {\n position: 'absolute',\n top: top + 'px',\n left: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px',\n zIndex: 5\n }\n },\n\n getColGroupBtnStyle(group) {\n const left = this.getColLeft(group.startIndex) - this.scrollLeft\n return {\n position: 'absolute',\n left: left + 'px',\n top: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px'\n }\n },\n\n // =========================================================\n // CELL SELECTION (3.1)\n // =========================================================\n selectCell(row, col, addToSelection = false) {\n if (row < 0 || row >= this.totalRows || col < 0 || col >= this.leafCols.length) return\n this.activeCell = { row, col }\n if (addToSelection) {\n this.selectionRanges.push(singleCellRange(row, col))\n } else {\n this.selectionRanges = [singleCellRange(row, col)]\n }\n this.emitSelectionChange()\n },\n\n selectRange(startRow, startCol, endRow, endCol, replace = true) {\n const range = { startRow, startCol, endRow, endCol }\n if (replace) {\n this.selectionRanges = [range]\n } else {\n this.selectionRanges.push(range)\n }\n this.emitSelectionChange()\n },\n\n selectRow(ri) {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [{ startRow: ri, endRow: ri, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n selectColumn(ci) {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n this.emitSelectionChange()\n },\n\n selectAll() {\n this.activeCell = { row: 0, col: 0 }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n emitSelectionChange() {\n this.$emit('selection-change', {\n activeCell: { ...this.activeCell },\n ranges: this.selectionRanges.map(r => normalizeRange(r))\n })\n },\n\n onCellMouseDown(e, ri, ci) {\n this.invalidCell = null\n if (this.editingCell) {\n const editCol = this.leafCols[this.editingCell.col]\n if (editCol && this._hasCustomEdit(editCol)) {\n // Custom cell/edit slots commit via their own @blur/@done handler\n // (see commitEditSlot()), and the browser fires that native blur\n // *after* this mousedown listener returns (blur is the default\n // action of the focus shift, which runs post-dispatch). Cancelling\n // here immediately would null out editingCell before the slot's\n // blur handler gets to call done(), silently discarding the typed\n // value. Defer the cleanup instead, and only run it if the slot's\n // own commit hasn't already resolved editingCell by then.\n const staleEditingCell = this.editingCell\n this.$nextTick(() => {\n if (this.editingCell === staleEditingCell) this.cancelEdit()\n })\n } else {\n this.commitEdit()\n }\n }\n this.hideContextMenu()\n this.$refs.wrapper && this.$refs.wrapper.focus()\n\n if (e.ctrlKey || e.metaKey) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Ctrl+click: start new independent range\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges.push(this.mouseDownRange)\n this.isCtrlDrag = true\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Shift+click: extend primary range to this cell\n this.selectionRanges = [buildRange(this.activeCell, { row: ri, col: ci })]\n } else {\n const wasActive = this.activeCell.row === ri && this.activeCell.col === ci\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges = [this.mouseDownRange]\n this.isCtrlDrag = false\n\n // Open select/date dropdown on click if cell was already active (2nd click)\n const col = this.leafCols[ci]\n if (wasActive && col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openSelectDropdown(ri, ci, e)\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (wasActive && col && col.type === 'date' && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openDatePickerForActive()\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n }\n\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onCellMouseOver(e, ri, ci) {\n if (!this.isMouseDown) return\n if (this.fillDrag.active) return\n if (this.dragMove.active) {\n this.dragMove.targetRow = ri\n this.dragMove.targetCol = ci\n return\n }\n\n // Extend current drag range\n if (this.mouseDownRange) {\n this.mouseDownRange.endRow = ri\n this.mouseDownRange.endCol = ci\n this.emitSelectionChange()\n }\n },\n\n onCellMouseUp() {\n this.isMouseDown = false\n },\n\n onCellDblClick(ri, ci) {\n const col = this.leafCols[ci]\n if (!this.isCellEditable(ri, ci)) return\n if (col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col)) {\n this.openSelectDropdownForActive()\n return\n }\n if (col && col.type === 'date' && !this._hasCustomEdit(col)) {\n this.openDatePickerForActive()\n return\n }\n if (col && col.type === 'checkbox') return\n if (col && col.type === 'action') return\n this.startEditing(ri, ci, null, true)\n },\n\n onRowHeaderMouseDown(e, ri) {\n const endCol = this.leafCols.length - 1\n const fullRow = { startRow: ri, endRow: ri, startCol: 0, endCol }\n\n if (e.ctrlKey || e.metaKey) {\n if (e.shiftKey && this.selectionRanges.length > 0) {\n // Ctrl+Shift: active qatordan ri gacha yangi range qo'sh\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [...this.selectionRanges, { startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n // Ctrl+click: qatorni toggle qil\n const idx = this.selectionRanges.findIndex(r =>\n r.startRow === ri && r.endRow === ri && r.startCol === 0 && r.endCol === endCol\n )\n if (idx >= 0) {\n const next = this.selectionRanges.filter((_, i) => i !== idx)\n this.selectionRanges = next.length ? next : [fullRow]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [...this.selectionRanges, fullRow]\n }\n }\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [{ startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [fullRow]\n }\n\n this.mouseDownRange = this.selectionRanges[this.selectionRanges.length - 1]\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderMouseDown(e, cell) {\n // Start pending drag (activates after 5 px horizontal movement)\n // Works for both leaf and group headers\n if (this.columnDraggable && !this.internalLocked && !e.shiftKey && !e.ctrlKey && !e.metaKey) {\n this.colDrag.pending = true\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = cell._firstLeafFlatIndex\n this.colDrag.sourceLeafCount = cell.isGroup ? cell.colspan : 1\n this.colDrag.startX = e.clientX\n this.colDrag.startY = e.clientY\n this.colDrag.ghostLabel = cell.column.title || ''\n // Grabbed header's rect sizes the ghost and anchors the grab point if\n // the 5px threshold later activates the drag (see _startColDrag).\n this._pendingDragHeaderRect = e.currentTarget ? e.currentTarget.getBoundingClientRect() : null\n }\n\n if (cell.isGroup) {\n // Group header click: select all leaf columns within the group\n const startCol = cell._firstLeafFlatIndex\n const endCol = startCol + cell.colspan - 1\n this.activeCell = { row: 0, col: startCol }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol, endCol }]\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n return\n }\n\n const ci = cell.column._flatIndex\n if (e.shiftKey && this.selectionRanges.length > 0) {\n const sc = Math.min(this.activeCell.col, ci)\n const ec = Math.max(this.activeCell.col, ci)\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: sc, endCol: ec }]\n } else {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n }\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderDblClick(e, cell) {\n if (cell.isGroup) return\n this.autoFitColumn(cell.column._flatIndex)\n },\n\n onColHeaderContextMenu(e, cell) {\n if (!cell.isGroup) {\n this._openContextMenu(e, -1, cell.column._flatIndex)\n }\n },\n\n onRowContextMenu(e, ri) {\n this._openContextMenu(e, ri, -1)\n },\n\n onCellContextMenu(e, ri, ci) {\n if (!this.isSelected(ri, ci)) {\n this.selectCell(ri, ci)\n }\n this._openContextMenu(e, ri, ci)\n },\n\n _openContextMenu(e, row, col) {\n this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row, col }\n this.contextMenuPositioned = false\n this.$nextTick(this._positionContextMenu)\n },\n\n // Flips the menu up/left if it would overflow the viewport at the raw\n // click coordinates — measured against the actual rendered size (item\n // count varies with devMode/pin/row-vs-col context, so an estimate isn't\n // reliable). Rendered off-screen (left:-9999px, see template) while\n // unpositioned so this first measurement reflects the menu's true\n // natural (single-line) width — measuring it in place near a viewport\n // edge would let the browser's own shrink-to-fit sizing wrap the text\n // to fit the cramped space, which then \"sticks\" even after flipping to\n // a roomier position since we only move it, we don't force a width.\n // Stays hidden (visibility, not v-if) until repositioned so there's no\n // visible jump.\n _positionContextMenu() {\n const el = this.$refs.contextMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.contextMenu.x\n let y = this.contextMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.contextMenu.x = x\n this.contextMenu.y = y\n this.contextMenuPositioned = true\n },\n\n hideContextMenu() {\n this.contextMenu.visible = false\n },\n\n // General click-anywhere-in-the-grid dismissal for both floating menus —\n // each menu item stops propagation (@click.stop) so a click *inside*\n // either menu never reaches this handler.\n hideFloatingMenus() {\n this.hideContextMenu()\n this.hideSortMenu()\n },\n\n // =========================================================\n // KEYBOARD NAVIGATION (3.2)\n // =========================================================\n onKeyDown(e) {\n // Delegate keyboard navigation to the active dropdown component\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n if (comp && comp.handleKey) comp.handleKey(e)\n return\n }\n\n if (this.infoModal) {\n if (e.key === 'Escape') this.closeInfoModal()\n return\n }\n\n if (this.editingCell) {\n // Normally handled by onEditKeyDown on the built-in <input> (which stops\n // propagation, so this branch never runs for it). Custom #cell-{key} edit\n // slots own their own input and focus it themselves — if that focus didn't\n // land (e.g. the host relies on the unreliable HTML `autofocus` attribute\n // on a freshly-inserted element), Escape falls through to here instead.\n // Without this, editingCell stays stuck and even arrow-key navigation is\n // swallowed by this same guard on every subsequent keydown.\n if (e.key === 'Escape') this.cancelEdit()\n return\n }\n const key = e.key\n // Layout-independent letter for the Ctrl/Cmd shortcuts below (z/y/a/c/x/v/d/r).\n // e.key reports the character a layout PRODUCES — on a non-Latin layout\n // (Cyrillic, etc.) the physical C key can report e.key: 'с' (Cyrillic es)\n // even with Ctrl held and the physical key/position unchanged, silently\n // breaking `key === 'c'`. e.code is the physical key position and is\n // immune to layout, so it's used here instead wherever available;\n // falls back to e.key when e.code is missing (e.g. synthetic events in\n // tests that don't set it).\n const shortcutKey = (e.code && e.code.length === 4 && e.code.slice(0, 3) === 'Key')\n ? e.code.charAt(3).toLowerCase()\n : key\n\n if ((e.ctrlKey || e.metaKey) && shortcutKey === 'z' && !e.shiftKey) {\n e.preventDefault()\n this.undo()\n return\n }\n if ((e.ctrlKey || e.metaKey) && (shortcutKey === 'y' || (shortcutKey === 'z' && e.shiftKey))) {\n e.preventDefault()\n this.redo()\n return\n }\n\n if (shortcutKey === 'a' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.selectAll()\n return\n }\n\n if (shortcutKey === 'c' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.copySelection()\n return\n }\n if (shortcutKey === 'x' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.cutSelection()\n return\n }\n if (shortcutKey === 'v' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.pasteAt(this.activeCell.row, this.activeCell.col)\n return\n }\n\n if (shortcutKey === 'd' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillDown()\n return\n }\n if (shortcutKey === 'r' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillRight()\n return\n }\n\n if (key === 'Delete' || key === 'Backspace') {\n e.preventDefault()\n this.clearSelection()\n return\n }\n\n if (key === 'F2') {\n e.preventDefault()\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openSelectDropdownForActive()\n } else if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openDatePickerForActive()\n } else if (activeCol && activeCol.type === 'checkbox' && activeEditable) {\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n } else if (activeCol && activeCol.type === 'action' && activeEditable) {\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n } else if (activeEditable) {\n this.startEditing(this.activeCell.row, this.activeCell.col, null, true)\n }\n return\n }\n\n if (key === ' ') {\n const activeCol = this.leafCols[this.activeCell.col]\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n return\n }\n }\n\n if (key === 'Escape') {\n this.clipboardRange = null\n this.clipboardMatrix = null\n this.clipboardMode = null\n return\n }\n\n if (key === 'Tab') {\n e.preventDefault()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n return\n }\n if (key === 'Enter') {\n e.preventDefault()\n this.moveActive(e.shiftKey ? -1 : 1, 0, e)\n return\n }\n\n const arrows = { ArrowUp: [-1,0], ArrowDown: [1,0], ArrowLeft: [0,-1], ArrowRight: [0,1] }\n if (arrows[key]) {\n e.preventDefault()\n const [dr, dc] = arrows[key]\n if (e.shiftKey) {\n this.extendSelection(dr, dc)\n } else {\n this.moveActive(dr, dc, e)\n }\n return\n }\n\n if (key === 'Home') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(0, 0)\n } else {\n this.selectCell(this.activeCell.row, 0)\n }\n return\n }\n if (key === 'End') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(this.totalRows - 1, this.leafCols.length - 1)\n } else {\n this.selectCell(this.activeCell.row, this.leafCols.length - 1)\n }\n return\n }\n if (key === 'PageUp') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(-this._pageRowSpan(), 0)\n } else {\n this.moveActive(-this._pageRowSpan(), 0, e)\n }\n return\n }\n if (key === 'PageDown') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(this._pageRowSpan(), 0)\n } else {\n this.moveActive(this._pageRowSpan(), 0, e)\n }\n return\n }\n },\n\n onKeyPress(e) {\n if (this.editingCell) return\n if (this.selectDropdown) return\n if (this.datePickerOverlay) return\n if (e.ctrlKey || e.metaKey || e.altKey) return\n if (e.key && e.key.length === 1) {\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openSelectDropdownForActive()\n return\n }\n if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openDatePickerForActive()\n return\n }\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n return\n }\n // Without this, the browser's own native \"insert this character\" action\n // (queued for whatever element ends up focused) still fires after\n // startEditing()'s $nextTick focuses the freshly-created edit <input> —\n // which is already seeded with this same character via editValue — so\n // the char gets inserted a second time (e.g. \"1\" -> \"11\").\n e.preventDefault()\n this.startEditing(this.activeCell.row, this.activeCell.col, e.key, false)\n }\n },\n\n onGlobalKeyDown(e) {\n if (e.key === 'Escape' && (this.colDrag.active || this.colDrag.pending)) {\n this._resetColDrag(true)\n e.preventDefault()\n }\n },\n\n // Shift+Arrow / Shift+PageUp/PageDown: extend or shrink the selection\n // relative to the FIXED anchor (activeCell) — not just always growing in\n // the pressed direction. The \"far\" corner is whichever end of the current\n // (normalized) range isn't the anchor; per axis, _extendAxis grows it\n // further out while it's already on the pressed side, or shrinks it back\n // toward the anchor (never past it in one step) while it's on the\n // opposite side — matching Excel/Sheets. Only the axis(es) with a\n // non-zero delta are touched, so a pure vertical extend preserves the\n // current column range untouched, and vice versa.\n extendSelection(dr, dc) {\n const anchor = this.activeCell\n const pr = this.primaryRange || singleCellRange(anchor.row, anchor.col)\n const nm = normalizeRange(pr)\n let farRow = nm.startRow === anchor.row ? nm.endRow : nm.startRow\n let farCol = nm.startCol === anchor.col ? nm.endCol : nm.startCol\n if (dr !== 0) farRow = this._extendAxis(anchor.row, farRow, dr, this.totalRows - 1)\n if (dc !== 0) farCol = this._extendAxis(anchor.col, farCol, dc, this.leafCols.length - 1)\n this.selectionRanges = [buildRange(anchor, { row: farRow, col: farCol })]\n this.emitSelectionChange()\n // Unlike moveActive(), this doesn't move the active cell — but the newly\n // extended (far) corner of the range must still be scrolled into view,\n // exactly like moveActive() does for the active cell itself.\n this.scrollToCell(farRow, farCol)\n },\n\n _extendAxis(anchorIdx, farIdx, delta, maxIdx) {\n if (delta > 0) {\n return farIdx >= anchorIdx ? Math.min(maxIdx, farIdx + delta) : Math.min(anchorIdx, farIdx + delta)\n }\n return farIdx <= anchorIdx ? Math.max(0, farIdx + delta) : Math.max(anchorIdx, farIdx + delta)\n },\n\n moveActive(dr, dc, e) {\n let row = this.activeCell.row + dr\n let col = this.activeCell.col + dc\n\n // Tab: wrap around rows\n if (e && e.key === 'Tab') {\n if (col >= this.leafCols.length) { col = 0; row++ }\n if (col < 0) { col = this.leafCols.length - 1; row-- }\n }\n\n row = Math.max(0, Math.min(this.totalRows - 1, row))\n col = Math.max(0, Math.min(this.leafCols.length - 1, col))\n\n // Skip hidden cols\n while (this.isColHidden(col) && col > 0 && col < this.leafCols.length - 1) {\n col += dc >= 0 ? 1 : -1\n }\n // Skip hidden rows\n while (this.isRowHidden(row) && row > 0 && row < this.totalRows - 1) {\n row += dr >= 0 ? 1 : -1\n }\n\n // Handle merge navigation\n const resolved = resolveMergeAnchor(row, col, this.internalMerges)\n row = resolved.row\n col = resolved.col\n\n this.activeCell = { row, col }\n this.selectionRanges = [singleCellRange(row, col)]\n this.emitSelectionChange()\n this.scrollToCell(row, col)\n },\n\n scrollToCell(ri, ci) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return\n const rowTop = getRowTop(ri, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD)\n const rowBottom = rowTop + this.getRowH(ri)\n\n if (rowTop < scrollBody.scrollTop) scrollBody.scrollTop = rowTop\n if (rowBottom > scrollBody.scrollTop + scrollBody.clientHeight) {\n scrollBody.scrollTop = rowBottom - scrollBody.clientHeight\n }\n\n // Pinned columns are sticky — always horizontally in view, nothing to do.\n if (!this.isColumnScrollable(ci)) return\n const zoneLeft = this.getZoneColLeft(ci, 'scroll')\n const zoneRight = zoneLeft + this.getColW(ci)\n // A scrollable cell only counts as visible inside the window the sticky\n // pinned zones don't cover — without subtracting them, a cell could sit\n // fully underneath a pinned zone yet be treated as already in view.\n const visibleW = scrollBody.clientWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n if (zoneLeft < scrollBody.scrollLeft) scrollBody.scrollLeft = zoneLeft\n if (zoneRight > scrollBody.scrollLeft + visibleW) {\n scrollBody.scrollLeft = zoneRight - visibleW\n }\n },\n\n // =========================================================\n // EDITING (used by 3.1 + 3.2)\n // =========================================================\n startEditing(ri, ci, initialChar = null, keepValue = false) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (ri < 0 || ri >= this.totalRows) return\n if (!this._hasCustomEdit(col)) {\n if (this.isSelectLikeCol(col)) { this.openSelectDropdownForActive(); return }\n if (col.type === 'date') { this.openDatePickerForActive(); return }\n if (col.type === 'checkbox') return\n if (col.type === 'action') return\n }\n\n this.invalidCell = null\n this.editingCell = { row: ri, col: ci }\n if (initialChar !== null) {\n this.editValue = initialChar\n } else if (keepValue) {\n this.editValue = String(this.getCellValue(ri, ci) || '')\n } else {\n this.editValue = ''\n }\n this.editOriginalValue = String(this.getCellValue(ri, ci) || '')\n\n this.$nextTick(() => {\n const inputs = this.$refs.editInput\n if (inputs) {\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) {\n // col.mask: MaskInput owns the element's live formatting/caret while\n // typing and re-dispatches a corrected native 'input' event after\n // every keystroke, which the existing v-model=\"editValue\" binding\n // picks up normally — no separate sync code needed. It also masks\n // whatever value is already in the input (seeded above) on attach.\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (col.mask) {\n const options = (typeof col.mask === 'string' || Array.isArray(col.mask)) ? { mask: col.mask } : col.mask\n this._maskInput = new MaskInput(input, options)\n }\n input.focus()\n if (keepValue) {\n input.setSelectionRange(input.value.length, input.value.length)\n }\n }\n }\n })\n },\n\n commitEdit() {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n\n // editValue is already masked live by MaskInput (see startEditing) — this\n // is a defensive final pass, cheap/idempotent, and covers any commit that\n // skipped the live-typing path (e.g. programmatic editValue changes).\n const value = this._coerceCellValue(col, this._applyColMask(col, this.editValue))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n cancelEdit() {\n clearTimeout(this._onInputTimer)\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Fires while the user is actively typing in the plain-text edit input —\n // debounced (col.debounceMs, default 400ms) so col.onInput isn't called\n // on every keystroke. `row` is the pre-edit row (not yet committed);\n // `value` is the live in-progress text.\n onEditInput(e) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n this._scheduleOnInput(row, col, e.target.value)\n },\n\n _scheduleOnInput(ri, ci, value) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n clearTimeout(this._onInputTimer)\n this._onInputPending = true\n const delay = typeof col.debounceMs === 'number' ? col.debounceMs : 400\n this._onInputTimer = setTimeout(() => {\n this._onInputPending = false\n const row = this._dataRaw[ri]\n if (row) {\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row, ri, col)\n // col.onInput is a common place for a host to derive/write another\n // cell's value on the same (already-reactive) row object — but cell\n // rendering reads through the non-reactive _dataRaw (see created()),\n // so an in-place mutation like that is otherwise invisible until the\n // next time `data` itself gets a new reference (e.g. on commit).\n this.$forceUpdate()\n }\n }, delay)\n },\n\n // Cancels any pending debounced call and fires col.onInput immediately —\n // used at commit points (Enter/blur, date pick, checkbox toggle) so a\n // still-in-flight debounce timer never drops the final value. If nothing\n // is pending (the debounce already fired naturally with this exact\n // value), skip re-firing — commitEdit() calls this unconditionally, so\n // without this guard a slow typist would get onInput called twice for\n // the same settled value.\n _flushOnInput(ri, ci, value, row) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n const wasPending = this._onInputPending\n clearTimeout(this._onInputTimer)\n this._onInputPending = false\n const last = this._lastFiredOnInput\n if (!wasPending && last && last.ri === ri && last.ci === ci && last.value === value) return\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row || this._dataRaw[ri], ri, col)\n // See _scheduleOnInput — a same-tick mutation to another cell on `row`\n // needs its own forceUpdate, since it happens after the data-watcher's\n // own forceUpdate (triggered by the commit that led here) already ran.\n this.$forceUpdate()\n },\n\n hasCellSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['cell-' + col.key])\n },\n hasLegacyEditSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['edit-' + col.key])\n },\n hasPrefixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['prefix-' + col.key])\n },\n hasSuffixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['suffix-' + col.key])\n },\n _hasCustomEdit(col) {\n // For select/treeSelect/date: cell-{key} slot only customizes display; picker still opens normally\n if (col.type === 'select' || col.type === 'treeSelect' || col.type === 'date') {\n return this.hasLegacyEditSlot(col)\n }\n return this.hasCellSlot(col) || this.hasLegacyEditSlot(col)\n },\n\n commitEditSlot(value) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n value = this._coerceCellValue(col, this._applyColMask(col, value))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // CHECKBOX CELLS\n // =========================================================\n\n isCheckboxChecked(ri, ci) {\n const v = this.getCellValue(ri, ci)\n if (v === true || v === 1) return true\n if (typeof v === 'string') return ['true', '1'].includes(v.trim().toLowerCase())\n return false\n },\n\n setCheckboxValue(ri, ci, value) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (!this.isCellValueValid(ri, ci, value)) {\n this._showInvalidCell(ri, ci, value)\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[ri] = { ...oldData[ri], [col.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(ri, ci, value, newData[ri])\n },\n\n toggleCheckbox(ri, ci) {\n this.setCheckboxValue(ri, ci, !this.isCheckboxChecked(ri, ci))\n },\n\n // =========================================================\n // ACTION CELLS (col.type === 'action')\n // =========================================================\n\n // Dynamic v-on argument expressions can't contain quotes/spaces in Vue 2.6\n // templates, so the 'click' fallback is resolved here instead of inline.\n actionHandlerEvent(col) {\n return (col && col.handlerType) || 'click'\n },\n\n triggerCellAction(ri, ci, e) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || col.type !== 'action' || typeof col.handler !== 'function') return\n if (!this.isCellEditable(ri, ci)) return\n col.handler(this._dataRaw[ri], ri, col, ci, e)\n },\n\n // =========================================================\n // CELL EDITABILITY (3.17)\n // =========================================================\n\n isCellEditable(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return false\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellEditable === 'function') return !!this.cellEditable(row, ri, col, ci)\n if (typeof col.editable === 'function') return !!col.editable(row, ri)\n if (col.editable === false) return false\n return true\n },\n\n // =========================================================\n // CELL VALUE VALIDATION (cellValidator)\n // =========================================================\n\n isCellValueValid(ri, ci, value) {\n if (typeof this.cellValidator !== 'function') return true\n const col = this.leafCols[ci]\n if (!col) return true\n const row = this._dataRaw[ri] || {}\n return !!this.cellValidator(value, row, ri, col, ci)\n },\n\n _resolveValidationMessage(ri, ci, value) {\n const col = this.leafCols[ci]\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellValidationMessage === 'function') return this.cellValidationMessage(value, row, ri, col, ci)\n if (typeof this.cellValidationMessage === 'string' && this.cellValidationMessage) return this.cellValidationMessage\n return this.t.invalidValue\n },\n\n // Find the on-screen rect of the cell currently being committed — the edit\n // input if still mounted (commitEdit, before editingCell is cleared), else\n // the active cell element (checkbox/select commits, which never enter edit mode).\n _invalidCellRect() {\n const inputs = this.$refs.editInput\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) return input.getBoundingClientRect()\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n return el ? el.getBoundingClientRect() : null\n },\n\n _showInvalidCell(ri, ci, value) {\n const message = this._resolveValidationMessage(ri, ci, value)\n this.invalidCell = { row: ri, col: ci, message, rect: this._invalidCellRect() }\n this.$emit('cell-validation-error', { row: ri, col: ci, value, message })\n },\n\n // Resolve+emit messages for a batch of rejected { row, col, value } entries\n // from a bulk write (paste / fill / clear). blocked=true means the whole\n // operation was aborted (strictBulkValidation); false means these cells\n // were skipped but the rest of the write still applied.\n _emitBulkValidationError(skipped, blocked) {\n const cells = skipped.map(c => ({\n row: c.row, col: c.col, value: c.value,\n message: this._resolveValidationMessage(c.row, c.col, c.value)\n }))\n this.$emit('bulk-validation-error', { blocked, cells })\n },\n\n // =========================================================\n // SELECT DROPDOWN (3.16) + TREE SELECT (3.22)\n // =========================================================\n\n isSelectLikeCol(col) {\n return !!(col && (col.type === 'select' || col.type === 'treeSelect'))\n },\n\n treeOptionKeys(col) {\n return {\n valueKey: col.optionValueKey || 'value',\n labelKey: col.optionLabelKey || 'label',\n childrenKey: col.optionChildrenKey || 'children'\n }\n },\n\n // Returns { [colKey]: 'left'|'right' } for columns that declare col.pin.\n _colPinMap(cols) {\n const map = {}\n ;(cols || []).forEach(col => {\n if (col.pin === 'left' || col.pin === 'right') map[col.key] = col.pin\n })\n return map\n },\n\n _treeFindLabel(nodes, keys, value) {\n for (const node of (nodes || [])) {\n // A malformed col.options (e.g. a host array with a hole/undefined entry,\n // often from an unguarded Array.prototype.find() result spread back in)\n // shouldn't crash the whole grid's render — skip it like a missing option.\n if (!node) continue\n if (String(node[keys.valueKey]) === value) {\n return node[keys.labelKey] != null ? node[keys.labelKey] : String(node[keys.valueKey])\n }\n const found = this._treeFindLabel(node[keys.childrenKey], keys, value)\n if (found != null) return found\n }\n return null\n },\n\n // col.options may be a static array OR a function (row, rowIndex, column, colIndex) => array,\n // letting hosts return a different option list per cell.\n hasOptionsSource(col) {\n return !!(col && (Array.isArray(col.options) || typeof col.options === 'function'))\n },\n\n resolveOptionsArray(col, ri, ci) {\n if (!col) return []\n if (typeof col.options === 'function') {\n const row = ri != null ? (this._dataRaw[ri] || {}) : {}\n const result = col.options(row, ri, col, ci != null ? ci : col._flatIndex)\n return Array.isArray(result) ? result : []\n }\n return Array.isArray(col.options) ? col.options : []\n },\n\n colOptions(ci, ri) {\n const col = this.leafCols[ci]\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return []\n // optionValueKey/optionLabelKey apply to type:'select' the same way they\n // already do for type:'treeSelect' — same config, same defaults.\n const { valueKey, labelKey } = this.treeOptionKeys(col)\n return raw.map(o =>\n typeof o === 'string' ? { value: o, label: o } : { value: o[valueKey], label: o[labelKey] != null ? o[labelKey] : String(o[valueKey]), color: o.color }\n )\n },\n\n getSelectLabel(ci, value, ri) {\n const col = this.leafCols[ci]\n const v = value == null ? '' : String(value)\n if (col && col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return v\n const found = this._treeFindLabel(raw, this.treeOptionKeys(col), v)\n return found != null ? found : v\n }\n const opts = this.colOptions(ci, ri)\n if (!opts.length) return v\n const found = opts.find(o => String(o.value) === v)\n return found ? found.label : v\n },\n\n _buildSelectDropdownState(ri, ci, col, currentValue, rect) {\n const rectObj = { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n return {\n row: ri, col: ci,\n tree: col.type === 'treeSelect',\n // valueKey/labelKey apply to both select and treeSelect (childrenKey\n // only matters for the tree dropdown, harmlessly unused otherwise).\n keys: this.treeOptionKeys(col),\n rawOptions: this.resolveOptionsArray(col, ri, ci),\n currentValue,\n rect: rectObj,\n // Defaults to on (opt out with enableSearch: false) — same convention\n // as clearable below.\n enableSearch: col.enableSearch !== false,\n clearable: col.clearable !== false\n }\n },\n\n openSelectDropdown(ri, ci, e) {\n const col = this.leafCols[ci]\n if (!this.hasOptionsSource(col)) return\n const el = e.currentTarget\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(ri, ci) != null ? this.getCellValue(ri, ci) : '')\n this.selectDropdown = this._buildSelectDropdownState(ri, ci, col, currentValue, rect)\n },\n\n openSelectDropdownForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!this.hasOptionsSource(colDef)) return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(row, col) != null ? this.getCellValue(row, col) : '')\n this.selectDropdown = this._buildSelectDropdownState(row, col, colDef, currentValue, rect)\n })\n },\n\n onComponentSelect({ value, label }) {\n if (this.internalLocked) { this.closeSelectDropdown(); return }\n if (!this.selectDropdown) return\n const { row, col } = this.selectDropdown\n const colDef = this.leafCols[col]\n if (colDef) {\n // Clearing (value === '') always goes through, same as onDatePick()'s\n // empty-string bypass — \"no selection\" is a valid state regardless of\n // what cellValidator requires of a real picked value.\n if (value !== '' && !this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.closeSelectDropdown()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n if (typeof colDef.onSelect === 'function') {\n // Deferred the same way as _fireCellCallbacksForRange (paste/fill/drag-move) —\n // a host watching the emitted data (e.g. mirroring it into its own\n // store) needs that watcher to run first, or onSelect reads stale state.\n this.$nextTick(() => {\n colDef.onSelect({ value, label }, newData[row], row, colDef)\n // See _scheduleOnInput — picks up any same-tick mutation the callback\n // made to another cell on this (already-reactive) row object.\n this.$forceUpdate()\n })\n }\n }\n this.closeSelectDropdown()\n },\n\n // Always returns focus to the grid wrapper — the dropdown (especially with\n // enableSearch) can hold real DOM focus on its own search input, and every\n // close path (Escape, outside click, picking a value) must hand focus back\n // or keyboard navigation on the grid goes dead until the user clicks a cell.\n closeSelectDropdown() {\n this.selectDropdown = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // DATE PICKER\n // =========================================================\n\n getCellDisplayValue(ci, val, ri) {\n const col = this.leafCols[ci]\n if (!col) return val == null ? '' : val\n if (typeof col.formatter === 'function') {\n const out = col.formatter(val, this._dataRaw[ri], ri, col, ci)\n return out == null ? '' : out\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, val, ri)\n if (col.type === 'date') {\n const d = parseDateValue(val)\n return d ? formatDate(d, col.format || DEFAULT_DATE_FORMAT) : (val == null ? '' : val)\n }\n return val == null ? '' : val\n },\n\n // =========================================================\n // FILTER ROW\n // =========================================================\n\n // Which control the filter cell renders for this column.\n // 'none' cells still render (empty) so column alignment stays intact.\n filterControlType(col) {\n if (!col || col.filterable === false || col.type === 'action') return 'none'\n if (col.type === 'checkbox') return 'checkbox'\n // Function-based options can't be resolved into a fixed list outside a\n // specific row, so those columns fall back to the text control.\n if ((col.type === 'select' || col.type === 'treeSelect') && Array.isArray(col.options)) return 'select'\n return 'text'\n },\n\n // Flat { value, label } list for a select-control filter cell. treeSelect\n // options are recursively flattened, each depth indented for readability.\n filterOptionsFor(col) {\n if (!col) return []\n if (col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, null, col._flatIndex)\n const out = []\n this._flattenTreeOptionsForFilter(raw, this.treeOptionKeys(col), 0, out)\n return out\n }\n return this.colOptions(col._flatIndex, null)\n },\n\n _flattenTreeOptionsForFilter(nodes, keys, depth, out) {\n for (const node of (nodes || [])) {\n const value = node[keys.valueKey]\n const label = node[keys.labelKey] != null ? node[keys.labelKey] : String(value)\n out.push({ value: String(value), label: '  '.repeat(depth) + label })\n this._flattenTreeOptionsForFilter(node[keys.childrenKey], keys, depth + 1, out)\n }\n },\n\n getFilterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n return {\n position: 'absolute',\n left: left + 'px',\n top: (this._headerRowH * this.headerDepth) + 'px',\n width: w + 'px',\n height: this._filterRowH + 'px',\n boxSizing: 'border-box'\n }\n },\n\n setFilter(key, value) {\n if (!this.leafCols.some(c => c.key === key)) return\n const next = { ...this.internalFilters }\n if (value === '' || value === null || value === undefined) {\n delete next[key]\n } else {\n next[key] = value\n }\n this.internalFilters = next\n this.$emit('update:filters', { ...next })\n this.$emit('filter-change', { key, value, filters: { ...next } })\n },\n\n clearFilters() {\n if (!Object.keys(this.internalFilters).length) return\n this.internalFilters = {}\n // A deliberate, infrequent action (unlike typing) — recompute immediately\n // rather than waiting out the debounce, and drop any timer already pending.\n clearTimeout(this._filterRecomputeTimer)\n this._recomputeRowFilters()\n this.$emit('update:filters', {})\n this.$emit('filter-change', { key: null, value: null, filters: {} })\n },\n\n // Debounced — recomputing filteredRowIndices scans every row, so typing\n // in the text filter shouldn't trigger it on every keystroke.\n _scheduleFilterRecompute() {\n clearTimeout(this._filterRecomputeTimer)\n this._filterRecomputeTimer = setTimeout(() => { this._recomputeRowFilters() }, 150)\n },\n\n _activeFilterCols() {\n const cols = []\n for (const col of this.leafCols) {\n if (this.filterControlType(col) === 'none') continue\n const v = this.internalFilters[col.key]\n if (v === undefined || v === null || v === '') continue\n cols.push(col)\n }\n return cols\n },\n\n _cellMatchesFilter(row, ri, col) {\n const ci = col._flatIndex\n const filterValue = this.internalFilters[col.key]\n if (typeof col.filterFn === 'function') {\n return !!col.filterFn(this.getCellValue(ri, ci), filterValue, row, ri, col)\n }\n const kind = this.filterControlType(col)\n if (kind === 'checkbox') {\n const checked = this.isCheckboxChecked(ri, ci)\n return filterValue === 'true' ? checked : !checked\n }\n if (kind === 'select') {\n const raw = this.getCellValue(ri, ci)\n return String(raw == null ? '' : raw) === String(filterValue)\n }\n const display = this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri)\n return String(display == null ? '' : display).toLowerCase().indexOf(String(filterValue).toLowerCase()) !== -1\n },\n\n _recomputeRowFilters() {\n // Can be reached (via the `columns`/`filterRow` watchers) during initWatch(),\n // before created() has set up _dataRaw — getCellValue() depends on it. The\n // `data` watcher's own immediate handler sets _dataRaw first, then always\n // calls this again, so the initial mount still ends up correct.\n if (!this._dataRaw) return\n if (!this.filterRow) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const activeCols = this._activeFilterCols()\n if (!activeCols.length) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const rows = this.data\n const out = []\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n let matches = true\n for (const col of activeCols) {\n if (!this._cellMatchesFilter(row, ri, col)) { matches = false; break }\n }\n if (!matches) out.push(ri)\n }\n this.filteredRowIndices = out\n },\n\n // =========================================================\n // COLUMN SORTING\n // =========================================================\n\n isColSortable(col) {\n return !!(this.sortable && col && col.sortable !== false && col.type !== 'action')\n },\n\n sortIconClass(col) {\n const active = !!(this.internalSort && this.internalSort.key === col.key)\n return active ? 'eg-icon-sort-menu eg-sort-icon-active' : 'eg-icon-sort-menu'\n },\n\n sortIconTitle() {\n return this.t.sortMenuTooltip\n },\n\n // Opens the sort popup menu (A → Z / Z → A / Cancel) anchored to the\n // clicked header icon. Replaces the old direct cycle-on-click behavior.\n openSortMenu(e, col) {\n if (this.internalLocked) return\n this.sortMenu = { visible: true, x: e.clientX, y: e.clientY, key: col.key }\n this.sortMenuPositioned = false\n this.$nextTick(this._positionSortMenu)\n },\n\n // Same flip-to-fit reasoning as _positionContextMenu().\n _positionSortMenu() {\n const el = this.$refs.sortMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.sortMenu.x\n let y = this.sortMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.sortMenu.x = x\n this.sortMenu.y = y\n this.sortMenuPositioned = true\n },\n\n hideSortMenu() {\n this.sortMenu.visible = false\n },\n\n // Marks whichever direction is currently applied to the menu's own\n // column so the popup shows which option is active (checkmark, see CSS).\n isSortMenuOptionActive(direction) {\n return !!(\n this.internalSort &&\n this.sortMenu.key &&\n this.internalSort.key === this.sortMenu.key &&\n this.internalSort.direction === direction\n )\n },\n\n sortMenuPickAsc() {\n this.setSort(this.sortMenu.key, 'asc')\n this.hideSortMenu()\n },\n\n sortMenuPickDesc() {\n this.setSort(this.sortMenu.key, 'desc')\n this.hideSortMenu()\n },\n\n sortMenuPickClear() {\n this.clearSort()\n this.hideSortMenu()\n },\n\n // Cycles a column through none → asc → desc → none. Kept as a public\n // method for programmatic/custom #header-{key} slot use — the built-in\n // header icon now opens the popup menu (openSortMenu) instead.\n toggleSort(key) {\n if (this.internalLocked) return\n const current = this.internalSort\n if (!current || current.key !== key) { this.setSort(key, 'asc'); return }\n if (current.direction === 'asc') { this.setSort(key, 'desc'); return }\n this.clearSort()\n },\n\n setSort(key, direction) {\n if (direction !== 'asc' && direction !== 'desc') { this.clearSort(); return }\n this.sortByColumn(key, direction)\n },\n\n // Clicking a third time (or calling this directly) clears the sort\n // indicator AND restores the row order from immediately before the\n // first sort of this session (see _preSortData in sortByColumn()) —\n // switching columns or direction mid-session doesn't move that\n // reference point, so this always lands back on the true original\n // order. Emitted as a normal data change (_emitDataChange), so it's\n // itself undoable/redoable like any other edit.\n clearSort() {\n if (!this.internalSort) return\n this.internalSort = null\n const oldData = this.data\n const restored = this._preSortData\n this._preSortData = null\n // Row count mismatch means rows were added/removed since the sort —\n // the snapshot no longer lines up with the current data, so skip the\n // restore rather than silently drop/duplicate rows (indicator still\n // clears either way).\n if (restored && restored.length === oldData.length) {\n this._emitDataChange(oldData, restored)\n }\n this.$emit('update:sort', null)\n this.$emit('sort-change', { key: null, direction: null })\n },\n\n sortByColumn(key, direction) {\n if (this.internalLocked) return\n // Merges and row groups are anchored to row *position* — a reorder\n // would silently invalidate them, so sorting is blocked while either\n // exists (same \"blocked while locked\" no-op convention as elsewhere).\n if (this.internalMerges.length || this.rowGroups.length) return\n const col = this.leafCols.find(c => c.key === key)\n if (!this.isColSortable(col)) return\n\n const oldData = this.data\n // Remember the order from before the *first* sort of this session —\n // re-sorting the same or a different column (asc<->desc, or switching\n // columns) without clearing first must not move this reference point,\n // so clearSort() always lands back on the true original order.\n if (!this.internalSort) this._preSortData = oldData\n const pairs = oldData.map((row, ri) => ({ row, ri }))\n pairs.sort((a, b) => this._compareForSort(a.row, a.ri, b.row, b.ri, col, direction))\n const newData = pairs.map(p => p.row)\n\n this.internalSort = { key, direction }\n this._emitDataChange(oldData, newData)\n this.$emit('update:sort', { key, direction })\n this.$emit('sort-change', { key, direction })\n\n this.scrollTop = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) this.$refs.scrollBody.scrollTop = 0\n })\n },\n\n _sortValueFor(row, ri, col) {\n const ci = col._flatIndex\n if (col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? 1 : 0\n if (col.type === 'date') {\n const d = parseDateValue(this.getCellValue(ri, ci))\n return d ? d.getTime() : null\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, this.getCellValue(ri, ci), ri)\n return this.getCellValue(ri, ci)\n },\n\n _compareForSort(rowA, riA, rowB, riB, col, direction) {\n if (typeof col.sortFn === 'function') {\n const result = col.sortFn(rowA, rowB, col)\n return direction === 'desc' ? -result : result\n }\n const a = this._sortValueFor(rowA, riA, col)\n const b = this._sortValueFor(rowB, riB, col)\n const aEmpty = a === null || a === undefined || a === ''\n const bEmpty = b === null || b === undefined || b === ''\n if (aEmpty && bEmpty) return 0\n if (aEmpty) return 1\n if (bEmpty) return -1\n const cmp = (typeof a === 'number' && typeof b === 'number')\n ? a - b\n : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n return direction === 'desc' ? -cmp : cmp\n },\n\n openDatePickerForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!colDef || colDef.type !== 'date') return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const val = this.getCellValue(row, col)\n this.datePickerOverlay = {\n row, col,\n format: colDef.format || DEFAULT_DATE_FORMAT,\n value: val == null ? '' : String(val),\n rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n }\n })\n },\n\n onDatePick(formattedVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n const colDef = this.leafCols[col]\n if (colDef) {\n if (formattedVal !== '' && !this.isCellValueValid(row, col, formattedVal)) {\n this._showInvalidCell(row, col, formattedVal)\n this.closeDatePicker()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: formattedVal }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, formattedVal, newData[row])\n }\n this.closeDatePicker()\n },\n\n // Raw keystrokes while typing in the date picker's manual text field —\n // debounced, same as onEditInput(). onDatePick() above flushes the final,\n // validated value immediately when the date actually commits.\n onDateTyping(rawVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n this._scheduleOnInput(row, col, rawVal)\n },\n\n // See closeSelectDropdown() — same reasoning: every close path must return\n // focus to the grid wrapper or keyboard navigation goes dead afterward.\n closeDatePicker() {\n clearTimeout(this._onInputTimer)\n this.datePickerOverlay = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n _onWindowScrollForDropdown(e) {\n if (this.contextMenu.visible) this.hideContextMenu()\n if (this.sortMenu.visible) this.hideSortMenu()\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n _onDocMouseDownForDropdown(e) {\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n onEditKeyDown(e) {\n if (e.key === 'Escape') {\n e.preventDefault()\n this.cancelEdit()\n } else if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(1, 0, e)\n } else if (e.key === 'Tab') {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n }\n },\n\n clearSelection() {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n let hasChanges = false\n const skipped = []\n for (const range of this.selectionRanges) {\n const r = {\n startRow: Math.min(range.startRow, range.endRow),\n endRow: Math.max(range.startRow, range.endRow),\n startCol: Math.min(range.startCol, range.endCol),\n endCol: Math.max(range.startCol, range.endCol)\n }\n for (let ri = r.startRow; ri <= r.endRow && ri < newData.length; ri++) {\n let rowUpdated = null\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n if (!this.isCellValueValid(ri, ci, '')) { skipped.push({ row: ri, col: ci, value: '' }); continue }\n if (!rowUpdated) rowUpdated = { ...newData[ri] }\n rowUpdated[col.key] = ''\n hasChanges = true\n }\n }\n if (rowUpdated) newData[ri] = rowUpdated\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n if (hasChanges) this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // FILL HANDLE (3.3)\n // =========================================================\n startFillDrag(e) {\n if (this.internalLocked) return\n const pr = this.primaryRange\n if (!pr) return\n this.fillDrag.active = true\n this.fillDrag.sourceRange = normalizeRange(pr)\n this.fillDrag.currentRow = pr.endRow\n this.fillDrag.currentCol = pr.endCol\n e.preventDefault()\n },\n\n onGlobalMouseMove(e) {\n // Detect column drag threshold (5 px)\n if (this.colDrag.pending) {\n if (Math.abs(e.clientX - this.colDrag.startX) > 5) {\n this.colDrag.pending = false\n this.colDrag.active = true\n this.colDrag.dropIndex = this.colDrag.sourceFirstLeaf\n this.isMouseDown = false // cancel cell-selection drag\n this._startColDrag()\n }\n }\n if (this.colDrag.active) {\n this.handleColDragMove(e)\n return\n }\n\n if (this.resizing.active) {\n this.handleResizeMove(e)\n return\n }\n if (this.fillDrag.active) {\n this.handleFillDragMove(e)\n return\n }\n if (this.dragMove.active) {\n this.handleDragMoveMove(e)\n return\n }\n },\n\n onGlobalMouseUp(e) {\n if (this.colDrag.active) {\n this.commitColDrag()\n return\n }\n if (this.colDrag.pending) {\n this._resetColDrag()\n // fall through — normal mouseup\n }\n\n if (this.resizing.active) {\n this.commitResize()\n return\n }\n if (this.fillDrag.active) {\n this.commitFillDrag()\n return\n }\n if (this.dragMove.active) {\n this.commitDragMove(e)\n return\n }\n this.isMouseDown = false\n },\n\n // Resolves which (row, col) the mouse is currently over, given the\n // scroll body's CURRENT scrollTop/scrollLeft — called both directly from\n // mousemove and repeatedly from the edge-auto-scroll rAF loop below (as\n // scrollTop changes on its own, the row under a STATIONARY cursor changes\n // with it).\n _fillDragTargetFromEvent(e) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return null\n const rect = scrollBody.getBoundingClientRect()\n const relY = e.clientY - rect.top + scrollBody.scrollTop\n const relX = e.clientX - rect.left + scrollBody.scrollLeft\n\n // Binary search on cumulativeHeights — O(log n) instead of O(n)\n const cum = this.cumulativeHeights\n const total = this.totalRows\n let lo = 0, hi = total - 1\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if (cum[mid + 1] <= relY) lo = mid + 1\n else hi = mid\n }\n const row = Math.min(lo, total - 1)\n\n // Columns are few — O(columns) is fast\n let left = this.getFrozenWidth()\n let col = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColumnScrollable(i)) continue\n left += this.getColW(i)\n if (relX < left) { col = i; break }\n col = i\n }\n return { row, col }\n },\n\n handleFillDragMove(e) {\n this._fillDragLastEvent = e\n const target = this._fillDragTargetFromEvent(e)\n if (!target) return\n this.fillDrag.currentRow = target.row\n this.fillDrag.currentCol = target.col\n // Covers the common case of a single big mousemove jump landing the\n // cursor (and its computed target row/col) outside the visible window.\n this.scrollToCell(target.row, target.col)\n // Covers the — far more common in practice — case of the cursor\n // PINNED at the container's edge (it usually can't travel further,\n // since the grid typically fills its host layout down to that edge):\n // keep scrolling for as long as it stays there. Same rAF/edge-zone\n // pattern as _updateDragAutoScroll (column-drag reordering) below.\n this._updateFillDragAutoScroll(e.clientX, e.clientY)\n },\n\n // Edge auto-scroll for fill-handle dragging — vertical AND horizontal\n // (column-drag's _updateDragAutoScroll is horizontal-only). Speed scales\n // with how deep into the 24px edge zone the cursor sits.\n _updateFillDragAutoScroll(clientX, clientY) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._fillDragScrollV = 0; this._fillDragScrollH = 0; return }\n const rect = sb.getBoundingClientRect()\n const ZONE = FILL_DRAG_EDGE_ZONE\n\n let v = 0\n if (clientY < rect.top + ZONE) v = -Math.ceil((rect.top + ZONE - clientY) / 4)\n else if (clientY > rect.bottom - ZONE) v = Math.ceil((clientY - (rect.bottom - ZONE)) / 4)\n this._fillDragScrollV = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, v))\n\n let h = 0\n if (clientX < rect.left + ZONE) h = -Math.ceil((rect.left + ZONE - clientX) / 4)\n else if (clientX > rect.right - ZONE) h = Math.ceil((clientX - (rect.right - ZONE)) / 4)\n this._fillDragScrollH = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, h))\n\n if ((this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) && !this._fillDragScrollRAF) {\n const step = () => {\n this._fillDragScrollRAF = null\n if (!this.fillDrag.active) return\n const body = this.$refs.scrollBody\n if (body) {\n if (this._fillDragScrollV) body.scrollTop = Math.max(0, Math.min(body.scrollHeight - body.clientHeight, body.scrollTop + this._fillDragScrollV))\n if (this._fillDragScrollH) body.scrollLeft = Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollLeft + this._fillDragScrollH))\n // The cursor itself hasn't moved, but the content under it has —\n // re-derive the fill target at the new scroll position.\n if (this._fillDragLastEvent) {\n const target = this._fillDragTargetFromEvent(this._fillDragLastEvent)\n if (target) { this.fillDrag.currentRow = target.row; this.fillDrag.currentCol = target.col }\n }\n }\n if (this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopFillDragAutoScroll() {\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragLastEvent = null\n if (this._fillDragScrollRAF) {\n cancelAnimationFrame(this._fillDragScrollRAF)\n this._fillDragScrollRAF = null\n }\n },\n\n async commitFillDrag() {\n this._stopFillDragAutoScroll()\n if (this.internalLocked) { this.fillDrag.active = false; return }\n const src = this.fillDrag.sourceRange\n if (!src) { this.fillDrag.active = false; return }\n\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n\n const oldData = this.data\n const newData = oldData.slice() // only affected rows get new objects\n let targetRange\n const skipped = []\n\n if (targetRow > src.endRow) {\n // Fill down\n const count = targetRow - src.endRow\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n targetRange = { startRow: src.endRow + 1, endRow: targetRow, startCol: src.startCol, endCol: src.endCol }\n } else if (targetCol > src.endCol) {\n // Fill right\n const count = targetCol - src.endCol\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n const srcValues = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n srcValues.push(col && oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ci = src.endCol + 1 + k\n if (ci < this.leafCols.length) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n }\n targetRange = { startRow: src.startRow, endRow: src.endRow, startCol: src.endCol + 1, endCol: targetCol }\n }\n\n let fireOnSelect = false\n if (targetRange) {\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n } else {\n this._emitDataChange(oldData, newData)\n this.$emit('fill', { sourceRange: src, targetRange, values: newData })\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n fireOnSelect = true\n }\n }\n\n this.fillDrag.active = false\n this.fillDrag.sourceRange = null\n\n if (fireOnSelect) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n doubleFillDown() {\n const pr = this.primaryRange\n if (!pr) return\n const src = normalizeRange(pr)\n // Find adjacent column filled length\n const adjCol = src.startCol > 0 ? src.startCol - 1 : src.endCol + 1\n if (adjCol < 0 || adjCol >= this.leafCols.length) return\n const adjColDef = this.leafCols[adjCol]\n if (!adjColDef) return\n let lastFilledRow = src.endRow\n for (let ri = src.endRow + 1; ri < this.totalRows; ri++) {\n if (this.data[ri] && this.data[ri][adjColDef.key] !== '' && this.data[ri][adjColDef.key] !== undefined) {\n lastFilledRow = ri\n } else break\n }\n if (lastFilledRow <= src.endRow) return\n\n const count = lastFilledRow - src.endRow\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // Ctrl+D / Ctrl+R — Excel's standard fill-down/fill-right (3.96)\n // =========================================================\n // Plain copy, NOT the fill-handle's series continuation (generateFillValues,\n // 1,2,3->4,5,6) — matches real Excel: Ctrl+D/Ctrl+R just duplicates a row/\n // column's values, it never extrapolates a pattern.\n async _fillAxis(direction) {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n const appliedRanges = []\n\n for (const range of this.selectionRanges) {\n const r = normalizeRange(range)\n if (direction === 'down') {\n let srcRow, firstTargetRow, lastTargetRow\n if (r.startRow === r.endRow) {\n // Single-row selection: Excel fills from the row directly above.\n srcRow = r.startRow - 1\n if (srcRow < 0) continue\n firstTargetRow = r.startRow\n lastTargetRow = r.startRow\n } else {\n srcRow = r.startRow\n firstTargetRow = r.startRow + 1\n lastTargetRow = r.endRow\n }\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcVal = oldData[srcRow] ? oldData[srcRow][col.key] : ''\n for (let ri = firstTargetRow; ri <= lastTargetRow; ri++) {\n if (!this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: srcRow, endRow: srcRow, startCol: r.startCol, endCol: r.endCol },\n targetRange: { startRow: firstTargetRow, endRow: lastTargetRow, startCol: r.startCol, endCol: r.endCol }\n })\n } else {\n let srcCol, firstTargetCol, lastTargetCol\n if (r.startCol === r.endCol) {\n // Single-column selection: Excel fills from the column directly to the left.\n srcCol = r.startCol - 1\n if (srcCol < 0) continue\n firstTargetCol = r.startCol\n lastTargetCol = r.startCol\n } else {\n srcCol = r.startCol\n firstTargetCol = r.startCol + 1\n lastTargetCol = r.endCol\n }\n const srcColDef = this.leafCols[srcCol]\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n const srcVal = srcColDef && oldData[ri] ? oldData[ri][srcColDef.key] : ''\n for (let ci = firstTargetCol; ci <= lastTargetCol; ci++) {\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: r.startRow, endRow: r.endRow, startCol: srcCol, endCol: srcCol },\n targetRange: { startRow: r.startRow, endRow: r.endRow, startCol: firstTargetCol, endCol: lastTargetCol }\n })\n }\n }\n\n if (!appliedRanges.length) return\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n this._emitDataChange(oldData, newData)\n for (const ar of appliedRanges) {\n this.$emit('fill', { sourceRange: ar.sourceRange, targetRange: ar.targetRange, values: newData })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n\n // See pasteAt's/commitFillDrag's _fireCellCallbacksForRange call — same\n // nextTick deferral reasoning (col.onInput/onSelect after the DOM settles).\n await this.$nextTick()\n for (const ar of appliedRanges) {\n this._fireCellCallbacksForRange(oldData, newData, ar.targetRange.startRow, ar.targetRange.endRow, ar.targetRange.startCol, ar.targetRange.endCol)\n }\n },\n\n fillDown() { return this._fillAxis('down') },\n fillRight() { return this._fillAxis('right') },\n\n // =========================================================\n // COPY / PASTE (3.4)\n // =========================================================\n copySelection() {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n this.clipboardMatrix = extractMatrix(this.data, this.leafCols, r)\n this.clipboardRange = r\n this.clipboardMode = 'copy'\n\n // Write to system clipboard as TSV\n const tsv = matrixToTSV(this.clipboardMatrix)\n if (navigator.clipboard) {\n navigator.clipboard.writeText(tsv).catch(() => {})\n }\n },\n\n cutSelection() {\n this.copySelection()\n this.clipboardMode = 'cut'\n },\n\n // Bulk cell writes (paste, fill handle, drag-move) set row values directly\n // and skip the single-cell paths (onComponentSelect / commitEdit /\n // setCheckboxValue / onDatePick) — so col.onSelect / col.onInput never\n // fire for them unless we replay it here for every cell whose value\n // actually changed in the range. select/treeSelect columns get onSelect\n // (matching the single-pick shape); everything else gets onInput —\n // fired immediately, no debounce (this is already a single bulk commit,\n // not live keystrokes).\n _fireCellCallbacksForRange(oldData, newData, startRow, endRow, startCol, endCol) {\n let fired = false\n for (let ri = startRow; ri <= endRow; ri++) {\n const oldRow = oldData[ri]\n const newRow = newData[ri]\n if (!newRow || newRow === oldRow) continue\n for (let ci = startCol; ci <= endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const oldVal = oldRow ? oldRow[col.key] : undefined\n const newVal = newRow[col.key]\n if (newVal === oldVal) continue\n if (this.isSelectLikeCol(col)) {\n if (typeof col.onSelect === 'function') {\n col.onSelect({ value: newVal, label: this.getSelectLabel(ci, newVal, ri) }, newRow, ri, col)\n fired = true\n }\n } else if (typeof col.onInput === 'function') {\n col.onInput(newVal, newRow, ri, col)\n fired = true\n }\n }\n }\n // See _scheduleOnInput — one forceUpdate covers the whole range instead of\n // one per cell (this can fire many callbacks for a large paste/fill range).\n if (fired) this.$forceUpdate()\n },\n\n async pasteAt(targetRow, targetCol, options = {}) {\n if (this.internalLocked) return\n const { mode = 'all', transpose = false } = options\n\n let matrix = null\n\n // Try system clipboard first\n if (navigator.clipboard) {\n try {\n const text = await navigator.clipboard.readText()\n if (text) matrix = tsvToMatrix(text)\n } catch (_) {}\n }\n\n // Fall back to internal clipboard\n if (!matrix && this.clipboardMatrix) {\n matrix = this.clipboardMatrix\n }\n\n if (!matrix) return\n\n if (transpose) matrix = transposeMatrix(matrix)\n\n // ── Excel-style selection broadcast / tiling ──────────────────────────\n // When pasting over a multi-cell selection and the clipboard matrix is\n // smaller than the selection, tile the matrix to fill the selection —\n // provided the selection dimensions are exact multiples of the matrix\n // dimensions (matches Excel's tiling rule exactly).\n // Cut-paste is never tiled (source must be cleared exactly once).\n const isCopyPaste = !this.clipboardMode || this.clipboardMode === 'copy'\n if (isCopyPaste) {\n const pr = this.primaryRange\n if (pr) {\n const sel = normalizeRange(pr)\n const selRows = sel.endRow - sel.startRow + 1\n const selCols = sel.endCol - sel.startCol + 1\n const matRows = matrix.length\n const matCols = matrix[0] ? matrix[0].length : 0\n\n // Only tile when selection is strictly larger than clipboard\n if ((selRows > matRows || selCols > matCols) &&\n selRows % matRows === 0 && selCols % matCols === 0) {\n matrix = tileMatrix(matrix, selRows, selCols)\n // Paste at selection origin, not just at the active cell\n targetRow = sel.startRow\n targetCol = sel.startCol\n }\n }\n }\n\n const oldData = this.data\n // pasteGrowsRows: pre-extend the working copy with blank rows so\n // applyMatrix's own data.length clamp naturally covers the full\n // pasted range instead of truncating it. `oldData` itself (used below\n // for _emitDataChange's undo/redo diff) stays untouched.\n let pasteBase = oldData\n let addedStartIndex = 0\n let addedCount = 0\n if (this.pasteGrowsRows) {\n const neededLength = targetRow + matrix.length\n if (neededLength > oldData.length) {\n const blankRows = []\n for (let i = oldData.length; i < neededLength; i++) {\n const row = {}\n this.leafCols.forEach(col => { row[col.key] = this._blankValueForCol(col) })\n if (this.pasteRowDefaults) {\n const extra = typeof this.pasteRowDefaults === 'function' ? this.pasteRowDefaults(i) : this.pasteRowDefaults\n if (extra) Object.assign(row, extra)\n }\n blankRows.push(row)\n }\n pasteBase = oldData.concat(blankRows)\n addedStartIndex = oldData.length\n addedCount = blankRows.length\n }\n }\n const { newData, affectedRange, skipped } = applyMatrix(\n pasteBase, this.leafCols, targetRow, targetCol, matrix, mode,\n (ri, ci) => this.isCellEditable(ri, ci),\n (ri, ci, v) => this.isCellValueValid(ri, ci, v),\n (ri, ci, v) => this._coerceCellValue(ci, this._applyColMask(ci, v))\n )\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n if (this.clipboardMode === 'cut' && this.clipboardRange) {\n // Clear source — ensure each source row is a new copy before mutation\n const src = this.clipboardRange\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci) && this.isCellValueValid(ri, ci, '')) {\n newData[ri][col.key] = ''\n }\n }\n }\n this.clipboardRange = null\n this.clipboardMode = null\n }\n\n this._emitDataChange(oldData, newData)\n // Fired once per paste operation (never per row), after 'input' — so by\n // the time hosts receive it, v-model already holds the final rows.\n // Lets hosts do a single aggregate action instead of reacting to\n // pasteRowDefaults' per-row calls.\n if (addedCount > 0) {\n this.$emit('rows-added', {\n startIndex: addedStartIndex,\n count: addedCount,\n rows: newData.slice(addedStartIndex, addedStartIndex + addedCount)\n })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n // Wait for Vue's reactivity queue to flush before firing onSelect — hosts\n // commonly react to the emitted 'input' with their own watcher (e.g. to\n // mirror the row into an external store); firing onSelect synchronously\n // would run it against that store's pre-paste, stale state.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, affectedRange.startRow, affectedRange.endRow, affectedRange.startCol, affectedRange.endCol)\n },\n\n ctxCopy() { this.hideContextMenu(); this.copySelection() },\n ctxCut() { this.hideContextMenu(); this.cutSelection() },\n ctxPaste() { this.hideContextMenu(); this.pasteAt(this.activeCell.row, this.activeCell.col) },\n ctxPasteSpecial(mode, transpose = false) {\n this.hideContextMenu()\n this.pasteAt(this.activeCell.row, this.activeCell.col, { mode, transpose })\n },\n\n // =========================================================\n // COLUMN DRAG-AND-DROP\n // =========================================================\n\n _computeDragOffsets(dropIndex) {\n const { sourceFirstLeaf, sourceLeafCount } = this.colDrag\n if (dropIndex < 0 || (dropIndex >= sourceFirstLeaf && dropIndex <= sourceFirstLeaf + sourceLeafCount)) return {}\n\n const previewCols = moveColumnNodeInTree(this.columns, sourceFirstLeaf, sourceLeafCount, dropIndex)\n const previewLeaves = flattenColumns(previewCols)\n\n const byKey = {}\n this.leafCols.forEach(c => { byKey[c.key] = c })\n\n const offsets = {}\n for (const zone of ['left', 'scroll', 'right']) {\n const inZone = zone === 'left'\n ? ci => this.isColumnPinnedLeft(ci)\n : zone === 'right'\n ? ci => this.isColumnPinnedRight(ci)\n : ci => this.isColumnScrollable(ci)\n\n const origPos = {}\n let acc = 0\n this.leafCols.forEach(col => {\n const ci = col._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) return\n origPos[col.key] = acc\n acc += this.getColW(ci)\n })\n\n const previewPos = {}\n acc = 0\n for (const col of previewLeaves) {\n const origCol = byKey[col.key]\n if (!origCol) continue\n const ci = origCol._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) continue\n previewPos[col.key] = acc\n acc += this.getColW(ci)\n }\n\n for (const key of Object.keys(origPos)) {\n offsets[key] = (previewPos[key] !== undefined ? previewPos[key] : origPos[key]) - origPos[key]\n }\n }\n return offsets\n },\n\n // Called once when the 5px movement threshold flips the drag active:\n // snapshots the original header geometry the whole drag will be measured\n // against, sizes the ghost from the grabbed header, and arms the CSS\n // transitions on every column element.\n _startColDrag() {\n const sb = this.$refs.scrollBody\n this._dragStartScrollLeft = sb ? sb.scrollLeft : 0\n const bounds = []\n if (this.$el) {\n this.$el.querySelectorAll('.eg-header-cell:not(.eg-header-group)').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n if (isNaN(fi)) return\n const rect = el.getBoundingClientRect()\n bounds.push({ fi, left: rect.left, right: rect.right, scrollable: this.isColumnScrollable(fi) })\n })\n }\n this._dragBounds = bounds\n this._dragScrollV = 0\n\n const hr = this._pendingDragHeaderRect\n if (hr && hr.width > 0) {\n this.colDrag.ghostWidth = Math.min(Math.round(hr.width), 320)\n this.colDrag.grabOffsetX = Math.min(\n Math.max(Math.round(this.colDrag.startX - hr.left), 12),\n this.colDrag.ghostWidth - 12\n )\n } else {\n this.colDrag.ghostWidth = 140\n this.colDrag.grabOffsetX = 70\n }\n\n this._setDragTransitions(true)\n },\n\n _setDragTransitions(on) {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = on ? COL_DRAG_TRANSITION : ''\n })\n },\n\n _applyDragTransforms(offsets) {\n if (!this.$el) return\n // Just retarget the transforms — the CSS transition set at drag start\n // animates each element from wherever it currently is. An explicit\n // translateX(0px) (not '') keeps returning columns gliding back instead\n // of snapping.\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n this.$el.querySelectorAll('[data-start]').forEach(el => {\n const start = parseInt(el.dataset.start)\n const end = parseInt(el.dataset.end)\n if (isNaN(start)) return\n let key = null\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n const dx = key ? (offsets[key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n },\n\n // Instant cleanup: transition and transform drop in the same style write,\n // so the (now transition-less) transform change never animates.\n _clearDragTransforms() {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = ''\n el.style.transform = ''\n })\n },\n\n handleColDragMove(e) {\n // Ghost follows the cursor with the original grab point preserved —\n // direct DOM writes, no Vue re-render per mousemove.\n const ghost = this.$refs.colDragGhost\n if (ghost) {\n ghost.style.left = (e.clientX - this.colDrag.grabOffsetX) + 'px'\n ghost.style.top = (e.clientY - 14) + 'px'\n }\n this._lastDragClientX = e.clientX\n this._refreshDragPreview(e.clientX)\n this._updateDragAutoScroll(e.clientX)\n },\n\n _refreshDragPreview(clientX) {\n const prevDrop = this.colDrag.dropIndex\n this._updateColDragDropIndex(clientX)\n if (this.colDrag.dropIndex !== prevDrop) {\n this._dragOffsets = this._computeDragOffsets(this.colDrag.dropIndex)\n this._applyDragTransforms(this._dragOffsets)\n }\n },\n\n _updateColDragDropIndex(clientX) {\n const bounds = this._dragBounds\n if (!bounds || !bounds.length) { this.colDrag.dropIndex = this.leafCols.length; return }\n\n // Boundaries come from the drag-start snapshot, shifted by how far the\n // body has scrolled since (pinned zones don't move with the scroll).\n // Live DOM rects are never read — they carry in-flight preview\n // transforms whose intermediate values would corrupt the math.\n const sb = this.$refs.scrollBody\n const scrollDelta = sb ? sb.scrollLeft - this._dragStartScrollLeft : 0\n const adjusted = bounds\n .map(b => ({\n fi: b.fi,\n left: b.scrollable ? b.left - scrollDelta : b.left,\n right: b.scrollable ? b.right - scrollDelta : b.right\n }))\n .sort((a, b) => a.left - b.left)\n\n let dropIndex = adjusted[adjusted.length - 1].fi + 1\n for (const { fi, left, right } of adjusted) {\n if (clientX < right) {\n dropIndex = clientX < (left + right) / 2 ? fi : fi + 1\n break\n }\n }\n this.colDrag.dropIndex = dropIndex\n },\n\n // Edge auto-scroll: dragging near the left/right edge of the visible\n // scrollable window (the part not covered by sticky pinned zones) scrolls\n // the body horizontally, speed proportional to how deep into the 40px\n // edge zone the cursor sits — so far-away drop targets are reachable.\n _updateDragAutoScroll(clientX) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._dragScrollV = 0; return }\n const rect = sb.getBoundingClientRect()\n const leftEdge = rect.left + this.getFrozenWidth()\n const rightEdge = rect.left + sb.clientWidth - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n const ZONE = 40\n let v = 0\n if (clientX < leftEdge + ZONE) v = -Math.ceil((leftEdge + ZONE - clientX) / 4)\n else if (clientX > rightEdge - ZONE) v = Math.ceil((clientX - (rightEdge - ZONE)) / 4)\n this._dragScrollV = Math.max(-20, Math.min(20, v))\n\n if (this._dragScrollV !== 0 && !this._dragScrollRAF) {\n const step = () => {\n this._dragScrollRAF = null\n if (!this.colDrag.active || this._dragScrollV === 0) return\n const body = this.$refs.scrollBody\n if (body) {\n const before = body.scrollLeft\n body.scrollLeft += this._dragScrollV\n // Re-derive the drop slot at the new scroll position even though\n // the cursor itself hasn't moved.\n if (body.scrollLeft !== before) this._refreshDragPreview(this._lastDragClientX)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopDragAutoScroll() {\n this._dragScrollV = 0\n if (this._dragScrollRAF) {\n cancelAnimationFrame(this._dragScrollRAF)\n this._dragScrollRAF = null\n }\n },\n\n commitColDrag() {\n const from = this.colDrag.sourceFirstLeaf\n const leafCount = this.colDrag.sourceLeafCount\n const to = this.colDrag.dropIndex\n\n if (from < 0 || to < 0 || leafCount < 1) { this._resetColDrag(true); return }\n if (to >= from && to <= from + leafCount) { this._resetColDrag(true); return }\n\n // KEEP the preview transforms in the DOM while Vue re-renders with the\n // new column order. After Vue updates left positions ($nextTick), we\n // instantly clear them — both steps run as microtasks before the browser\n // paints, so there is never a visible intermediate state.\n this._stopDragAutoScroll()\n clearTimeout(this._dragCleanupTimer)\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n\n const newCols = moveColumnNodeInTree(this.columns, from, leafCount, to)\n this.$emit('update:columns', newCols)\n this.$emit('column-reorder', { fromIndex: from, toIndex: to, columns: newCols })\n\n // Clear transforms after Vue re-renders new column left positions\n this.$nextTick(() => { this._clearDragTransforms() })\n },\n\n // animateBack: cancelled drags (Escape, drop on the source slot) glide the\n // columns back to their original places instead of snapping.\n _resetColDrag(animateBack = false) {\n const wasActive = this.colDrag.active\n this._stopDragAutoScroll()\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n clearTimeout(this._dragCleanupTimer)\n if (animateBack && wasActive && this.$el) {\n this._applyDragTransforms({}) // everything retargets to translateX(0) with transitions still on\n this._dragCleanupTimer = setTimeout(() => this._clearDragTransforms(), 230)\n } else {\n this._clearDragTransforms()\n }\n },\n\n // =========================================================\n // RESIZE (3.5)\n // =========================================================\n startColResize(e, ci) {\n // Under fitColumns, resizing a column turns IT into a fixed px\n // override (see commitResize/_applyFitColumns) — the rest keep\n // scaling proportionally to fill what's left, so this can reintroduce\n // horizontal scroll. That's an accepted tradeoff for letting a column\n // be pinned to an exact width while fitColumns is on.\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'col',\n index: ci,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getColW(ci),\n currentPos: e.clientX,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n // Manual resize turns a relative-unit width ('20%', ...) into a fixed px\n // override — it should no longer rescale on window resize.\n this.$delete(this.relativeColSpecs, ci)\n e.preventDefault()\n },\n\n startRowResize(e, ri) {\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'row',\n index: ri,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getRowH(ri),\n currentPos: e.clientY,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n e.preventDefault()\n },\n\n handleResizeMove(e) {\n if (this.resizing.type === 'col') {\n this.resizing.currentPos = e.clientX\n const newWidth = Math.max(20, this.resizing.startSize + (e.clientX - this.resizing.startX))\n this.$set(this.colWidths, this.resizing.index, newWidth)\n this._cellStyleCache = Object.create(null)\n } else {\n this.resizing.currentPos = e.clientY\n this._resizePreviewHeight = Math.max(10, this.resizing.startSize + (e.clientY - this.resizing.startY))\n // Batch $set + updateVirtualScroll to rAF — prevents O(n) cumulativeHeights rebuild\n // on every mousemove (200+ Hz). At most 60 rebuilds/sec instead of 200+.\n if (!this._resizeRAF) {\n this._resizeRAF = requestAnimationFrame(() => {\n this._resizeRAF = null\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n this.updateVirtualScroll()\n })\n }\n }\n },\n\n commitResize() {\n if (this._resizeRAF) {\n cancelAnimationFrame(this._resizeRAF)\n this._resizeRAF = null\n // Apply the last pending preview height immediately on commit\n if (this.resizing.type === 'row' && this._resizePreviewHeight !== undefined) {\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n }\n }\n if (this.resizing.type === 'col') {\n this._cellStyleCache = Object.create(null)\n const newSize = this.colWidths[this.resizing.index]\n if (this.fitColumns) {\n const col = this.leafCols[this.resizing.index]\n if (col) this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: this.resizing.index, newSize })\n } else {\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n const newSize = this.rowHeights[this.resizing.index]\n this.$emit('row-resize', { index: this.resizing.index, newSize })\n this.updateVirtualScroll()\n }\n this.resizing.active = false\n },\n\n // Double-click-the-header \"AutoFit column width\" — same idea as Excel/Sheets'\n // double-click-the-border shortcut. Under fitColumns, same override\n // treatment as a manual drag-resize (see commitResize/_applyFitColumns):\n // this column becomes a fixed px width excluded from the proportional\n // fit, the rest keep scaling in what's left.\n autoFitColumn(ci) {\n const col = this.leafCols[ci]\n if (!col) return\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n const newWidth = Math.max(50, maxLen * 8 + 16)\n this.$set(this.colWidths, ci, newWidth)\n this.$delete(this.relativeColSpecs, ci)\n this._cellStyleCache = Object.create(null)\n if (this.fitColumns) {\n this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: ci, newSize: newWidth })\n },\n\n _applyAutoWidth() {\n // fitColumns is the higher-priority width control — see its own JSDoc.\n if (!this.autoWidth || this.fitColumns || !this.leafCols.length) return\n this.leafCols.forEach((col, ci) => {\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n this.$set(this.colWidths, ci, Math.max(50, maxLen * 8 + 16))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n // fitColumns prop — ignores col.width as an absolute value, using it only\n // as a relative WEIGHT so every visible leaf column (pinned or not)\n // scales by one shared factor to exactly fill _relativeWidthContainer\n // (the same circular-dependency-safe reference %/CSS-length widths\n // already resolve against — see its declaration comment). Mirrors\n // naturalGridWidth's formula in reverse: that computed derives the\n // grid's natural width FROM the column widths; this derives the column\n // widths FROM the available width.\n //\n // Columns in fitColumnsOverrides (manually resized while fitColumns was\n // on — see commitResize) are excluded from the scaling pool and keep\n // their current colWidths px value untouched; the remaining columns\n // scale to fill whatever space is left after subtracting the overrides'\n // total width, which can go negative (horizontal scroll returns) if the\n // overrides alone exceed the available width.\n _applyFitColumns() {\n if (!this.fitColumns || !this.leafCols.length) return\n const containerWidth = this._relativeWidthContainer\n if (!containerWidth) return // not measured yet — re-run once hostContainerWidth arrives\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n const available = containerWidth - ROW_HEADER_WIDTH - outlineW - this.vScrollbarW - this.shellBorderW\n if (available <= 0) return\n\n const visible = []\n let totalWeight = 0\n let overriddenWidth = 0\n this.leafCols.forEach((col, ci) => {\n if (this.isColHidden(ci)) return\n if (this.fitColumnsOverrides[col.key]) {\n const w = this.colWidths[ci] !== undefined ? this.colWidths[ci] : this.resolveColWidth(col.width, containerWidth)\n overriddenWidth += w\n return\n }\n const w = this.resolveColWidth(col.width, containerWidth)\n visible.push({ ci, weight: w })\n totalWeight += w\n })\n if (!visible.length || totalWeight <= 0) return\n\n const remaining = available - overriddenWidth\n const scale = remaining > 0 ? remaining / totalWeight : 0\n visible.forEach(({ ci, weight }) => {\n this.$set(this.colWidths, ci, Math.max(FIT_COLUMNS_MIN_WIDTH, weight * scale))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n ctxHideRow() {\n const targetRow = this.contextMenu.row\n // Row header: row >= 0 AND col = -1 (no column context)\n const fromRowHeader = targetRow >= 0 && this.contextMenu.col < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenRows]\n if (fromRowHeader) {\n if (!newHidden.includes(targetRow)) newHidden.push(targetRow)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n if (!newHidden.includes(ri)) newHidden.push(ri)\n }\n }\n this.$emit('update:hiddenRows', newHidden)\n },\n\n ctxHideColumn() {\n const targetCol = this.contextMenu.col\n // Column header: col >= 0 AND row = -1 (no row context)\n const fromColHeader = targetCol >= 0 && this.contextMenu.row < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenColumns]\n if (fromColHeader) {\n if (!newHidden.includes(targetCol)) newHidden.push(targetCol)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n if (!newHidden.includes(ci)) newHidden.push(ci)\n }\n }\n this.$emit('update:hiddenColumns', newHidden)\n },\n\n ctxUnhideRows() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenRows', [])\n },\n\n ctxUnhideColumns() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenColumns', [])\n },\n\n ctxPinLeft() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'left')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxPinRight() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'right')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxUnpin() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$delete(this.internalPinnedColumns, col.key)\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n isColPinned(ci) {\n const col = this.leafCols[ci]\n return !!(col && this.internalPinnedColumns[col.key])\n },\n\n // =========================================================\n // DEV MODE — column/row config info (context menu → modal)\n // =========================================================\n _stringifyForInfo(value) {\n const seen = new WeakSet()\n return JSON.stringify(value, (key, val) => {\n if (typeof val === 'function') return 'ƒ ' + (val.name || 'anonymous') + '()'\n if (val && typeof val === 'object') {\n if (seen.has(val)) return '[Circular]'\n seen.add(val)\n }\n return val\n }, 2)\n },\n\n ctxShowColumnInfo() {\n const ci = this.contextMenu.col\n this.hideContextMenu()\n const col = this.leafCols[ci]\n if (!col) return\n const { _flatIndex, ...rest } = col\n this.infoModal = { title: `${this.t.columnInfo}: ${col.title || col.key}`, json: this._stringifyForInfo(rest) }\n },\n\n ctxShowRowInfo() {\n const ri = this.contextMenu.row\n this.hideContextMenu()\n const row = this._dataRaw[ri]\n if (!row) return\n this.infoModal = { title: `${this.t.rowInfo} #${ri + 1}`, json: this._stringifyForInfo(row) }\n },\n\n closeInfoModal() {\n this.infoModal = null\n },\n\n // =========================================================\n // MERGE CELLS (3.8)\n // =========================================================\n mergeCells(range) {\n if (this.internalLocked) return false\n const oldMerges = this.internalMerges.slice()\n const { merges: newMerges, conflict } = addMerge(range, this.internalMerges)\n if (conflict) {\n this.$emit('merge-error', { range, reason: 'Overlaps existing merge' })\n return false\n }\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n return true\n },\n\n unmergeCells(row, col) {\n if (this.internalLocked) return\n const oldMerges = this.internalMerges.slice()\n const newMerges = removeMergeAt(row, col, this.internalMerges)\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n },\n\n ctxMerge() {\n this.hideContextMenu()\n const pr = this.primaryRange\n if (!pr) return\n this.mergeCells(normalizeRange(pr))\n },\n\n ctxUnmerge() {\n this.hideContextMenu()\n this.unmergeCells(this.activeCell.row, this.activeCell.col)\n },\n\n // =========================================================\n // GROUPING (3.7)\n // =========================================================\n groupRows(startIndex, endIndex) {\n this.internalGroups = addGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n groupColumns(startIndex, endIndex) {\n this.internalGroups = addGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupRows(startIndex, endIndex) {\n this.internalGroups = removeGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupColumns(startIndex, endIndex) {\n this.internalGroups = removeGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n toggleGroup(group) {\n this.internalCollapsedGroups = toggleGroupCollapse(group, this.internalCollapsedGroups)\n this.$emit('update:collapsedGroups', [...this.internalCollapsedGroups])\n this.$emit('group-toggle', { group, collapsed: _isGroupCollapsed(group, this.internalCollapsedGroups) })\n this.updateVirtualScroll()\n },\n\n isGroupCollapsed(group) {\n return _isGroupCollapsed(group, this.internalCollapsedGroups)\n },\n\n expandAll() {\n this.internalCollapsedGroups = []\n this.$emit('update:collapsedGroups', [])\n this.updateVirtualScroll()\n },\n\n collapseAll() {\n this.internalCollapsedGroups = [...this.internalGroups]\n this.$emit('update:collapsedGroups', [...this.internalGroups])\n this.updateVirtualScroll()\n },\n\n // =========================================================\n // DRAG AND DROP (3.9)\n // =========================================================\n startDragMove(e, ri, ci) {\n const pr = this.primaryRange\n if (!pr) return\n this.dragMove = {\n active: true,\n sourceRange: normalizeRange(pr),\n startX: e.clientX,\n startY: e.clientY,\n targetRow: ri,\n targetCol: ci,\n copyMode: e.ctrlKey || e.metaKey\n }\n this.$emit('cell-drag-start', { sourceRange: this.dragMove.sourceRange })\n e.preventDefault()\n },\n\n handleDragMoveMove(e) {\n this.dragMove.copyMode = e.ctrlKey || e.metaKey\n this.$emit('cell-drag-move', {\n sourceRange: this.dragMove.sourceRange,\n targetRow: this.dragMove.targetRow,\n targetCol: this.dragMove.targetCol\n })\n },\n\n async commitDragMove(e) {\n if (!this.dragMove.active) return\n const { sourceRange, targetRow, targetCol, copyMode } = this.dragMove\n\n const targetRange = {\n startRow: targetRow,\n startCol: targetCol,\n endRow: targetRow + (sourceRange.endRow - sourceRange.startRow),\n endCol: targetCol + (sourceRange.endCol - sourceRange.startCol)\n }\n\n let proceed = true\n if (this.beforeDrop) {\n proceed = await Promise.resolve(this.beforeDrop(sourceRange, targetRange))\n }\n\n let oldData = null\n let newData = null\n if (proceed) {\n oldData = this.data\n const matrix = extractMatrix(oldData, this.leafCols, sourceRange)\n newData = applyMatrix(oldData, this.leafCols, targetRow, targetCol, matrix, 'all', (ri, ci) => this.isCellEditable(ri, ci)).newData\n\n if (!copyMode) {\n // Clear source — ensure each source row is a new copy before mutation\n for (let ri = sourceRange.startRow; ri <= sourceRange.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = sourceRange.startCol; ci <= sourceRange.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) newData[ri][col.key] = ''\n }\n }\n }\n this._emitDataChange(oldData, newData)\n this.$emit('cell-drop', { sourceRange, targetRange })\n }\n\n this.dragMove = { active: false, sourceRange: null, startX: 0, startY: 0, targetRow: -1, targetCol: -1, copyMode: false }\n this.isMouseDown = false\n\n if (proceed) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n // =========================================================\n // EXCEL EXPORT (3.11)\n // =========================================================\n async exportToExcel(options = {}) {\n const {\n filename = 'export.xlsx',\n sheetName = 'Sheet1',\n scope = 'all',\n includeHidden = false\n } = options\n\n this.$emit('export-start')\n this.exportState = { active: true }\n\n const workerPayload = {\n data: this.data,\n columns: this.columns,\n // width resolved to a concrete px number (getColW handles px/%/other CSS\n // units) — excelExport.js's char-width math needs a real number, and a\n // worker has no DOM to resolve a raw '20%'/'10em' spec itself.\n leafCols: this.leafCols.map((col, ci) => ({ ...col, width: this.getColW(ci) })),\n merges: this.internalMerges,\n hiddenCols: [...this.effectiveHiddenColSet],\n hiddenRows: [...this.hiddenRowSet],\n range: scope === 'selection' ? this.primaryRange : null,\n filename,\n sheetName,\n scope,\n includeHidden\n }\n\n try {\n const worker = createExcelExportWorker()\n if (worker) {\n await this._exportViaWorker(worker, workerPayload)\n } else {\n // Fallback: synchronous export on main thread\n _exportToExcel({\n ...workerPayload,\n hiddenColSet: this.effectiveHiddenColSet,\n hiddenRowSet: this.hiddenRowSet\n })\n }\n this.$emit('export-success')\n } catch (err) {\n this.$emit('export-error', err)\n } finally {\n this.exportState = { active: false }\n }\n },\n\n /**\n * Print all (visible) grid data as a plain, theme-independent HTML\n * table — not the styled data-grid itself. Honors hidden columns/rows,\n * merged cells, and footer totals, same sources `exportToExcel()` uses.\n * `type: 'action'` columns (buttons — add/remove row, etc.) are always\n * excluded: they're interactive UI, not printable data.\n */\n printGrid() {\n const rowIndices = []\n for (let ri = 0; ri < this.totalRows; ri++) {\n if (!this.hiddenRowSet.has(ri)) rowIndices.push(ri)\n }\n const printExcludedColSet = new Set(this.effectiveHiddenColSet)\n this.leafCols.forEach(col => { if (col.type === 'action') printExcludedColSet.add(col._flatIndex) })\n const html = buildPrintTableHtml({\n columns: this.columns,\n leafCols: this.leafCols,\n rowIndices,\n merges: this.internalMerges,\n hiddenColSet: printExcludedColSet,\n // Resolved px width per column (same getColW() exportToExcel() uses)\n // so print columns stay proportional to their on-screen width.\n colWidths: this.leafCols.map((col, ci) => this.getColW(ci)),\n getCellText: (ri, ci) => {\n const col = this.leafCols[ci]\n if (col && col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? '✓' : ''\n return String(this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri))\n },\n showFooter: this.hasFooter,\n getFooterText: ci => this.getColumnTotalDisplay(ci),\n printTitle: this.printTitle\n })\n this._openPrintFrame(html)\n },\n\n // Hidden same-origin <iframe> instead of window.open(): can't be\n // popup-blocked, and works the same when the host app itself runs\n // inside an iframe. srcdoc's document is fully self-contained (its own\n // <style>, nothing inherited from the host page), so the printed table\n // never picks up the grid's current theme.\n _openPrintFrame(html) {\n const iframe = document.createElement('iframe')\n iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden;'\n document.body.appendChild(iframe)\n const cleanup = () => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe) }\n iframe.onload = () => {\n const win = iframe.contentWindow\n win.addEventListener('afterprint', cleanup)\n win.focus()\n win.print()\n // Fallback in case `afterprint` never fires (observed on some\n // browsers when the print dialog is dismissed a certain way).\n setTimeout(cleanup, 2000)\n }\n iframe.srcdoc = html\n },\n\n _exportViaWorker(worker, payload) {\n return new Promise((resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, buffer, filename, message } = e.data\n if (type === 'result') {\n // Trigger browser download from the main thread (DOM required)\n const blob = new Blob([new Uint8Array(buffer)], {\n type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n URL.revokeObjectURL(url)\n finish(resolve)\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Export worker xatosi'))\n\n worker.postMessage(payload)\n })\n },\n\n // =========================================================\n // EXCEL IMPORT (3.12)\n // =========================================================\n async importFromFile(file, options = {}) {\n this.importState = { active: true, percent: 5, message: \"Fayl o'qilmoqda...\" }\n\n try {\n let result\n const worker = createExcelImportWorker()\n\n if (worker) {\n result = await this._importViaWorker(worker, file, options)\n } else {\n // Fallback: chunked async on main thread\n result = await _importFromFile(\n file,\n this.columns,\n options,\n (percent, message) => { this.importState = { active: true, percent, message } }\n )\n }\n\n const { columns, merges } = result\n // Freeze each row so Vue 2 skips deep Object.defineProperty observation.\n // Vue 2 checks Object.isExtensible() — frozen objects are skipped entirely.\n // All edit paths already use spread ({ ...row }) so frozen rows are safe.\n const data = result.data.map(row => Object.freeze(row))\n this.$emit('input', data)\n this._scheduleBufferWrite(data)\n this.$emit('update:columns', columns)\n\n const hasMerges = merges && merges.length > 0\n this.internalMerges = hasMerges ? merges : []\n this.$emit('update:merges', hasMerges ? merges : [])\n this.$emit('update:collapsedGroups', [])\n this.$emit('update:hiddenRows', [])\n this.$emit('import-success', { data, columns, merges })\n\n // Reset scroll state and re-derive virtual scroll after DOM settles.\n // Without this, stale scrollTop + old rowHeights cause blank bands on scroll.\n this.rowHeights = {}\n this.scrollTop = 0\n this.scrollLeft = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) {\n this.$refs.scrollBody.scrollTop = 0\n this.$refs.scrollBody.scrollLeft = 0\n }\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = 0\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = 0\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = 0\n this.updateVirtualScroll()\n })\n } catch (err) {\n this.$emit('import-error', err)\n } finally {\n this.importState = { active: false, percent: 0, message: '' }\n }\n },\n\n _importViaWorker(worker, file, options) {\n return new Promise(async (resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, percent, message, data, columns, merges } = e.data\n if (type === 'progress') {\n this.importState = { active: true, percent, message }\n } else if (type === 'result') {\n finish(resolve, { data, columns, merges })\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Import worker xatosi'))\n\n try {\n // Transfer the ArrayBuffer zero-copy into the worker\n const buffer = await file.arrayBuffer()\n worker.postMessage(\n { buffer, existingColumns: this.columns, options },\n [buffer]\n )\n } catch (err) {\n finish(reject, err)\n }\n })\n },\n\n // =========================================================\n // UNDO / REDO (3.13)\n // =========================================================\n\n // Push one entry onto the non-reactive history stack.\n // Truncates the redo tail, caps at 100 entries.\n _pushHistory(entry) {\n if (this._historyIndex < this._history.length - 1) {\n this._history.splice(this._historyIndex + 1)\n }\n this._history.push(entry)\n if (this._history.length > 100) {\n this._history.shift()\n // historyIndex stays the same — it now points to the new last entry\n } else {\n this._historyIndex++\n }\n this.historyVersion++\n },\n\n // Emit 'input' and record a data diff entry (only changed row indices).\n // Use slice() + per-row spread in callers so unchanged rows keep the\n // same reference — then the identity check here catches only real changes.\n _emitDataChange(oldData, newData) {\n if (!this._historySkip) {\n const indices = []\n const beforeRows = []\n const afterRows = []\n const len = Math.max(oldData.length, newData.length)\n for (let i = 0; i < len; i++) {\n if (oldData[i] !== newData[i]) {\n indices.push(i)\n beforeRows.push(oldData[i])\n afterRows.push(newData[i])\n }\n }\n if (indices.length > 0) {\n // rowCount lets undo() truncate the array back down when this change\n // added rows (e.g. pasteGrowsRows) — a no-op for every change that\n // doesn't touch row count, since rowCount then matches on both sides.\n this._pushHistory({\n type: 'data',\n before: { indices, rows: beforeRows, rowCount: oldData.length },\n after: { indices, rows: afterRows, rowCount: newData.length }\n })\n }\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n },\n\n // Emit 'update:merges' and record a merges diff entry.\n _emitMergeChange(oldMerges, newMerges) {\n if (!this._historySkip) {\n this._pushHistory({\n type: 'merges',\n before: oldMerges,\n after: newMerges\n })\n }\n this.$emit('update:merges', newMerges)\n },\n\n undo() {\n if (this.internalLocked) return\n if (this._historyIndex < 0) return\n const entry = this._history[this._historyIndex--]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.before.indices.length; i++) {\n newData[entry.before.indices[i]] = entry.before.rows[i]\n }\n // Assigning `undefined` to a trailing index doesn't shrink the\n // array — truncate explicitly when this entry added rows (e.g.\n // pasteGrowsRows), so undo actually removes them instead of\n // leaving `undefined` rows at the tail.\n if (entry.before.rowCount != null && entry.before.rowCount < newData.length) {\n newData.length = entry.before.rowCount\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.before.slice()\n this.$emit('update:merges', entry.before.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n redo() {\n if (this.internalLocked) return\n if (this._historyIndex >= this._history.length - 1) return\n const entry = this._history[++this._historyIndex]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.after.indices.length; i++) {\n newData[entry.after.indices[i]] = entry.after.rows[i]\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.after.slice()\n this.$emit('update:merges', entry.after.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n // =========================================================\n // TOOLBAR (fullscreen / lock / export / import / custom buttons)\n // =========================================================\n\n // Public, ref-callable: returns keyboard focus to the grid. Meant for\n // hosts whose action-cell `handler` opens a modal/popup outside the\n // grid's control — call this once that modal closes, otherwise the\n // browser leaves focus on document.body (its subtree was removed) and\n // arrow-key navigation goes dead until a cell is clicked again.\n focus() {\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Public, ref-callable: formats a number with a custom thousands\n // separator (decimal point stays '.', no forced/padded decimals). Any\n // value that isn't a finite number — already-formatted text like\n // \"6 000 000,00\", arbitrary strings, etc. — is returned unchanged, so\n // hosts can call this on raw cell values without checking the type first.\n formatNumber(value, separator) {\n return _formatNumber(value, separator)\n },\n\n // =========================================================\n // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by\n // localSyncKey (optionally namespaced by tenant), only when the page is\n // being left (not per-edit).\n // =========================================================\n\n // Shared by the localSyncKey and tenant watchers above — either one\n // changing means effectiveSyncKey now points at a (possibly) different\n // version history, so the badge/version-list state must be reloaded\n // from scratch rather than left stale.\n _reloadLocalSyncForKeyChange() {\n this.localSyncAvailable = false\n if (this.effectiveSyncKey) this._loadLocalVersionsOnMount()\n },\n\n // Internal, called once from mounted(): populates the toolbar \"unseen\n // local versions\" dot and emits local-sync-available if any versions\n // already exist for this key (e.g. saved in an earlier visit).\n async _loadLocalVersionsOnMount() {\n if (!this.localSyncKey) return\n await this._promoteBufferIfAny()\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n if (!versions.length) return\n this.localSyncAvailable = true\n this.$emit('local-sync-available', {\n key: this.localSyncKey,\n versions: versions.length,\n mostRecentUpdatedAt: versions[0].updatedAt\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n }\n },\n\n // Debounced (800ms), called from the grid's own real-change points\n // (_emitDataChange, import, undo, redo — the exact same \"user worked\"\n // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,\n // for the same reason spelled out on the localSyncKey prop's JSDoc: a\n // host loading rows from an API must never look like a user edit).\n //\n // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no\n // hash, no history) at a moment with no teardown race at all — the user\n // is actively still on the page. This is what actually fixes reload/\n // close data loss: a version-store write ATTEMPTED at the leave-signal\n // has no guarantee of finishing before an actual page teardown (verified\n // directly — even a trivial, unrelated IndexedDB write failed to survive\n // a real page.reload() in Chromium), but a buffer write that already\n // completed minutes or seconds earlier, during ordinary editing, needs no\n // such guarantee — it's already durably there. _promoteBufferIfAny()\n // turns it into a real version next time the grid mounts.\n _scheduleBufferWrite(data) {\n if (!this.localSyncKey) return\n const key = this.effectiveSyncKey // captured now, not re-read 800ms later —\n // a host that swaps localSyncKey/tenant mid-edit (rare, but now\n // reactive, see the watchers below) must not have this land under the\n // NEW key with the OLD key's data.\n clearTimeout(this._bufferWriteTimer)\n this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)\n },\n\n async _writeBufferNow(key, data) {\n if (!key) return\n try {\n await putBuffer(key, data)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Called once from _loadLocalVersionsOnMount(), before this key's version\n // list is read — folds a leftover buffer (from an edit that was saved\n // during the PREVIOUS visit but never made it into 'versions' — the\n // reload/close case this whole mechanism exists for) into a real version.\n // No time pressure here, so this uses the Worker-backed hashRowsAsync()\n // like any other non-leave-signal path.\n async _promoteBufferIfAny() {\n try {\n const buffered = await getBuffer(this.effectiveSyncKey)\n if (!buffered) return\n const hash = await hashRowsAsync(buffered.data)\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, buffered.data, hash, this.localSyncMaxVersions\n )\n await clearBuffer(this.effectiveSyncKey)\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: buffered.data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the\n // passive leave-triggers can't afford a Worker round-trip.\n _onVisibilityChangeForLocalSync() {\n if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)\n },\n\n _onPageHideForLocalSync() {\n this._flushLocalVersionOnHide(false, true)\n },\n\n // The core save-on-leave path — replaces a per-edit autosave (see the\n // localSyncKey prop's JSDoc for why: an autosave tied to \"data changed\"\n // can't distinguish a user's edit from a host loading rows from an API,\n // and would silently overwrite a user's saved work the moment a host\n // does the latter). Triggered from mounted()'s visibilitychange/pagehide\n // listeners and beforeDestroy() — see those for the three trigger points.\n //\n // Content-hashes the current `data` and either touches an existing local\n // version with the same hash (just refreshes its timestamp — \"ustiga\n // yoziladi, alohida versiya qilinmaydi\") or creates a brand-new one,\n // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's\n // saveOrTouchVersion()). Never throws — IndexedDB failures become\n // local-sync-error.\n //\n // `force` skips the \"nothing changed since the last flush\" short-circuit\n // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)\n // fire often on an idle tab and must NOT wake IndexedDB for no reason, so\n // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,\n // host/modal-initiated \"checkpoint now\" action — passes force so it never\n // silently does nothing: even unchanged data still gets its existing\n // version's timestamp refreshed via the same hash-dedup path, rather than\n // the caller seeing no effect at all.\n //\n // `sync` picks hashRows() (main thread) over the Worker-based\n // hashRowsAsync(). This was a real, observed bug, not a theoretical one:\n // a Worker round-trip (postMessage out, worker computes, postMessage\n // back) is one more hop through the event loop than a page that's\n // actually being torn down (as opposed to merely hidden — e.g. a tab\n // switch, where the page stays alive indefinitely and the extra hop\n // costs nothing) can be relied on to survive. visibilitychange/pagehide/\n // beforeDestroy all fire in exactly that \"might be torn down any moment\"\n // window, so they hash synchronously to remove that hop — the whole\n // flush then only has the unavoidable async part (the IndexedDB write\n // itself) left, giving it the best realistic chance to land before\n // teardown. saveLocalVersionNow() (an explicit host/modal action with no\n // teardown racing it) keeps the Worker, so hashing a large `data` array\n // doesn't block the main thread during ordinary use.\n async _flushLocalVersionOnHide(force = false, sync = false) {\n if (!this.localSyncKey) return\n if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush\n const data = this.data\n // Fires independently of the version-save attempt below, started as\n // early as possible — see _scheduleBufferWrite()'s comment. This is\n // the save that's actually likely to survive an imminent teardown;\n // the version-store write below is best-effort on top of it, reliable\n // for the common case (tab switch/background, page stays alive).\n clearTimeout(this._bufferWriteTimer)\n this._writeBufferNow(this.effectiveSyncKey, data)\n try {\n const hash = sync ? hashRows(data) : await hashRowsAsync(data)\n if (this.data !== data) return // data changed again mid-hash — a later flush will catch it\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, data, hash, this.localSyncMaxVersions\n )\n this._lastFlushedDataRef = data\n // The version store now has this content durably — the buffer's job\n // for it is done. Best-effort like everything else here: if this\n // doesn't complete before teardown, _promoteBufferIfAny() finds the\n // (now-redundant but harmless, dedup makes it a no-op touch) buffer\n // on next mount anyway.\n clearBuffer(this.effectiveSyncKey).catch(() => {})\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Public, ref-callable: runs the save-on-leave flow immediately, for a\n // host that wants its own explicit \"save checkpoint\" button instead of\n // waiting for a page-hide. Always goes through IndexedDB (force — see\n // _flushLocalVersionOnHide()), but still through the same hash-dedup\n // logic, so repeated calls with unchanged data don't spam duplicate\n // versions — they just refresh the existing matching version.\n async saveLocalVersionNow() {\n await this._flushLocalVersionOnHide(true)\n },\n\n // Public, ref-callable: lightweight metadata for every saved version of\n // this localSyncKey, newest first — no row data (use getLocalVersion(id)\n // for that). Powers the built-in modal's list; also usable to build a\n // custom UI.\n async listLocalVersions() {\n if (!this.localSyncKey) return []\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return []\n }\n },\n\n // Public, ref-callable: the full { id, data, createdAt, updatedAt } for\n // one saved version, WITHOUT applying it to v-model — the pure \"Read\"\n // for a custom UI (as opposed to restoreLocalVersion(), which reads AND\n // commits in one step).\n async getLocalVersion(id) {\n try {\n return await getVersion(id)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return null\n }\n },\n\n // Public, ref-callable: writes one saved version back into v-model\n // (input/update:data) — the modal's \"Restore\" button calls this too.\n async restoreLocalVersion(id) {\n try {\n const version = await this.getLocalVersion(id)\n if (!version) return\n this.$emit('input', version.data)\n this.$emit('update:data', version.data)\n this._lastFlushedDataRef = version.data // what's now on screen is already what's stored\n this.localSyncAvailable = false\n this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })\n this.closeLocalSyncModal()\n } catch (error) {\n this.$emit('local-sync-error', { action: 'restore', error })\n }\n },\n\n // Public, ref-callable: deletes one saved version — the modal's \"Delete\"\n // button calls this too (after its own inline confirm step).\n async deleteLocalVersion(id) {\n try {\n await deleteVersion(id)\n if (this.localSyncModal.selectedId === id) {\n this.localSyncModal.selectedId = null\n this.localSyncModal.selectedVersion = null\n }\n this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)\n this.$emit('local-sync-clear', { key: this.localSyncKey, id })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'clear', error })\n }\n },\n\n // Public, ref-callable: opens the local-sync modal (also reachable via\n // the toolbar button), loading the version list fresh each time.\n async openLocalSyncModal() {\n if (!this.localSyncKey) return\n this.localSyncModal.visible = true\n this.localSyncAvailable = false // the version(s) have now been seen\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Internal: modal's list-selection handler — loads the selected\n // version's full data for the nested-grid preview (EgLocalSyncModal.vue).\n async _selectLocalSyncModalVersion(id) {\n this.localSyncModal.selectedId = id\n this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null\n },\n\n // Modal's \"Save\" button — same as saveLocalVersionNow(), then refreshes\n // the modal's own list/selection in place so the user sees it happened.\n async _onLocalSyncModalSave() {\n await this.saveLocalVersionNow()\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Modal's \"Delete\" button (after its own inline confirm) — deletes the\n // given version, then selects the next-most-recent one if the deleted\n // one was selected.\n async _onLocalSyncModalDelete(id) {\n await this.deleteLocalVersion(id)\n if (this.localSyncModal.selectedId == null) {\n const remaining = this.localSyncModal.versions\n await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)\n }\n },\n\n // Public, ref-callable.\n closeLocalSyncModal() {\n this.localSyncModal.visible = false\n },\n\n toggleLock() {\n this.internalLocked = !this.internalLocked\n this.$emit('update:locked', this.internalLocked)\n this.$emit('lock-change', { locked: this.internalLocked })\n this._flashLock(this.internalLocked)\n },\n\n // Pops a big lock/unlock icon in the center of the grid and fades it\n // out — pure visual feedback, mirrors the video-player OSD pattern.\n // Triggered by the internalLocked watcher, so it covers every path that\n // can change the lock state (toolbar click, toggleLock(), :locked.sync).\n _flashLock(locked) {\n this.lockFlashLocked = locked\n this.lockFlashVisible = true\n this.$nextTick(() => {\n const el = this.$refs.lockFlashIconEl\n if (!el) return\n anime.remove(el)\n anime.set(el, { opacity: 0, scale: 0.5 })\n anime.timeline()\n .add({ targets: el, opacity: 1, scale: 1.12, duration: 180, easing: 'easeOutBack' })\n .add({ targets: el, scale: 1, duration: 120, easing: 'easeOutQuad' })\n .add({ targets: el, opacity: 0, duration: 350, easing: 'easeInQuad' }, '+=250')\n .finished.then(() => { this.lockFlashVisible = false })\n })\n },\n\n toolbarHas(key) {\n return this._toolbarList.indexOf(key) !== -1\n },\n\n toolbarDisabled(key) {\n return this._toolbarDisabledList.indexOf(key) !== -1\n },\n\n setSize(newSize) {\n if (!SIZE_CONFIG[newSize] || newSize === this.internalSize) return\n this.internalSize = newSize\n this.$emit('update:size', newSize)\n this.$emit('size-change', { size: newSize })\n },\n\n setTheme(newTheme) {\n if (THEME_LIST.indexOf(newTheme) === -1 || newTheme === this.internalTheme) return\n this.internalTheme = newTheme\n this.$emit('update:theme', newTheme)\n this.$emit('theme-change', { theme: newTheme })\n },\n\n sizeTitle(key) {\n return { small: this.t.sizeSmall, middle: this.t.sizeMiddle, large: this.t.sizeLarge }[key] || key\n },\n\n toggleFullscreen() {\n this.isFullscreen = !this.isFullscreen\n if (this.isFullscreen) {\n this._prevBodyOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n document.addEventListener('keydown', this._onFullscreenKeyDown)\n } else {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n this.$emit('fullscreen-change', { fullscreen: this.isFullscreen })\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n _onFullscreenKeyDown(e) {\n if (e.key === 'Escape' && this.isFullscreen) this.toggleFullscreen()\n },\n\n toolbarExportClick() {\n this.exportToExcel()\n },\n\n toolbarImportClick() {\n if (this.internalLocked) return\n const input = this.$refs.toolbarImportInput\n if (input) input.click()\n },\n\n onToolbarImportFile(e) {\n const file = e.target.files && e.target.files[0]\n e.target.value = ''\n if (!file || this.internalLocked) return\n this.importFromFile(file)\n },\n\n onToolbarButtonClick(btn, event) {\n if (typeof btn.onClick === 'function') btn.onClick(event, this)\n }\n }\n}\n\nexport default ExcelGridComponent\n</script>\n\n<style scoped>\n.eg-shell {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n\n.eg-shell-fullscreen {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n\n.eg-wrapper {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-toolbar {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-group {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n\n.eg-toolbar-group + .eg-toolbar-group {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-spacer {\n flex: 1 1 auto;\n}\n\n.eg-toolbar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged {\n position: relative;\n}\n.eg-toolbar-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-btn:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-btn-active {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n\n.eg-toolbar-select {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n\n.eg-toolbar-select:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n.eg-toolbar-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n\n.eg-toolbar-select:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-segmented {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n\n.eg-toolbar-segment {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon { width: 12px; height: 12px; }\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon { width: 15px; height: 15px; }\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon { width: 18px; height: 18px; }\n\n.eg-toolbar-icon {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n\n.eg-icon-undo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); }\n.eg-icon-redo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); }\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); }\n.eg-icon-import { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); }\n.eg-icon-print { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); }\n.eg-icon-local-sync { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); }\n.eg-icon-lock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); }\n.eg-icon-unlock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); }\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n.eg-icon-compress{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); }\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-middle { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-large { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); }\n\n.eg-container {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n\n.eg-header-area {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n\n.eg-header-cell {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n\n.eg-header-cell:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-header-group { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default)); }\n\n.eg-sort-icon {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon { opacity: 0.65; }\n.eg-sort-icon:hover { opacity: 1; }\n.eg-sort-icon.eg-sort-icon-active {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default)); }\n\n.eg-filter-input,\n.eg-filter-select {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input:focus,\n.eg-filter-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n\n.eg-col-resize-handle {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n\n.eg-body-area {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer,\n.eg-scroll-col-footer,\n.eg-pinned-right-footer {\n position: relative;\n}\n\n.eg-footer-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total {\n font-weight: 700;\n}\n\n.eg-row {\n position: relative;\n}\n\n.eg-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n\n.eg-locked .eg-cell { cursor: not-allowed; }\n\n.eg-cell-value {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right { justify-content: flex-end; }\n.eg-cell-align-center { justify-content: center; }\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value { text-align: right; }\n.eg-cell-align-center .eg-cell-value { text-align: center; }\n.eg-cell--has-suffix.eg-cell-select::after { display: none; }\n.eg-cell-select-trigger-wrap {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n\n.eg-cell-selected {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n\n.eg-cell-fill-preview {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n\n.eg-cell-copy {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n\n.eg-cell-merged-hidden {\n display: none !important;\n}\n\n.eg-cell-invalid {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n\n.eg-cell-input {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n\n.eg-row-header-cell {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-row-resize-handle {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n\n.eg-fill-handle {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n\n.eg-context-menu {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu {\n min-width: 110px;\n}\n\n.eg-context-item {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n\n.eg-context-item:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n\n.eg-context-separator {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n\n.eg-context-submenu-parent::after { content: ' ▶'; float: right; }\n\n.eg-context-submenu {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-context-submenu-parent:hover .eg-context-submenu { display: block; }\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-info-modal {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-info-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n\n.eg-info-modal-title {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-info-modal-close {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n\n.eg-info-modal-close:hover { color: var(--eg-color-text, var(--eg-color-text-default)); }\n\n.eg-info-modal-body {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.eg-group-btn {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n\n.eg-group-btn.collapsed { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default)); }\n\n.eg-drag-ghost {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging,\n.eg-container.eg-col-dragging * {\n cursor: grabbing !important;\n}\n\n.eg-resize-line {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header, .eg-frozen-col-body, .eg-frozen-col-footer { position: relative; }\n.eg-pinned-right-header, .eg-pinned-right-footer { position: relative; }\n\n.eg-frozen-col-header::after, .eg-frozen-col-body::after, .eg-frozen-col-footer::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge::after,\n.eg-frozen-col-body.eg-shadow-edge::after,\n.eg-frozen-col-footer.eg-shadow-edge::after {\n opacity: 1;\n}\n\n.eg-pinned-right-body {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n\n.eg-pinned-right-header::before, .eg-pinned-right-body::before, .eg-pinned-right-footer::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge::before,\n.eg-pinned-right-body.eg-shadow-edge::before,\n.eg-pinned-right-footer.eg-shadow-edge::before {\n opacity: 1;\n}\n\n.eg-pinned-right-header {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n\n.eg-import-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n\n.eg-lock-flash {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n\n.eg-import-dialog {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n\n.eg-import-title {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n\n.eg-import-bar-wrap {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n\n.eg-import-bar {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n\n.eg-import-msg {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n\n.eg-import-pct {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n@keyframes eg-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n.eg-skeleton {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer {\n 0% { background-position: 100% 50%; }\n 100% { background-position: 0 50%; }\n}\n@media (prefers-reduced-motion: reduce) {\n .eg-skeleton-block, .eg-loading-spinner { animation: none; }\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date::after { display: none; }\n.eg-cell-date::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap { justify-content: flex-end; }\n.eg-cell-align-center .eg-cell-action-wrap { justify-content: center; }\n.eg-validation-tooltip {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu,\n.eg-size-small .eg-context-item {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input,\n.eg-size-small .eg-filter-select {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu,\n.eg-size-large .eg-context-item {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input,\n.eg-size-large .eg-filter-select {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon {\n width: 13px;\n height: 13px;\n}\n</style>\n\n<style>\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px; }\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default)); }\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0); }\n.eg-select-btn--clear:hover { background: #fff0f0; }\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden; }\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default)); }\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default; }\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed; }\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); }\n</style>\n"]}, media: undefined });
19848
+ inject("data-v-33b216ce_0", { source: "\n.eg-shell[data-v-33b216ce] {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n.eg-shell-fullscreen[data-v-33b216ce] {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material[data-v-33b216ce] {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd[data-v-33b216ce] {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent[data-v-33b216ce] {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon[data-v-33b216ce] {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n.eg-wrapper[data-v-33b216ce] {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n.eg-toolbar[data-v-33b216ce] {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-group[data-v-33b216ce] {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n.eg-toolbar-group + .eg-toolbar-group[data-v-33b216ce] {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-spacer[data-v-33b216ce] {\n flex: 1 1 auto;\n}\n.eg-toolbar-btn[data-v-33b216ce] {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged[data-v-33b216ce] {\n position: relative;\n}\n.eg-toolbar-badge[data-v-33b216ce] {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn[data-v-33b216ce]:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-toolbar-btn[data-v-33b216ce]:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.eg-toolbar-btn-active[data-v-33b216ce] {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n.eg-toolbar-select[data-v-33b216ce] {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n.eg-toolbar-select[data-v-33b216ce]:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-toolbar-select[data-v-33b216ce]:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-toolbar-select[data-v-33b216ce]:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.eg-toolbar-segmented[data-v-33b216ce] {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n.eg-toolbar-segment[data-v-33b216ce] {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon[data-v-33b216ce] { width: 12px; height: 12px;\n}\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon[data-v-33b216ce] { width: 15px; height: 15px;\n}\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon[data-v-33b216ce] { width: 18px; height: 18px;\n}\n.eg-toolbar-icon[data-v-33b216ce] {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n.eg-icon-undo[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\");\n}\n.eg-icon-redo[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\");\n}\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\");\n}\n.eg-icon-import[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\");\n}\n.eg-icon-print[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\");\n}\n.eg-icon-local-sync[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\");\n}\n.eg-icon-lock[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\");\n}\n.eg-icon-unlock[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\");\n}\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\");\n}\n.eg-icon-compress[data-v-33b216ce]{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\");\n}\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\");\n}\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\");\n}\n.eg-icon-size-middle[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\");\n}\n.eg-icon-size-large[data-v-33b216ce] { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\");\n}\n.eg-container[data-v-33b216ce] {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n.eg-header-area[data-v-33b216ce] {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n.eg-header-cell[data-v-33b216ce] {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n.eg-header-cell[data-v-33b216ce]:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n}\n.eg-header-group[data-v-33b216ce] { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n}\n.eg-sort-icon[data-v-33b216ce] {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon[data-v-33b216ce] { opacity: 0.65;\n}\n.eg-sort-icon[data-v-33b216ce]:hover { opacity: 1;\n}\n.eg-sort-icon.eg-sort-icon-active[data-v-33b216ce] {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell[data-v-33b216ce] {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty[data-v-33b216ce] { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-filter-input[data-v-33b216ce],\n.eg-filter-select[data-v-33b216ce] {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input[data-v-33b216ce]:focus,\n.eg-filter-select[data-v-33b216ce]:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select[data-v-33b216ce] {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n.eg-col-resize-handle[data-v-33b216ce] {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n.eg-body-area[data-v-33b216ce] {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer[data-v-33b216ce],\n.eg-scroll-col-footer[data-v-33b216ce],\n.eg-pinned-right-footer[data-v-33b216ce] {\n position: relative;\n}\n.eg-footer-cell[data-v-33b216ce] {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total[data-v-33b216ce] {\n font-weight: 700;\n}\n.eg-row[data-v-33b216ce] {\n position: relative;\n}\n.eg-cell[data-v-33b216ce] {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n.eg-locked .eg-cell[data-v-33b216ce] { cursor: not-allowed;\n}\n.eg-cell-value[data-v-33b216ce] {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right[data-v-33b216ce] { justify-content: flex-end;\n}\n.eg-cell-align-center[data-v-33b216ce] { justify-content: center;\n}\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value[data-v-33b216ce] { text-align: right;\n}\n.eg-cell-align-center .eg-cell-value[data-v-33b216ce] { text-align: center;\n}\n.eg-cell--has-suffix.eg-cell-select[data-v-33b216ce]::after { display: none;\n}\n.eg-cell-select-trigger-wrap[data-v-33b216ce] {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline[data-v-33b216ce] {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap[data-v-33b216ce] {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active[data-v-33b216ce] {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n.eg-cell-selected[data-v-33b216ce] {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top[data-v-33b216ce] {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom[data-v-33b216ce] {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left[data-v-33b216ce] {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right[data-v-33b216ce] {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-fill-preview[data-v-33b216ce] {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n.eg-cell-copy[data-v-33b216ce] {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell-merged-hidden[data-v-33b216ce] {\n display: none !important;\n}\n.eg-cell-invalid[data-v-33b216ce] {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n.eg-cell-input[data-v-33b216ce] {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n.eg-row-header-cell[data-v-33b216ce] {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.eg-row-resize-handle[data-v-33b216ce] {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n.eg-fill-handle[data-v-33b216ce] {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n.eg-context-menu[data-v-33b216ce] {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu[data-v-33b216ce] {\n min-width: 110px;\n}\n.eg-context-item[data-v-33b216ce] {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n.eg-context-item[data-v-33b216ce]:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active[data-v-33b216ce] {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active[data-v-33b216ce]::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n.eg-context-separator[data-v-33b216ce] {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n.eg-context-submenu-parent[data-v-33b216ce]::after { content: ' ▶'; float: right;\n}\n.eg-context-submenu[data-v-33b216ce] {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-context-submenu-parent:hover .eg-context-submenu[data-v-33b216ce] { display: block;\n}\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop[data-v-33b216ce] {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.eg-info-modal[data-v-33b216ce] {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n.eg-info-modal-header[data-v-33b216ce] {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n.eg-info-modal-title[data-v-33b216ce] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-info-modal-close[data-v-33b216ce] {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n.eg-info-modal-close[data-v-33b216ce]:hover { color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-info-modal-body[data-v-33b216ce] {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n.eg-group-btn[data-v-33b216ce] {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n.eg-group-btn.collapsed[data-v-33b216ce] { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default));\n}\n.eg-drag-ghost[data-v-33b216ce] {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source[data-v-33b216ce] {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source[data-v-33b216ce] {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost[data-v-33b216ce] {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging[data-v-33b216ce],\n.eg-container.eg-col-dragging *[data-v-33b216ce] {\n cursor: grabbing !important;\n}\n.eg-resize-line[data-v-33b216ce] {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header[data-v-33b216ce], .eg-frozen-col-body[data-v-33b216ce], .eg-frozen-col-footer[data-v-33b216ce] { position: relative;\n}\n.eg-pinned-right-header[data-v-33b216ce], .eg-pinned-right-footer[data-v-33b216ce] { position: relative;\n}\n.eg-frozen-col-header[data-v-33b216ce]::after, .eg-frozen-col-body[data-v-33b216ce]::after, .eg-frozen-col-footer[data-v-33b216ce]::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge[data-v-33b216ce]::after,\n.eg-frozen-col-body.eg-shadow-edge[data-v-33b216ce]::after,\n.eg-frozen-col-footer.eg-shadow-edge[data-v-33b216ce]::after {\n opacity: 1;\n}\n.eg-pinned-right-body[data-v-33b216ce] {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n.eg-pinned-right-header[data-v-33b216ce]::before, .eg-pinned-right-body[data-v-33b216ce]::before, .eg-pinned-right-footer[data-v-33b216ce]::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge[data-v-33b216ce]::before,\n.eg-pinned-right-body.eg-shadow-edge[data-v-33b216ce]::before,\n.eg-pinned-right-footer.eg-shadow-edge[data-v-33b216ce]::before {\n opacity: 1;\n}\n.eg-pinned-right-header[data-v-33b216ce] {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-import-overlay[data-v-33b216ce] {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n.eg-lock-flash[data-v-33b216ce] {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon[data-v-33b216ce] {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n.eg-import-dialog[data-v-33b216ce] {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n.eg-import-title[data-v-33b216ce] {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n.eg-import-bar-wrap[data-v-33b216ce] {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n.eg-import-bar[data-v-33b216ce] {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n.eg-import-msg[data-v-33b216ce] {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n.eg-import-pct[data-v-33b216ce] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner[data-v-33b216ce] {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin-data-v-33b216ce 0.8s linear infinite;\n}\n@keyframes eg-spin-data-v-33b216ce {\nto { transform: rotate(360deg);\n}\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay[data-v-33b216ce] {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner[data-v-33b216ce] {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin-data-v-33b216ce 0.8s linear infinite;\n}\n.eg-skeleton[data-v-33b216ce] {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row[data-v-33b216ce] {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header[data-v-33b216ce] {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block[data-v-33b216ce] {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer-data-v-33b216ce 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer-data-v-33b216ce {\n0% { background-position: 100% 50%;\n}\n100% { background-position: 0 50%;\n}\n}\n@media (prefers-reduced-motion: reduce) {\n.eg-skeleton-block[data-v-33b216ce], .eg-loading-spinner[data-v-33b216ce] { animation: none;\n}\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly[data-v-33b216ce] {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected[data-v-33b216ce] {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active[data-v-33b216ce] {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select[data-v-33b216ce] {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select[data-v-33b216ce]:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select[data-v-33b216ce]::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date[data-v-33b216ce]:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date[data-v-33b216ce]::after { display: none;\n}\n.eg-cell-date[data-v-33b216ce]::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox[data-v-33b216ce] {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap[data-v-33b216ce] {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap[data-v-33b216ce] { justify-content: flex-end;\n}\n.eg-cell-align-center .eg-cell-action-wrap[data-v-33b216ce] { justify-content: center;\n}\n.eg-validation-tooltip[data-v-33b216ce] {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell[data-v-33b216ce]:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell[data-v-33b216ce]:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview),\n.eg-row.eg-row--hover .eg-cell[data-v-33b216ce]:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n.eg-row-header-cell[data-v-33b216ce]:hover,\n.eg-row-header-cell.eg-row--hover[data-v-33b216ce] {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell[data-v-33b216ce] {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input[data-v-33b216ce] {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell[data-v-33b216ce] {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell[data-v-33b216ce] {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell[data-v-33b216ce] {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu[data-v-33b216ce],\n.eg-size-small .eg-context-item[data-v-33b216ce] {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item[data-v-33b216ce] {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu[data-v-33b216ce] {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon[data-v-33b216ce] {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input[data-v-33b216ce],\n.eg-size-small .eg-filter-select[data-v-33b216ce] {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon[data-v-33b216ce] {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell[data-v-33b216ce] {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input[data-v-33b216ce] {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell[data-v-33b216ce] {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell[data-v-33b216ce] {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell[data-v-33b216ce] {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu[data-v-33b216ce],\n.eg-size-large .eg-context-item[data-v-33b216ce] {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item[data-v-33b216ce] {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu[data-v-33b216ce] {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon[data-v-33b216ce] {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input[data-v-33b216ce],\n.eg-size-large .eg-filter-select[data-v-33b216ce] {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon[data-v-33b216ce] {\n width: 13px;\n height: 13px;\n}\n", map: {"version":3,"sources":["/Users/macmini/Projects/Realsoft/vue2-excel-grid/src/components/ExcelGrid.vue"],"names":[],"mappings":";AA2iOA;EACA;;GAEA;EACA,mEAAA;;EAEA,uBAAA;EACA,kEAAA;EACA,4BAAA;EACA,mCAAA;;EAEA,kBAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,iCAAA;EACA,kCAAA;EACA,uCAAA;EACA,uDAAA;;EAEA,+BAAA;EACA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,oBAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,mBAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,wBAAA;EACA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,iBAAA;EACA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,oBAAA;EACA,6DAAA;EACA,0DAAA;EACA,0DAAA;EACA,qDAAA;;EAEA,uCAAA;EACA,wDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,qBAAA;EACA,2CAAA;EACA,qDAAA;;EAEA,yCAAA;EACA,yCAAA;EACA,0CAAA;;EAEA,sBAAA;EACA,iEAAA;EACA,wEAAA;EACA,yDAAA;EACA,gBAAA;AACA;AAEA;EACA,eAAA;EACA,QAAA;EACA,aAAA;EACA,oEAAA;EACA,aAAA;EACA,sBAAA;EACA,YAAA;EACA,gBAAA;EACA,gBAAA;AACA;;AAEA,sEAAA;AACA;EACA,oEAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,4CAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,8DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,kEAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,+FAAA;EACA,4DAAA;EACA,4DAAA;EACA,uDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,yBAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,oEAAA;AACA;EACA,oHAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,qCAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,+DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,yCAAA;EACA,mDAAA;EACA,+CAAA;;EAEA,sHAAA;EACA,uDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,4CAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,qEAAA;AACA;EACA,gEAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,gCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,oCAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,oDAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,gEAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,8FAAA;EACA,uDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,uBAAA;EACA,qCAAA;EACA,sCAAA;AACA;;AAEA,qEAAA;AACA;EACA,8EAAA;EACA,4BAAA;EACA,mCAAA;EACA,wBAAA;EACA,iCAAA;;EAEA,kCAAA;EACA,uCAAA;EACA,uCAAA;;EAEA,iCAAA;EACA,sCAAA;EACA,sCAAA;;EAEA,yDAAA;EACA,kCAAA;EACA,uCAAA;EACA,yCAAA;;EAEA,0DAAA;EACA,qCAAA;EACA,2CAAA;EACA,wCAAA;EACA,8CAAA;;EAEA,mCAAA;EACA,yCAAA;EACA,6CAAA;EACA,yCAAA;EACA,kDAAA;EACA,6DAAA;EACA,qCAAA;;EAEA,gCAAA;EACA,0CAAA;EACA,sCAAA;;EAEA,wDAAA;EACA,sDAAA;EACA,uDAAA;EACA,kDAAA;;EAEA,qDAAA;EACA,2CAAA;EACA,kDAAA;;EAEA,2CAAA;EACA,qDAAA;;EAEA,4CAAA;EACA,qCAAA;EACA,sCAAA;AACA;AAEA;EACA,aAAA;EACA,sBAAA;EACA,gBAAA;EACA,sBAAA;EACA,iEAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,cAAA;EACA,YAAA;EACA,cAAA;EACA,sBAAA;EACA,oEAAA;EACA,+EAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,cAAA;AACA;AAEA;EACA,6EAAA;AACA;AAEA;EACA,cAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,UAAA;EACA,6BAAA;EACA,kBAAA;EACA,uBAAA;EACA,eAAA;EACA,6EAAA;AACA;;AAEA;;2CAEA;AACA;EACA,kBAAA;AACA;AACA;EACA,kBAAA;EACA,QAAA;EACA,UAAA;EACA,UAAA;EACA,WAAA;EACA,kBAAA;EACA,kEAAA;EACA,oBAAA;AACA;AACA;EACA,oFAAA;EACA,oEAAA;AACA;AAEA;EACA,YAAA;EACA,eAAA;AACA;AAEA;EACA,4EAAA;EACA,oEAAA;EACA,uEAAA;AACA;AAEA;EACA,YAAA;EACA,qBAAA;EACA,wEAAA;EACA,kBAAA;EACA,0EAAA;EACA;yEACA;EACA,qQAAA;EACA,4BAAA;EACA,qCAAA;EACA,wBAAA;EACA,6EAAA;EACA,eAAA;EACA,iEAAA;EACA,eAAA;EACA,gBAAA;EACA,wBAAA;EACA,aAAA;AACA;AAEA;EACA,oEAAA;AACA;AAEA;EACA,oEAAA;EACA,sFAAA;AACA;AAEA;EACA,YAAA;EACA,eAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,wEAAA;EACA,kBAAA;EACA,gBAAA;EACA,YAAA;EACA,QAAA;EACA,oEAAA;AACA;AAEA;EACA,kBAAA;AACA;;AAEA;gFACA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AACA,qFAAA,WAAA,EAAA,YAAA;AAAA;AAEA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,8BAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;AACA;AAEA,oCAAA,gRAAA,EAAA,wRAAA;AAAA;AACA,oCAAA,mRAAA,EAAA,2RAAA;AAAA;AACA;;yBAEA;AACA,oCAAA,gVAAA,EAAA,wVAAA;AAAA;AACA,oCAAA,+UAAA,EAAA,uVAAA;AAAA;AACA,oCAAA,8XAAA,EAAA,sYAAA;AAAA;AACA,uCAAA,mUAAA,EAAA,2UAAA;AAAA;AACA,oCAAA,ySAAA,EAAA,iTAAA;AAAA;AACA,oCAAA,wSAAA,EAAA,gTAAA;AAAA;AACA;gEACA;AACA,oCAAA,kTAAA,EAAA,0TAAA;AAAA;AACA,oCAAA,kTAAA,EAAA,0TAAA;AAAA;;AAEA;;;uEAGA;AACA,sCAAA,wNAAA,EAAA,gOAAA;AAAA;;AAEA;;;sBAGA;AACA,wCAAA,6PAAA,EAAA,qQAAA;AAAA;AACA,wCAAA,2PAAA,EAAA,mQAAA;AAAA;AACA,wCAAA,6PAAA,EAAA,qQAAA;AAAA;AAEA;EACA,aAAA;EACA,sBAAA;EACA,OAAA;EACA,cAAA;EACA,kBAAA;AACA;AAEA;EACA,aAAA;EACA,cAAA;EACA,sFAAA;EACA,kBAAA;EACA,WAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,sBAAA;EACA,8EAAA;EACA,+EAAA;EACA,wEAAA;EACA,yEAAA;EACA,gBAAA;EACA,gBAAA;EACA,mBAAA;EACA,kBAAA;EACA,iBAAA;EACA,eAAA;AACA;AAEA,yCAAA,4EAAA;AAAA;AACA,oCAAA,oFAAA;AAAA;AAEA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,gBAAA;EACA,sBAAA;EACA,cAAA;EACA,8BAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;EACA,eAAA;EACA,YAAA;AACA;AACA,uDAAA,aAAA;AAAA;AACA,uCAAA,UAAA;AAAA;AACA;EACA,UAAA;EACA,6DAAA;AACA;;AAEA,2BAAA;AACA;EACA,sBAAA;EACA,gBAAA;EACA,wFAAA;EACA,+EAAA;EACA,wEAAA;EACA,kBAAA;EACA,aAAA;EACA,mBAAA;AACA;AACA,yCAAA,wEAAA;AAAA;AAEA;;EAEA,WAAA;EACA,YAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,oEAAA;EACA,yDAAA;EACA,yEAAA;EACA,iEAAA;EACA,gBAAA;EACA,cAAA;EACA,aAAA;AACA;AACA;;EAEA,oEAAA;EACA,sFAAA;AACA;AACA;EACA,gBAAA;EACA,wBAAA;EACA,eAAA;EACA,mBAAA;EACA,sLAAA;EACA,4BAAA;EACA,qCAAA;EACA,wBAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,MAAA;EACA,SAAA;EACA,UAAA;EACA,kBAAA;EACA,UAAA;AACA;AAEA;EACA,aAAA;EACA,OAAA;EACA,gBAAA;AACA;;AAEA,4CAAA;AACA;;;EAGA,kBAAA;AACA;AAEA;EACA,sBAAA;EACA,wFAAA;EACA,gBAAA;EACA,mBAAA;EACA,2DAAA;EACA,gBAAA;EACA,qEAAA;EACA,kBAAA;EACA,MAAA;EACA,wEAAA;EACA,aAAA;EACA,mBAAA;EACA,yBAAA;AACA;;AAEA;wEACA;AACA;EACA,gBAAA;AACA;AAEA;EACA,kBAAA;AACA;AAEA;EACA,sBAAA;EACA,wFAAA;EACA,yFAAA;EACA,gBAAA;EACA,mBAAA;EACA,2DAAA;EACA,qEAAA;EACA,kBAAA;EACA,MAAA;EACA,oEAAA;EACA,eAAA;EACA,aAAA;EACA,mBAAA;AACA;AAEA,uCAAA,mBAAA;AAAA;AAEA;EACA,OAAA;EACA,gBAAA;EACA,uBAAA;EACA,mBAAA;EACA,YAAA;AACA;AACA,yCAAA,yBAAA;AAAA;AACA,yCAAA,uBAAA;AAAA;AACA;;;;2DAIA;AACA,wDAAA,iBAAA;AAAA;AACA,wDAAA,kBAAA;AAAA;AACA,8DAAA,aAAA;AAAA;AACA;EACA,OAAA;EACA,aAAA;EACA,mBAAA;EACA,gBAAA;EACA,YAAA;AACA;AACA;EACA,cAAA;EACA,qEAAA;EACA,eAAA;EACA,gBAAA;AACA;AACA;EACA,cAAA;EACA,oBAAA;EACA,mBAAA;AACA;AACA;EACA,oFAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,2FAAA;AACA;;AAEA;;wEAEA;AACA;EACA,uFAAA;AACA;AACA;EACA,0FAAA;AACA;AACA;EACA,wFAAA;AACA;AACA;EACA,yFAAA;AACA;AAEA;EACA,mGAAA;EACA,oFAAA;EACA,oBAAA;AACA;AAEA;EACA,0EAAA;EACA,oBAAA;AACA;AAEA;EACA,wBAAA;AACA;AAEA;EACA,kFAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,WAAA;EACA,YAAA;EACA,YAAA;EACA,aAAA;EACA,oEAAA;EACA,2DAAA;EACA,oBAAA;EACA,UAAA;AACA;AAEA;EACA,sBAAA;EACA,wEAAA;EACA,yFAAA;EACA,4FAAA;EACA,kBAAA;EACA,eAAA;EACA,6EAAA;EACA,eAAA;EACA,iBAAA;EACA,kBAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;AACA;AAEA;EACA,kBAAA;EACA,SAAA;EACA,OAAA;EACA,QAAA;EACA,WAAA;EACA,kBAAA;EACA,UAAA;AACA;AAEA;EACA,kBAAA;EACA,UAAA;EACA,WAAA;EACA,kEAAA;EACA,uBAAA;EACA,iBAAA;EACA,WAAA;AACA;AAEA;EACA,eAAA;EACA,6FAAA;EACA,wEAAA;EACA,4EAAA;EACA,yDAAA;EACA,wEAAA;EACA,aAAA;EACA,gBAAA;EACA,2DAAA;EACA,cAAA;EACA,yDAAA;AACA;;AAEA;;sDAEA;AACA;EACA,gBAAA;AACA;AAEA;EACA,iBAAA;EACA,eAAA;EACA,kBAAA;EACA,mBAAA;AACA;AAEA;EACA,oFAAA;EACA,yDAAA;AACA;;AAEA;sEACA;AACA;EACA,gBAAA;EACA,6DAAA;AACA;AACA;EACA,YAAA;EACA,kBAAA;EACA,SAAA;EACA,QAAA;EACA,2BAAA;AACA;AAEA;EACA,WAAA;EACA,4EAAA;EACA,aAAA;AACA;AAEA,qDAAA,aAAA,EAAA,YAAA;AAAA;AAEA;EACA,aAAA;EACA,kBAAA;EACA,UAAA;EACA,SAAA;EACA,6FAAA;EACA,wEAAA;EACA,4EAAA;EACA,yDAAA;EACA,wEAAA;EACA,gBAAA;EACA,cAAA;EACA,cAAA;EACA,yDAAA;AACA;AAEA,wEAAA,cAAA;AAAA;;AAEA,oCAAA;AACA;EACA,eAAA;EACA,QAAA;EACA,cAAA;EACA,+BAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;AACA;AAEA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,wEAAA;EACA,UAAA;EACA,gBAAA;EACA,gBAAA;EACA,aAAA;EACA,sBAAA;EACA,iEAAA;AACA;AAEA;EACA,aAAA;EACA,mBAAA;EACA,8BAAA;EACA,SAAA;EACA,kBAAA;EACA,+EAAA;EACA,cAAA;AACA;AAEA;EACA,2DAAA;EACA,gBAAA;EACA,yDAAA;AACA;AAEA;EACA,gBAAA;EACA,YAAA;EACA,eAAA;EACA,eAAA;EACA,cAAA;EACA,6EAAA;EACA,gBAAA;AACA;AAEA,8CAAA,yDAAA;AAAA;AAEA;EACA,SAAA;EACA,aAAA;EACA,cAAA;EACA,OAAA;EACA,4EAAA;EACA,eAAA;EACA,yDAAA;EACA,qBAAA;EACA,sBAAA;AACA;AAEA;EACA,kBAAA;EACA,WAAA;EACA,YAAA;EACA,iBAAA;EACA,kBAAA;EACA,eAAA;EACA,eAAA;EACA,oFAAA;EACA,oFAAA;EACA,kBAAA;EACA,UAAA;EACA,iBAAA;AACA;AAEA,2CAAA,0FAAA;AAAA;AAEA;EACA,kBAAA;EACA,yEAAA;EACA,oBAAA;EACA,WAAA;AACA;;AAEA,yBAAA;AACA;EACA,YAAA;EACA,yEAAA;EACA,oBAAA;AACA;AACA;EACA,YAAA;AACA;AACA;;2BAEA;AACA;EACA,eAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,sBAAA;EACA,eAAA;EACA,wEAAA;EACA,wEAAA;EACA,yDAAA;EACA,0CAAA;EACA,yEAAA;EACA,gBAAA;EACA,yDAAA;EACA,mBAAA;EACA,gBAAA;EACA,uBAAA;EACA,oBAAA;EACA,aAAA;EACA,YAAA;AACA;;AAEA;iDACA;AACA;;EAEA,2BAAA;AACA;AAEA;EACA,oBAAA;EACA,aAAA;AACA;;AAEA;;;;;;;;;iBASA;AACA,uHAAA,kBAAA;AAAA;AACA,qFAAA,kBAAA;AAAA;AAEA;EACA,WAAA;EACA,kBAAA;EACA,MAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,wHAAA;EACA,UAAA;EACA,8BAAA;EACA,oBAAA;AACA;AACA;;;EAGA,UAAA;AACA;AAEA;EACA,gBAAA;EACA,QAAA;EACA,WAAA;EACA,oEAAA;EACA,cAAA;AACA;AAEA;EACA,WAAA;EACA,kBAAA;EACA,MAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA;EACA,wHAAA;EACA,UAAA;EACA,8BAAA;EACA,oBAAA;AACA;AACA;;;EAGA,UAAA;AACA;AAEA;EACA,gBAAA;EACA,cAAA;EACA,2FAAA;EACA,wEAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,0EAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,YAAA;EACA,yDAAA;AACA;AAEA;EACA,kBAAA;EACA,QAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,oBAAA;EACA,WAAA;AACA;AACA;EACA,qBAAA;EACA,WAAA;EACA,YAAA;EACA,oEAAA;EACA,kBAAA;EACA,sBAAA;EACA,qBAAA;EACA,0BAAA;EACA,8BAAA;EACA,6BAAA;EACA,mDAAA;AACA;AAEA;EACA,oEAAA;EACA,kFAAA;EACA,kBAAA;EACA,kBAAA;EACA,gBAAA;EACA,oEAAA;EACA,kBAAA;AACA;AAEA;EACA,eAAA;EACA,gBAAA;EACA,yDAAA;EACA,mBAAA;AACA;AAEA;EACA,WAAA;EACA,4EAAA;EACA,yDAAA;EACA,gBAAA;EACA,mBAAA;AACA;AAEA;EACA,YAAA;EACA,sGAAA;EACA,yDAAA;EACA,4BAAA;AACA;AAEA;EACA,eAAA;EACA,6EAAA;EACA,kBAAA;AACA;AAEA;EACA,2DAAA;EACA,gBAAA;EACA,6DAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,mBAAA;EACA,kFAAA;EACA,wEAAA;EACA,kBAAA;EACA,uDAAA;AACA;AACA;AACA,KAAA,yBAAA;AAAA;AACA;;AAEA,+CAAA;AACA;EACA,kBAAA;EACA,QAAA;EACA,0EAAA;EACA,aAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,gBAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,kFAAA;EACA,wEAAA;EACA,kBAAA;EACA,uDAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;EACA,gBAAA;AACA;AACA;EACA,aAAA;EACA,mBAAA;EACA,cAAA;EACA,sBAAA;AACA;AACA;EACA,wEAAA;AACA;AACA;EACA,WAAA;EACA,aAAA;EACA,yDAAA;EACA;;;;;GAKA;EACA,0BAAA;EACA,iEAAA;AACA;AACA;AACA,KAAA,6BAAA;AAAA;AACA,OAAA,0BAAA;AAAA;AACA;AACA;AACA,4EAAA,eAAA;AAAA;AACA;;AAEA,8BAAA;AACA;;;;8EAIA;AACA;EACA;;;;;;;;mFAQA;EACA;;;;;;iEAMA;EACA,wFAAA;EACA,eAAA;AACA;AACA;EACA;;;;;;;;qGAQA;AACA;AACA;EACA,wFAAA;AACA;;AAEA,gCAAA;AACA;EACA,eAAA;AACA;AACA;;;;;;;;2FAQA;AACA;EACA,sFAAA;AACA;AACA;EACA,YAAA;EACA,kBAAA;EACA,UAAA;EACA,QAAA;EACA,2BAAA;EACA,qEAAA;EACA,eAAA;EACA,oBAAA;EACA,cAAA;AACA;;AAEA,+BAAA;AACA;;;;;+BAKA;AACA;EACA,sFAAA;AACA;AACA,4DAAA,aAAA;AAAA;AACA;EACA,WAAA;EACA,kBAAA;EACA,UAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,2BAAA;EACA,gFAAA;EACA,oBAAA;EACA,uYAAA;EACA,+XAAA;EACA,8BAAA;EACA,sBAAA;EACA,0BAAA;EACA,kBAAA;AACA;;AAEA,8BAAA;AACA;EACA,uBAAA;EACA,cAAA;AACA;;AAEA,4BAAA;AACA;EACA,aAAA;EACA,mBAAA;EACA,YAAA;EACA,WAAA;EACA,eAAA;AACA;AACA;;6EAEA;AACA,6DAAA,yBAAA;AAAA;AACA,8DAAA,uBAAA;AAAA;AACA;EACA,0EAAA;EACA,sEAAA;EACA,qEAAA;EACA,eAAA;EACA,gBAAA;EACA,yDAAA;EACA,sEAAA;EACA,mBAAA;EACA,oBAAA;AACA;;AAEA,mCAAA;AACA;EACA,0EAAA;AACA;AACA;;EAEA,wEAAA;AACA;AACA;;EAEA,wEAAA;AACA;;AAEA,8BAAA;;AAEA,UAAA;AACA;EACA,eAAA;EACA,cAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,iBAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,UAAA;EACA,WAAA;AACA;;AAEA,UAAA;AACA;EACA,eAAA;EACA,cAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,2DAAA;AACA;AACA;EACA,eAAA;AACA;AACA;EACA,eAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,iBAAA;AACA;AACA;EACA,gBAAA;AACA;AACA;EACA,YAAA;EACA,aAAA;AACA;AACA;;EAEA,eAAA;AACA;AACA;EACA,WAAA;EACA,YAAA;AACA","file":"ExcelGrid.vue","sourcesContent":["<template>\n <div\n class=\"eg-shell\"\n :class=\"['eg-size-' + internalSize, 'eg-theme-' + internalTheme, { 'eg-shell-fullscreen': isFullscreen, 'eg-locked': internalLocked }]\"\n :style=\"shellStyle\"\n >\n <!-- Toolbar -->\n <div v-if=\"_toolbarVisible\" class=\"eg-toolbar\">\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canUndo || toolbarDisabled('undoRedo')\"\n :title=\"t.undo\"\n @click=\"undo\"\n ><span class=\"eg-toolbar-icon eg-icon-undo\"></span></button>\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canRedo || toolbarDisabled('undoRedo')\"\n :title=\"t.redo\"\n @click=\"redo\"\n ><span class=\"eg-toolbar-icon eg-icon-redo\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('export')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('export')\"\n :title=\"t.export\"\n @click=\"toolbarExportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-export\"></span></button>\n <button\n v-if=\"toolbarHas('import')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || toolbarDisabled('import')\"\n :title=\"t.import\"\n @click=\"toolbarImportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-import\"></span></button>\n <input\n v-if=\"toolbarHas('import')\"\n ref=\"toolbarImportInput\"\n type=\"file\"\n accept=\".xlsx,.xls,.csv\"\n style=\"display: none\"\n @change=\"onToolbarImportFile\"\n >\n <button\n v-if=\"toolbarHas('print')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('print')\"\n :title=\"t.print\"\n @click=\"printGrid\"\n ><span class=\"eg-toolbar-icon eg-icon-print\"></span></button>\n <button\n v-if=\"localSyncKey && toolbarHas('localSync')\"\n type=\"button\"\n class=\"eg-toolbar-btn eg-toolbar-btn-badged\"\n :disabled=\"toolbarDisabled('localSync')\"\n :title=\"localSyncAvailable ? t.localSyncAvailable : t.localSync\"\n @click=\"openLocalSyncModal\"\n ><span class=\"eg-toolbar-icon eg-icon-local-sync\"></span\n ><span v-if=\"localSyncAvailable\" class=\"eg-toolbar-badge\" aria-hidden=\"true\"></span></button>\n </div>\n\n <slot name=\"toolbar-extra\" :locked=\"internalLocked\" :fullscreen=\"isFullscreen\"></slot>\n\n <div v-if=\"toolbarButtons.length\" class=\"eg-toolbar-group\">\n <button\n v-for=\"btn in toolbarButtons\"\n :key=\"btn.key\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :title=\"btn.title\"\n @click=\"onToolbarButtonClick(btn, $event)\"\n ><span class=\"eg-toolbar-icon\" :style=\"btn.icon ? { maskImage: 'url(' + btn.icon + ')', webkitMaskImage: 'url(' + btn.icon + ')' } : null\">{{ btn.icon ? '' : (btn.label || '') }}</span></button>\n </div>\n\n <div class=\"eg-toolbar-spacer\"></div>\n\n <div v-if=\"toolbarHas('theme')\" class=\"eg-toolbar-group\">\n <select\n class=\"eg-toolbar-select\"\n :value=\"internalTheme\"\n :disabled=\"toolbarDisabled('theme')\"\n :title=\"t.theme\"\n @change=\"setTheme($event.target.value)\"\n >\n <option value=\"excel\">Excel</option>\n <option value=\"material\">Material</option>\n <option value=\"antd\">Ant Design</option>\n <option value=\"fluent\">Fluent</option>\n <option value=\"carbon\">Carbon</option>\n </select>\n </div>\n\n <div v-if=\"toolbarHas('size')\" class=\"eg-toolbar-segmented\" role=\"radiogroup\" :aria-label=\"t.size\">\n <button\n v-for=\"key in sizeOptions\"\n :key=\"key\"\n type=\"button\"\n role=\"radio\"\n class=\"eg-toolbar-btn eg-toolbar-segment\"\n :class=\"['eg-toolbar-segment-' + key, { 'eg-toolbar-btn-active': internalSize === key }]\"\n :aria-checked=\"internalSize === key\"\n :disabled=\"toolbarDisabled('size')\"\n :title=\"sizeTitle(key)\"\n @click=\"setSize(key)\"\n ><span class=\"eg-toolbar-icon\" :class=\"'eg-icon-size-' + key\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('lock')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': internalLocked }\"\n :disabled=\"toolbarDisabled('lock')\"\n :title=\"internalLocked ? t.unlock : t.lock\"\n @click=\"toggleLock\"\n ><span class=\"eg-toolbar-icon\" :class=\"internalLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"></span></button>\n <button\n v-if=\"toolbarHas('fullscreen')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': isFullscreen }\"\n :disabled=\"toolbarDisabled('fullscreen')\"\n :title=\"isFullscreen ? t.exitFullscreen : t.fullscreen\"\n @click=\"toggleFullscreen\"\n ><span class=\"eg-toolbar-icon\" :class=\"isFullscreen ? 'eg-icon-compress' : 'eg-icon-expand'\"></span></button>\n </div>\n </div>\n\n <div\n class=\"eg-wrapper\"\n :class=\"'eg-size-' + internalSize\"\n :style=\"wrapperStyle\"\n tabindex=\"0\"\n @keydown=\"onKeyDown\"\n @keypress=\"onKeyPress\"\n @mousemove=\"onGridMouseMove\"\n @mouseleave=\"onGridMouseLeave\"\n ref=\"wrapper\"\n >\n <!-- Context menu -->\n <div\n v-if=\"contextMenu.visible\"\n key=\"eg-context-menu\"\n ref=\"contextMenuEl\"\n class=\"eg-context-menu\"\n :style=\"{ top: contextMenu.y + 'px', left: (contextMenuPositioned ? contextMenu.x : -9999) + 'px', visibility: contextMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" @click=\"ctxCopy\">{{ t.copy }}</div>\n <div class=\"eg-context-item\" @click=\"ctxCut\">{{ t.cut }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPaste\">{{ t.paste }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item eg-context-submenu-parent\">\n {{ t.pasteSpecial }}\n <div class=\"eg-context-submenu\">\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all')\">{{ t.pasteAll }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('values')\">{{ t.pasteValues }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('formats')\">{{ t.pasteFormats }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all', true)\">{{ t.transpose }}</div>\n </div>\n </div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxMerge\">{{ t.mergeCells }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnmerge\">{{ t.unmergeCells }}</div>\n <div class=\"eg-context-separator\"></div>\n <div v-if=\"contextMenu.row >= 0\" class=\"eg-context-item\" @click=\"ctxHideRow\">{{ t.hideRows }}</div>\n <div v-if=\"contextMenu.col >= 0\" class=\"eg-context-item\" @click=\"ctxHideColumn\">{{ t.hideColumns }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideRows\">{{ t.unhideRows }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideColumns\">{{ t.unhideColumns }}</div>\n <template v-if=\"contextMenu.row < 0 && contextMenu.col >= 0\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxPinLeft\">{{ t.pinLeft }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPinRight\">{{ t.pinRight }}</div>\n <div class=\"eg-context-item\" v-if=\"isColPinned(contextMenu.col)\" @click=\"ctxUnpin\">{{ t.unpin }}</div>\n </template>\n <template v-if=\"devMode && (contextMenu.col >= 0 || contextMenu.row >= 0)\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.col >= 0\" @click=\"ctxShowColumnInfo\">{{ t.columnInfo }}</div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.row >= 0\" @click=\"ctxShowRowInfo\">{{ t.rowInfo }}</div>\n </template>\n </div>\n\n <!-- Sort popup menu (header sort icon) -->\n <div\n v-if=\"sortMenu.visible\"\n key=\"eg-sort-menu\"\n ref=\"sortMenuEl\"\n class=\"eg-context-menu eg-sort-menu\"\n :style=\"{ top: sortMenu.y + 'px', left: (sortMenuPositioned ? sortMenu.x : -9999) + 'px', visibility: sortMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('asc') }\" @click=\"sortMenuPickAsc\">{{ t.sortMenuAsc }}</div>\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('desc') }\" @click=\"sortMenuPickDesc\">{{ t.sortMenuDesc }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"sortMenuPickClear\">{{ t.sortMenuClear }}</div>\n </div>\n\n <!-- devMode: column/row info modal -->\n <div v-if=\"infoModal\" key=\"eg-info-modal-backdrop\" class=\"eg-info-modal-backdrop\" @click=\"closeInfoModal\">\n <div class=\"eg-info-modal\" @click.stop>\n <div class=\"eg-info-modal-header\">\n <span class=\"eg-info-modal-title\">{{ infoModal.title }}</span>\n <button type=\"button\" class=\"eg-info-modal-close\" @click=\"closeInfoModal\">×</button>\n </div>\n <pre class=\"eg-info-modal-body\">{{ infoModal.json }}</pre>\n </div>\n </div>\n\n <!-- Scroll container -->\n <div class=\"eg-container\" :class=\"{ 'eg-col-dragging': colDrag.active }\" ref=\"container\" @scroll=\"onScroll\" @click=\"hideFloatingMenus\">\n\n <!-- ===== HEADER AREA ===== -->\n <div class=\"eg-header-area\" :style=\"headerAreaStyle\">\n\n <!-- Corner cell -->\n <div\n class=\"eg-corner\"\n :style=\"cornerStyle\"\n @click=\"selectAll\"\n ></div>\n\n <!-- Row group outline gutter (left) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-gutter\" :style=\"rowOutlineGutterStyle\"></div>\n\n <!-- Frozen columns header -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, true)\">\n <div\n :key=\"'fh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, true)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'ffilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable columns header -->\n <div class=\"eg-scroll-col-header\" ref=\"scrollColHeader\" :style=\"scrollColHeaderStyle\">\n <!-- Inner width = scrollable columns only: this strip renders no pinned cells\n (they live in the sibling zones), and scrollableContentWidth keeps its max\n scrollLeft identical to the body's (see scrollColHeaderStyle). -->\n <div class=\"eg-scroll-col-header-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: (headerRowHeight * headerDepth + _filterRowH) + 'px', position: 'relative' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, false)\">\n <div\n :key=\"'sh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, false)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'sfilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns header -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellInZone(cell, rowIdx, 'right')\">\n <div\n :key=\"'prh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyleForZone(cell, rowIdx, 'right')\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prfilter-' + col._flatIndex\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(col._flatIndex)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- ===== BODY AREA ===== -->\n <div class=\"eg-body-area\" :style=\"bodyAreaStyle\">\n\n <!-- Row number header column -->\n <div class=\"eg-row-header-col\" :style=\"rowHeaderColStyle\">\n <!-- Scrollable row headers -->\n <div class=\"eg-scroll-row-headers\" :style=\"scrollRowHeadersStyle\" ref=\"scrollRowHeaders\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'srh-' + ri\"\n class=\"eg-row-header-cell\"\n :style=\"getRowHeaderStyle(ri)\"\n :data-row=\"ri\"\n @mousedown=\"onRowHeaderMouseDown($event, ri)\"\n @contextmenu.prevent=\"onRowContextMenu($event, ri)\"\n >\n {{ ri + 1 }}\n <div class=\"eg-row-resize-handle\" @mousedown.stop=\"startRowResize($event, ri)\"></div>\n </div>\n </template>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Row group outline gutter (left side of rows) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-col\" :style=\"rowOutlineColStyle\">\n <template v-for=\"group in rowGroups\">\n <div\n :key=\"'rg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getRowGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n <!-- Main scroll body -->\n <div class=\"eg-scroll-body\" ref=\"scrollBody\" :style=\"scrollBodyStyle\" @scroll=\"onBodyScroll\">\n <!-- display:flex puts the two sticky pinned zones side by side at y=0 (as\n blocks they'd stack vertically, pushing pinned-right below the rows), and\n lets margin-left:auto give pinned-right its natural place at the inner's\n RIGHT edge — sticky right:0 only ever pulls an element back toward its\n natural position, so from x=0 (block flow) it would never stick at all.\n .eg-main-cells is position:absolute and doesn't participate. -->\n <div class=\"eg-scroll-body-inner\" :style=\"{ width: scrollContentWidth + 'px', height: totalVirtualHeight + 'px', position: 'relative', display: 'flex' }\">\n\n <!-- Pinned left column body -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'fc-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\" :data-row=\"ri\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'fc-cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Main data cells -->\n <div class=\"eg-main-cells\" :style=\"mainCellsStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative', width: scrollableContentWidth + 'px' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'row-' + ri\"\n class=\"eg-row\"\n :class=\"{ 'eg-row--even': ri % 2 === 0 }\"\n :style=\"getRowStyle(ri)\"\n :data-row=\"ri\"\n >\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input\n v-else\n ref=\"editInput\"\n class=\"eg-cell-input\"\n v-model=\"editValue\"\n @input=\"onEditInput\"\n @blur=\"commitEdit\"\n @keydown.stop=\"onEditKeyDown\"\n />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Pinned Right column body -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'pr-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\" :data-row=\"ri\">\n <template v-for=\"col in pinnedRightCols\">\n <div\n :key=\"'pr-cell-' + ri + '-' + col._flatIndex\"\n class=\"eg-cell\"\n :data-fi=\"col._flatIndex\"\n :class=\"getCellClasses(ri, col._flatIndex)\"\n :style=\"[getCellStyle(ri, col._flatIndex), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, col._flatIndex)\"\n @mouseover=\"onCellMouseOver($event, ri, col._flatIndex)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, col._flatIndex)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, col._flatIndex)\"\n >\n <div v-if=\"isMergedAway(ri, col._flatIndex)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, col._flatIndex)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, col._flatIndex)\"\n :disabled=\"!isCellEditable(ri, col._flatIndex)\"\n @change=\"(v) => setCheckboxValue(ri, col._flatIndex, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, col._flatIndex, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Fill handle (shown on primary range) -->\n <div\n v-if=\"fillHandleVisible\"\n class=\"eg-fill-handle\"\n :style=\"fillHandleStyle\"\n @mousedown.stop=\"startFillDrag\"\n ></div>\n\n <!-- Drag move ghost border -->\n <div\n v-if=\"dragMove.active\"\n class=\"eg-drag-ghost\"\n :style=\"dragGhostStyle\"\n ></div>\n </div>\n </div>\n\n <!-- Column group outline row (top, above body) -->\n <div v-if=\"colGroups.length\" class=\"eg-col-outline-row\" :style=\"colOutlineRowStyle\">\n <template v-for=\"group in colGroups\">\n <div\n :key=\"'cg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getColGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n </div><!-- end body-area -->\n\n <!-- ===== FOOTER AREA (column totals) ===== -->\n <div v-if=\"hasFooter\" class=\"eg-footer-area\" :style=\"footerAreaStyle\">\n\n <!-- \"Jami\" label cell (mirrors corner cell) -->\n <div class=\"eg-footer-label\" :style=\"footerLabelStyle\">{{ t.total }}</div>\n\n <!-- Frozen columns footer -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div :key=\"'ff-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable footer (horizontally synced with body via scrollFooter ref) -->\n <div class=\"eg-scroll-col-footer\" ref=\"scrollFooter\" :style=\"scrollColHeaderStyle\">\n <div class=\"eg-scroll-col-footer-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: footerRowHeight + 'px', position: 'relative' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div :key=\"'sf-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns footer -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prf-' + col._flatIndex\"\n class=\"eg-footer-cell\"\n :class=\"getFooterCellClass(col._flatIndex)\"\n :style=\"getFooterCellStyle(col._flatIndex)\"\n >{{ getColumnTotalDisplay(col._flatIndex) }}</div>\n </div>\n </div>\n </div><!-- end footer-area -->\n\n <!-- Resize indicator line -->\n <div v-if=\"resizing.active\" class=\"eg-resize-line\" :style=\"resizeLineStyle\"></div>\n\n <!-- Loading overlay (host-driven, e.g. during an async data fetch) -->\n <div v-if=\"loading\" class=\"eg-loading-overlay\">\n <template v-if=\"columns && columns.length\">\n <div class=\"eg-skeleton\">\n <div class=\"eg-skeleton-row eg-skeleton-row-header\" :style=\"{ height: headerRowHeight + 'px' }\">\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-h-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n <div\n v-for=\"r in 20\"\n :key=\"'sk-r-' + r\"\n class=\"eg-skeleton-row\"\n :style=\"{ height: _rowH + 'px' }\"\n >\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-c-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n </div>\n </template>\n <template v-else>\n <div class=\"eg-loading-spinner\"></div>\n </template>\n </div>\n </div><!-- end container -->\n\n <!-- Import progress overlay -->\n <div v-if=\"importState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl import qilinmoqda</div>\n <div class=\"eg-import-bar-wrap\">\n <div class=\"eg-import-bar\" :style=\"{ width: importState.percent + '%' }\"></div>\n </div>\n <div class=\"eg-import-msg\">{{ importState.message }}</div>\n <div class=\"eg-import-pct\">{{ importState.percent }}%</div>\n </div>\n </div>\n\n <!-- Export progress overlay -->\n <div v-if=\"exportState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl export qilinmoqda...</div>\n <div class=\"eg-export-spinner\"></div>\n </div>\n </div>\n\n <!-- Local sync modal (localSyncKey feature) -->\n <eg-local-sync-modal\n v-if=\"localSyncKey\"\n :visible=\"localSyncModal.visible\"\n :grid-options=\"_selfComponentDef\"\n :columns=\"_localSyncPreviewColumns\"\n :versions=\"localSyncModal.versions\"\n :selected-id=\"localSyncModal.selectedId\"\n :selected-version=\"localSyncModal.selectedVersion\"\n :locale=\"locale\"\n :theme=\"internalTheme\"\n @close=\"closeLocalSyncModal\"\n @select=\"_selectLocalSyncModalVersion\"\n @save=\"_onLocalSyncModalSave\"\n @restore=\"restoreLocalVersion\"\n @clear=\"_onLocalSyncModalDelete\"\n />\n\n <!-- Lock/unlock state feedback — brief centered icon flash -->\n <div v-if=\"lockFlashVisible\" class=\"eg-lock-flash\" aria-hidden=\"true\">\n <span\n ref=\"lockFlashIconEl\"\n class=\"eg-lock-flash-icon\"\n :class=\"lockFlashLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"\n ></span>\n </div>\n\n <!-- Select dropdown overlay (flat select) -->\n <eg-select\n v-if=\"selectDropdown && !selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n <!-- Tree select dropdown overlay -->\n <eg-tree-select\n v-if=\"selectDropdown && selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :children-key=\"selectDropdown.keys.childrenKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n\n <!-- Date picker overlay -->\n <eg-date-picker\n v-if=\"datePickerOverlay\"\n ref=\"datePickerComp\"\n :value=\"datePickerOverlay.value\"\n :format=\"datePickerOverlay.format\"\n :locale=\"locale\"\n :trigger-rect=\"datePickerOverlay.rect\"\n @pick=\"onDatePick\"\n @typing=\"onDateTyping\"\n @close=\"closeDatePicker\"\n />\n\n <!-- Validation error tooltip (cellValidator) -->\n <div\n v-if=\"invalidCell && invalidCell.rect\"\n class=\"eg-validation-tooltip\"\n :style=\"invalidTooltipStyle\"\n >{{ invalidCell.message }}</div>\n\n <!-- Column drag ghost — a header-shaped copy of the grabbed column that follows\n the cursor with the original grab point preserved (position updated via\n direct DOM in handleColDragMove) -->\n <div\n v-if=\"colDrag.active\"\n ref=\"colDragGhost\"\n class=\"eg-col-drag-ghost\"\n :style=\"{\n left: (colDrag.startX - colDrag.grabOffsetX) + 'px',\n top: (colDrag.startY - 14) + 'px',\n width: colDrag.ghostWidth + 'px',\n height: _headerRowH + 'px'\n }\"\n >{{ colDrag.ghostLabel }}</div>\n\n </div>\n </div>\n</template>\n\n<script>\nimport {\n normalizeRange, isCellInRange, isCellInAnyRange,\n buildRange, singleCellRange, getPrimaryRange\n} from '../utils/selection.js'\nimport { flattenColumns, buildHeaderRows, columnTreeDepth, moveColumnNodeInTree } from '../utils/headerTree.js'\nimport { calcVisibleRows, buildCumulativeHeights, findVisibleRange, getRowTop, getRowHeight } from '../utils/virtualScroll.js'\nimport { generateFillValues } from '../utils/fillHandle.js'\nimport {\n matrixToTSV, tsvToMatrix, extractMatrix, transposeMatrix, applyMatrix, tileMatrix\n} from '../utils/clipboard.js'\nimport {\n mergeHasConflict, findMergeAtCell, isMergeAnchor, addMerge,\n removeMergeAt, resolveMergeAnchor\n} from '../utils/merge.js'\nimport {\n addGroup, removeGroup, toggleGroupCollapse, isGroupCollapsed as _isGroupCollapsed,\n getCollapsedRowIndices, getCollapsedColIndices\n} from '../utils/outline.js'\nimport { exportToExcel as _exportToExcel } from '../utils/excelExport.js'\nimport { buildPrintTableHtml } from '../utils/printTable.js'\nimport { importFromFile as _importFromFile } from '../utils/excelImport.js'\nimport { createExcelImportWorker, createExcelExportWorker } from '../utils/workerFactory'\nimport { getMessages } from '../utils/i18n.js'\nimport EgCheckbox from './EgCheckbox.vue'\nimport anime from 'animejs'\nimport EgSelect from './EgSelect.vue'\nimport EgTreeSelect from './EgTreeSelect.vue'\nimport EgDatePicker from './EgDatePicker.vue'\nimport EgLocalSyncModal from './EgLocalSyncModal.vue'\nimport { parseDateValue, formatDate, DEFAULT_DATE_FORMAT } from '../utils/dateUtils.js'\nimport { formatNumber as _formatNumber } from '../utils/numberFormat.js'\nimport { Mask, MaskInput } from '../utils/mask.js'\nimport { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'\nimport { hashRows, hashRowsAsync } from '../utils/dataHash.js'\n\nconst SIZE_CONFIG = {\n small: { row: 22, header: 24, footer: 24, filter: 26 },\n middle: { row: 28, header: 28, footer: 28, filter: 32 },\n large: { row: 36, header: 36, footer: 36, filter: 40 }\n}\nconst ROW_HEADER_WIDTH = 50\nconst DEFAULT_COL_WIDTH = 100\nconst OUTLINE_GUTTER = 20\nconst FIT_COLUMNS_MIN_WIDTH = 40\nconst ROW_LIST_EDGE_PAD = 8 // birinchi/oxirgi qator bilan tepa/pastki border orasidagi bo'sh joy (px)\nconst FILL_DRAG_EDGE_ZONE = 24 // px from .eg-scroll-body's edge that triggers continuous auto-scroll while fill-dragging\nconst FILL_DRAG_MAX_SPEED = 18 // px/frame, at the very edge (scales down to 0 at the zone's outer boundary)\n\n// Classifies a col.width spec so resolveColWidth() knows how to turn it into a\n// concrete px number. Plain numbers/px strings resolve instantly with no DOM\n// work; '%' resolves via arithmetic against the grid's own measured viewport\n// width; any other CSS length (em, rem, vw, calc(), ...) needs a DOM probe.\nfunction parseColWidthSpec(spec) {\n if (typeof spec === 'number' && !isNaN(spec)) return { type: 'px', value: spec }\n if (typeof spec === 'string') {\n const trimmed = spec.trim()\n if (/^\\d+(\\.\\d+)?$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?px$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?%$/.test(trimmed)) return { type: 'percent', value: parseFloat(trimmed) }\n if (trimmed) return { type: 'css', value: trimmed }\n }\n return { type: 'px', value: DEFAULT_COL_WIDTH }\n}\n// Default toolbar tool list when `toolbar` is `true` or an object without\n// its own `list` — every tool group. Like `size`, the theme picker mirrors\n// its own `internalTheme` state and works standalone; `:theme.sync` is only\n// needed if the host wants to read the current theme back, not a prerequisite\n// for the picker to function.\nconst DEFAULT_TOOLBAR_LIST = ['undoRedo', 'export', 'import', 'print', 'localSync', 'size', 'lock', 'fullscreen', 'theme']\n// Column drag live-preview transition (AG Grid-like feel). Native CSS transitions\n// retarget smoothly from whatever intermediate position an element is currently\n// at when the drop slot changes mid-flight — no JS animation library races.\nconst COL_DRAG_TRANSITION = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)'\nconst THEME_LIST = ['excel', 'material', 'antd', 'fluent', 'carbon']\n\n// Kept as a named const (rather than an inline `export default {}`) so the\n// local-sync preview modal can receive the plain component definition\n// itself as a prop — see `_selfComponentDef` below. Importing ExcelGrid.vue\n// from EgLocalSyncModal.vue to nest a grid inside it would be a circular\n// import (ExcelGrid.vue -> EgLocalSyncModal.vue -> ExcelGrid.vue); passing\n// this reference down avoids that entirely, and avoids passing `this.$options`\n// (an instance's merged/runtime-contaminated options, not a clean definition).\nconst ExcelGridComponent = {\n name: 'ExcelGrid',\n\n components: { EgCheckbox, EgSelect, EgTreeSelect, EgDatePicker, EgLocalSyncModal },\n\n // v-model → :data + @input\n model: { prop: 'data', event: 'input' },\n\n props: {\n /** Array of row objects keyed by columns[].key */\n data: { type: Array, default: () => [] },\n\n /**\n * Column definitions (may be nested via children[]).\n *\n * `width` accepts anything the CSS `width` property accepts, not just a\n * raw px number:\n * - `Number` (e.g. `150`) or `'150px'` — fixed pixel width (fast path, no\n * DOM measurement).\n * - `'20%'` — relative to the grid's own rendered viewport width. Stays\n * responsive: recalculated whenever the grid's container is resized,\n * exactly like a real CSS percentage — until the column is manually\n * drag-resized (or auto-fit), at which point it becomes a fixed px\n * override and stops rescaling.\n * - Any other CSS length (`'10em'`, `'5rem'`, `'20vw'`, `calc(...)`, ...)\n * — resolved once via a hidden probe element measured against the\n * grid's own font-size/layout. `'auto'`/`'fit-content'` have no\n * intrinsic width this way and fall back to the 100px default; use the\n * `autoWidth` prop or double-click auto-fit for real content-fit sizing.\n *\n * `mask` — input mask for the plain-text editable input (engine adapted\n * from the `maska` library, vendored in `src/utils/mask.js`), applied\n * while typing and to every other value-entry path (paste, fill handle,\n * custom edit slots). Ignored by `select`/`treeSelect`/`date`/`checkbox`/\n * `action` columns, which use their own pickers. The masked/formatted\n * text is what gets written to `data` — display, storage, and export\n * all see the same string.\n * - `String` — e.g. `'(##) ###-##-##'` (`#` digit, `@` letter, `*`\n * alphanumeric by default). Use non-digit separators (spaces, dashes,\n * parens, letters) between token runs — a literal made of digits\n * (e.g. `'998##'`) can misalign against a raw/pasted value whose own\n * digits happen to coincide with the literal's; type the value through\n * the live cell editor or keep the fixed prefix outside the mask\n * (as static UI, e.g. a `#prefix-{key}` slot) if that's a concern.\n * - `Array<String>` — dynamic mask, picked by input length, e.g.\n * `['#-#', '##-##']`.\n * - `Object` — full maska options passthrough (`{ mask, tokens, eager,\n * reversed, ... }`) for custom tokens or advanced behavior.\n *\n * @example\n * columns: [\n * { key: 'id', title: '#', width: 60 },\n * { key: 'name', title: 'Name', width: '30%' },\n * { key: 'note', title: 'Note', width: '12em' },\n * { key: 'phone', title: 'Phone', mask: '(##) ###-##-##' }\n * ]\n */\n columns: { type: Array, default: () => [] },\n\n /** Indices of hidden leaf columns */\n hiddenColumns: { type: Array, default: () => [] },\n\n /** Indices of hidden rows */\n hiddenRows: { type: Array, default: () => [] },\n\n /**\n * Dynamic row visibility: (row, rowIndex) => boolean. Return false to\n * hide that row — same visual effect as `hiddenRows`/collapsed groups\n * (row is not rendered, but stays in `data`; nothing is deleted). Feeds\n * the same unification point as every other row-hiding mechanism, so it\n * automatically applies to virtual scroll, keyboard navigation, and\n * Excel export with no extra wiring.\n *\n * @example\n * // Hide inactive rows\n * <excel-grid :row-visible=\"(row) => row.status !== 'inactive'\" />\n * @example\n * // Hide every other row (by position, not by content)\n * <excel-grid :row-visible=\"(row, rowIndex) => rowIndex % 2 === 0\" />\n */\n rowVisible: { type: Function, default: null },\n\n /** Initial merge descriptors [{ startRow, startCol, endRow, endCol }] */\n merges: { type: Array, default: () => [] },\n\n /** Collapsed group descriptors [{ type, startIndex, endIndex }] */\n collapsedGroups: { type: Array, default: () => [] },\n\n /**\n * Height of the grid container in px.\n *\n * Omit it (or pass `null`) for auto-height: the grid sizes itself to fit\n * the header + filter row + all rows + footer for the current `data`\n * length, capped at 70% of the viewport height (`window.innerHeight`) —\n * beyond that it stops growing and scrolls internally, same as a fixed\n * `height` would. Recomputes reactively as `data` grows/shrinks and on\n * window resize. Does not apply in fullscreen mode (already sized to the\n * viewport there) and does not include the toolbar's own height.\n *\n * Pass an explicit `Number` to opt back into the old fixed-height behavior.\n */\n height: { type: Number, default: null },\n\n /**\n * Width of the grid container (px or a CSS length string).\n * Only applies while `widthMode` is `'full'` — in `'auto'` mode the grid\n * sizes itself to its columns and this prop is ignored.\n */\n width: { type: [Number, String], default: '100%' },\n\n /**\n * How the grid decides its overall width.\n *\n * - `'full'` (default) — the grid takes the `width` prop's value\n * (default `100%` of the host container), regardless of how little\n * space its columns actually need. Current/legacy behavior.\n * - `'auto'` — the grid hugs its content (max-content): its width is\n * the row-number gutter + the sum of all visible column widths\n * (live: reacts to column resize, hide/show, and `col.visible`),\n * plus the vertical scrollbar's width when rows overflow the height.\n * Capped at `max-width: 100%` — when the columns are wider than the\n * host, the grid stops at the host's edge and scrolls horizontally\n * inside, exactly like `'full'`. The `width` prop is ignored.\n *\n * Fullscreen mode always fills the viewport, whatever this is set to.\n *\n * @example\n * <excel-grid v-model=\"rows\" :columns=\"columns\" width-mode=\"auto\" />\n */\n widthMode: {\n type: String,\n default: 'full',\n validator: (v) => ['full', 'auto'].includes(v)\n },\n\n /**\n * Called before drag-drop: (sourceRange, targetRange) => bool\n * Return false to cancel the drop.\n */\n beforeDrop: { type: Function, default: null },\n\n /**\n * UI locale for the context menu.\n * Supported: 'en' | 'ru' | 'uz' | 'uz-Cyrl'\n */\n locale: { type: String, default: 'en' },\n\n /**\n * Pinned columns map: { [colKey]: 'left' | 'right' }\n * Pinned-left columns render as a fixed zone on the left.\n * Pinned-right columns render as a fixed zone on the right.\n * Emit 'update:pinnedColumns' to support .sync modifier.\n */\n pinnedColumns: { type: Object, default: () => ({}) },\n\n /**\n * Dynamic cell editability: (row, rowIndex, col, colIndex) => boolean\n * Overrides column-level editable when provided.\n * row = plain data object, col = column definition.\n */\n cellEditable: { type: Function, default: null },\n\n /**\n * Cell value validation: (value, row, rowIndex, col, colIndex) => boolean\n * Return false to reject the write — the cell keeps its old value and\n * an error tooltip is shown anchored to that cell.\n */\n cellValidator: { type: Function, default: null },\n\n /**\n * Error tooltip text for a rejected value.\n * Function: (value, row, rowIndex, col, colIndex) => string\n * String: same message for every rejected cell.\n * Default: locale's \"invalidValue\" message.\n */\n cellValidationMessage: { type: [Function, String], default: null },\n\n /**\n * Bulk-write strictness (paste / fill handle / Delete).\n * false (default): invalid cells in the affected range are skipped,\n * the rest of the operation still applies.\n * true: if ANY cell in the affected range fails validation, the whole\n * bulk operation is rejected — nothing is written.\n */\n strictBulkValidation: { type: Boolean, default: false },\n\n /**\n * Paste auto-grows rows: false (default) — pasting a clipboard matrix\n * taller than the remaining rows below the paste target clips to the\n * existing rows, same as before. true — missing rows are appended to\n * `data` to fit the full pasted range (Excel/Sheets-style). New rows'\n * columns not covered by the pasted matrix get a type-aware blank value\n * (`type: 'checkbox'` → false, everything else → ''). Columns are never\n * auto-created — pasting more columns than exist still clips.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows />\n */\n pasteGrowsRows: { type: Boolean, default: false },\n\n /**\n * Extra fields merged into rows auto-created by pasteGrowsRows, beyond\n * the type-aware column blanks. Rows only ever get values for `columns`\n * keys — anything else your app tracks per row (an id, a visibility\n * flag `rowVisible` depends on, a nested list, etc.) is NOT set unless\n * provided here, since the grid has no way to know about it.\n * Object: same fields merged into every new row.\n * Function(newRowIndex) => Object: computed per new row (e.g. a fresh\n * uuid per row). `newRowIndex` is the row's index in the resulting\n * `data` array.\n * Applied before the pasted values are written, so pasted cells always\n * win over any column-key present here.\n * For a single aggregate action after ALL rows from one paste have been\n * created and written to v-model, listen for the `rows-added` event\n * instead of doing it here (this runs once per row, before the commit).\n * @type {Object|Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows\n * :paste-row-defaults=\"() => ({ uuid: uuidv4(), state: 1 })\" />\n */\n pasteRowDefaults: { type: [Object, Function], default: null },\n\n /** Visual size of the grid. Affects row/header height and font sizes. */\n size: { type: String, default: 'middle' },\n\n /** Auto-fit all column widths to content (same as double-clicking each resize handle). Re-applies on data/columns change. */\n autoWidth: { type: Boolean, default: false },\n\n /**\n * Forces every visible leaf column to fit within a single viewport —\n * no horizontal scroll. Column widths are still resolved (`col.width`,\n * `%`/`px`/other CSS lengths, `DEFAULT_COL_WIDTH` when omitted) but only\n * as RELATIVE WEIGHTS: all resolved widths are scaled by one shared\n * factor so their sum exactly fills the available width, keeping each\n * column's proportion to the others. Each column still gets at least\n * `40px` — if there are enough columns that even that minimum doesn't\n * fit, horizontal scroll returns for that (rare) case rather than\n * shrinking columns illegibly thin.\n *\n * Highest-priority width control: overrides `col.width` as an absolute\n * value and `autoWidth`. Manual column resize (drag handle) and\n * double-click auto-fit ARE both allowed — either one turns that column\n * into a fixed px width, excluded from the fit; the rest keep scaling\n * proportionally to fill what's left, which can reintroduce horizontal\n * scroll if the fixed column(s) alone exceed the viewport. Pinned\n * columns count toward the fit too — the goal is the whole row fitting,\n * regardless of pin state.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" fit-columns />\n */\n fitColumns: { type: Boolean, default: false },\n\n /**\n * Allow columns to be reordered by dragging their headers.\n * On drop, emits 'update:columns' (compatible with .sync) and 'column-reorder'.\n */\n columnDraggable: { type: Boolean, default: true },\n\n /**\n * Controls the built-in toolbar above the grid — visibility as a whole,\n * and (optionally) which individual tool groups appear. Controlled\n * entirely by the parent — no in-component toggle for the bar itself.\n *\n * @type {Boolean|{visible?: Boolean, list?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>, disabled?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>}}\n * @default false\n *\n * Two forms:\n * - `Boolean` — `false` (default) hides the toolbar entirely; `true`\n * shows it with the default tool list — every group, including\n * `'theme'` (it mirrors its own internal state like `size` does;\n * `:theme.sync` is optional, only needed if the parent wants to read\n * the current theme back).\n * - `Object { visible, list, disabled }` — fine-grained control:\n * - `visible` {Boolean} — defaults to `true` when an object is given\n * (passing an object is itself an opt-in to show the toolbar);\n * set explicitly to `false` to hide it while still describing a\n * `list` you intend to reuse later.\n * - `list` {Array<String>} — which tool groups render, using the\n * identifiers above. Unknown/misspelled strings are silently\n * ignored. Omitting `list` falls back to the same default set as\n * the `true` shorthand. **Order in `list` does not change the\n * toolbar's layout** — groups always render in a fixed position\n * (undo/redo → export/import/print → #toolbar-extra slot → toolbarButtons\n * → spacer → theme → size → lock/fullscreen); `list` is purely an\n * inclusion filter.\n * - `disabled` {Array<String>} — which tool groups render grayed out\n * and unclickable while staying **visible** (same identifiers as\n * `list`) — distinct from `list`, which removes a tool entirely.\n * Scoped to the toolbar button itself: it doesn't block the\n * underlying keyboard shortcut (only `'undoRedo'` has one —\n * Ctrl+Z/Ctrl+Y) or a ref method call (`undo()`, `exportToExcel()`,\n * etc.), matching how `list` also only hides the button, not the\n * shortcut/method. Omitting `disabled` disables nothing (default).\n *\n * `toolbarButtons` and the `#toolbar-extra` slot are independent of\n * this prop — they render whenever content is provided, regardless of\n * `list`/`disabled`.\n *\n * @example\n * // Whole default set\n * <excel-grid toolbar />\n * @example\n * // Only undo/redo and fullscreen\n * <excel-grid :toolbar=\"{ list: ['undoRedo', 'fullscreen'] }\" />\n * @example\n * // Toggle visibility from a data property while keeping a fixed list\n * <excel-grid :toolbar=\"{ visible: showToolbar, list: ['export', 'import'] }\" />\n * @example\n * // Visible but grayed out — e.g. import disabled while a prior import is processing\n * <excel-grid :toolbar=\"{ disabled: ['import', 'size'] }\" />\n * @example\n * // Read the current theme back (optional — the picker works without it too)\n * <excel-grid toolbar :theme.sync=\"myTheme\" />\n */\n toolbar: { type: [Boolean, Object], default: false },\n\n /** Extra custom toolbar buttons: [{ key, icon, title, onClick(event, gridApi) }] */\n toolbarButtons: { type: Array, default: () => [] },\n\n /**\n * Print (`printGrid()`/toolbar \"Print\" tugmasi) chiqishida jadval tepasida,\n * markazda ko'rinadigan sarlavha. `null` (standart) — sarlavha umuman\n * render qilinmaydi.\n * @property {String} title - sarlavha matni (bo'sh/undefined bo'lsa render qilinmaydi)\n * @property {Object} [style] - Vue :style uslubidagi obyekt (camelCase),\n * standart markazlashtirilgan/qalin uslub USTIGA qo'shiladi\n * @property {String} [class] - qo'shimcha CSS klass nomi(lari)\n * @example\n * <excel-grid :print-title=\"{ title: 'Talabalar ro\\'yxati', style: { color: '#1a73e8' } }\" />\n */\n printTitle: { type: Object, default: null },\n\n /**\n * Locks the entire grid: blocks all edits (typing, paste, fill, delete,\n * checkbox, select/treeSelect) and structural changes (resize, column\n * reorder, merge/unmerge, undo/redo). Independent from cellEditable/\n * col.editable, which remain per-cell rules. Emit 'update:locked' to\n * support the .sync modifier.\n */\n locked: { type: Boolean, default: false },\n\n /**\n * Host-driven loading state (e.g. while an async data fetch is in\n * flight). When `true`, only the grid body (`.eg-container`) is\n * covered — the toolbar stays visible and interactive. If `columns`\n * is non-empty, a shimmering skeleton matching each column's width is\n * shown; otherwise a plain centered spinner is shown. Purely visual —\n * doesn't block edits/interaction the way `locked` does (combine both\n * if that's also needed).\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :loading=\"isFetching\" />\n */\n loading: { type: Boolean, default: false },\n\n /**\n * Data-driven per-row styling: `(row, rowIndex) => styleObject`. The\n * returned style object is applied to every cell in that row (across\n * the frozen/scrollable/pinned-right zones alike), giving the row a\n * continuous horizontal background/style without any host-side CSS.\n * Selection/active/fill-preview/readonly cell highlighting keeps\n * winning visually — those already use `!important`, which always\n * beats a plain (non-`!important`) inline style. Doesn't affect the\n * row-number header column (mirrors Excel: conditional formatting\n * colors data cells, not the row gutter). Return `null`/`undefined`\n * for rows that shouldn't get a custom style.\n * @type {Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :row-style=\"(row) => row.status === 'error' ? { background: '#fde2e2' } : null\" />\n */\n rowStyle: { type: Function, default: null },\n\n /** Show \"Column info\" / \"Row info\" items in the context menu (dev/debug aid). */\n devMode: { type: Boolean, default: false },\n\n /**\n * Visual theme of the grid.\n * Built-in: 'excel' | 'material' | 'antd' | 'fluent' | 'carbon'\n * Default is the classic Excel 365 look. Use 'material' for Material Design 3.\n * Mirrors the `size` prop's convention: an internal `internalTheme` state\n * mirrors this prop and is what the toolbar's theme picker actually reads\n * from/writes to (via `setTheme()`), so the picker works standalone even\n * without `:theme.sync` — `.sync` is only needed to reflect the current\n * theme back into the host's own state.\n */\n theme: { type: String, default: 'excel' },\n\n /**\n * Shows a filter row directly beneath the column headers (inside the\n * sticky header area). Each leaf column gets a filter control matched to\n * its `type`:\n * - default (no type / 'date' / 'progress'): text search input,\n * matched case-insensitively against the column's *displayed* value\n * (so 'date' matches the formatted string, not the raw stored value).\n * - 'select' / 'treeSelect' *with a static `col.options` array*: a\n * native <select> populated from those options (tree options are\n * flattened, indented by depth), matched by exact raw value.\n * - 'select' / 'treeSelect' with function-based `col.options` (options\n * computed per-row): falls back to the text control, since a single\n * fixed option list can't be resolved outside a specific row.\n * - 'checkbox': a tri-state <select> — All / Checked / Unchecked.\n * - 'action': no filter control (not a data-bound column).\n *\n * Rows that don't match every active filter are hidden using the same\n * mechanism as `hiddenRows` — virtual scroll, keyboard navigation, and\n * `exportToExcel()` all skip them automatically, with no separate\n * filtering code path to keep in sync.\n *\n * Set `col.filterable: false` to omit the filter control for one column\n * (its filter cell renders empty). Set\n * `col.filterFn(cellValue, filterValue, row, rowIndex, column)` to\n * override the default match logic for that column entirely.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row />\n */\n filterRow: { type: Boolean, default: false },\n\n /**\n * Current filter values, keyed by column `key`. Optional — the filter\n * row works standalone via internal state (`internalFilters`) even\n * without `.sync`, mirroring the `size`/`theme` prop convention. Bind\n * `:filters.sync` only if the host needs to read or restore the current\n * filter state.\n * @type {Object}\n * @default {}\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row :filters.sync=\"myFilters\" />\n */\n filters: { type: Object, default: () => ({}) },\n\n /**\n * Shows a sort icon (kebab/\"more actions\" glyph) in every leaf column\n * header. Clicking it opens a popup menu — A → Z / Z → A / Clear sort —\n * matched to Excel's own column-header dropdown. Picking a different\n * column's option replaces the active sort — single-column sort only.\n *\n * Sorting **physically reorders** the `data` array (like a real\n * spreadsheet sort) and emits it through the normal v-model/`input`\n * event — it is recorded in the undo/redo history exactly like any\n * other data edit, so `undo()` restores the pre-sort row order.\n * \"Clear sort\" (or `clearSort()`) both clears the indicator **and**\n * restores the row order from immediately before the first sort of\n * this session — switching columns or direction in between doesn't\n * move that reference point. If rows were added/removed since sorting\n * (row count no longer matches), the restore is skipped and only the\n * indicator clears, to avoid silently dropping/duplicating rows.\n *\n * Comparison is matched to the column's `type`: plain/`'progress'`\n * columns compare the raw value (numeric-aware); `'date'` compares the\n * parsed date chronologically, not the formatted string; `'select'` /\n * `'treeSelect'` compare the displayed option label; `'checkbox'`\n * compares the boolean state. Empty/`null` values always sort last,\n * regardless of direction. `'action'` columns are never sortable.\n *\n * Set `col.sortable: false` to omit the icon for one column. Set\n * `col.sortFn(rowA, rowB, column) => number` to fully override the\n * comparison for that column.\n *\n * Blocked (no-op) while the grid is `locked`, or while any merged\n * cells or row outline groups exist — both are anchored to row\n * *position*, which a reorder would silently invalidate.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable />\n */\n sortable: { type: Boolean, default: false },\n\n /**\n * Current sort state — `{ key, direction }` (`direction` is `'asc'` or\n * `'desc'`) or `null` when unsorted. Optional — sorting works standalone\n * via internal state (`internalSort`) even without `.sync`, mirroring\n * the `size`/`theme`/`filters` prop convention. Bind `:sort.sync` only\n * if the host needs to read or restore the current sort state.\n * @type {Object}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable :sort.sync=\"mySort\" />\n */\n sort: { type: Object, default: null },\n\n /**\n * Unique key that opts the grid into local version history: when the page\n * is being left (tab hidden/closed, or this component unmounted — NOT on\n * every edit), the current `v-model` is content-hashed (via a Web Worker\n * when available, so the main thread stays responsive right as the page\n * may be unloading) and saved to the browser's IndexedDB as a **version**,\n * keyed by this string. If the hash matches an already-saved version for\n * this key, that version's timestamp is refreshed instead of creating a\n * duplicate — only genuinely different content produces a new version.\n * Also adds a toolbar button ('localSync' — see the `toolbar` prop's\n * `list`/`disabled`) that opens a modal listing saved versions, each with\n * a read-only preview and **Restore** / **Delete** actions, plus a\n * **Save** button for an explicit checkpoint.\n *\n * Two different grid instances must use different keys — the same key\n * means \"the same version history\" (concurrent tabs interleave). Omit\n * this prop entirely to opt out completely: no IndexedDB access happens\n * at all, and the toolbar button never renders.\n *\n * See also `localSyncMaxVersions`, the ref-callable methods\n * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,\n * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,\n * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the\n * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`\n * events — [details](/local-sync).\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" toolbar />\n */\n localSyncKey: { type: String, default: null },\n\n /**\n * Optional tenant/organization identifier for multi-tenant hosts (SaaS\n * apps where the same browser/device is used across different\n * organizations). When set, it's combined with `localSyncKey`\n * internally (`${tenant}::${localSyncKey}`) so each tenant gets a fully\n * isolated IndexedDB version history — switching tenants on the same\n * device never shows another tenant's saved drafts. Omit it (default)\n * for single-tenant hosts; behavior is then identical to before this\n * prop existed.\n *\n * Has no effect without `localSyncKey` — it only changes how that key\n * is namespaced internally, it doesn't opt into the feature by itself.\n * Reactive: changing `tenant` (e.g. after an async \"current\n * organization\" load) reloads that tenant's own version list/buffer,\n * the same way a `localSyncKey` that starts empty and becomes truthy\n * later does. Avoid `::` inside `tenant`/`localSyncKey` values\n * themselves — it's the internal separator between the two.\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :tenant=\"currentOrgId\" toolbar />\n */\n tenant: { type: String, default: null },\n\n /**\n * Maximum number of local versions kept per `localSyncKey` (per\n * `tenant`, when set). Once a new version pushes the count past this,\n * the oldest version(s) (by `createdAt`) are deleted automatically\n * (FIFO). Has no effect without `localSyncKey`.\n * @type {Number}\n * @default 10\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :local-sync-max-versions=\"20\" />\n */\n localSyncMaxVersions: { type: Number, default: 10 }\n },\n\n data() {\n return {\n // Selection\n activeCell: { row: 0, col: 0 },\n selectionRanges: [], // [{ startRow, startCol, endRow, endCol }]\n isMouseDown: false,\n mouseDownRange: null,\n isCtrlDrag: false,\n\n // Edit\n editingCell: null, // { row, col }\n editValue: '',\n editOriginalValue: '',\n\n // Scroll\n scrollTop: 0,\n scrollLeft: 0,\n headerViewportWidth: null,\n // The grid's DOM parent's width — the reference for relative (%, css)\n // column widths, in BOTH widthMode values (see _relativeWidthContainer).\n // In 'auto' mode the shell's width is DERIVED from column widths, so\n // resolving a relative column width against the shell's own measured\n // viewport (headerViewportWidth) is circular (each resolve nudges the\n // shell size, which nudges the next resolve...). 'full' mode isn't\n // circular that way, but headerViewportWidth (scrollBody.clientWidth)\n // turned out to have its OWN instability source: a vertical scrollbar\n // toggling on/off flips it by the scrollbar's width even though the\n // shell's own outer size never changes — confirmed via a real host\n // report (repeated brief re-render bursts hurting CLS) that 'auto'\n // mode didn't reproduce. The parent's width doesn't depend on the\n // grid's own rendered size OR its internal scrollbar state, so using\n // it everywhere sidesteps both instability sources uniformly.\n hostContainerWidth: null,\n // Real vertical-scrollbar width measured off the scroll body (0 for\n // macOS overlay scrollbars) — widthMode:'auto' adds it to the natural\n // width so the scrollbar never eats into the last column.\n vScrollbarW: 0,\n // .eg-shell's horizontal border total (border-box). Measured at mount —\n // themes can restyle it — so widthMode:'auto' accounts for it instead\n // of shaving those pixels off the last column.\n shellBorderW: 2,\n\n // Auto-height (height prop omitted) — 70vh cap, kept in sync on resize\n windowInnerH: typeof window !== 'undefined' ? window.innerHeight : 800,\n\n // Virtual scroll\n virtualStartIndex: 0,\n virtualEndIndex: 0,\n virtualOffsetTop: 0,\n totalVirtualHeight: 0,\n\n // Column widths (flat index → px)\n colWidths: {},\n\n // Flat index → raw CSS width spec, for columns whose width is a relative\n // unit ('20%', '10em', ...) and therefore needs re-resolving whenever the\n // grid's viewport width changes. Cleared for a column once it's manually\n // resized/auto-fit — see startColResize/autoFitColumn/_applyAutoWidth.\n relativeColSpecs: {},\n\n // col.key → true, for columns manually resized while fitColumns is on.\n // Such a column becomes a fixed px width (excluded from the proportional\n // fit); the rest keep scaling among themselves to fill what's left — see\n // _applyFitColumns(). Keyed by col.key (not flat index) so it survives\n // column reorders, same convention as relativeColSpecs preservation.\n fitColumnsOverrides: {},\n\n // Row heights (row index → px)\n rowHeights: {},\n\n // Import/export progress\n importState: { active: false, percent: 0, message: '' },\n exportState: { active: false },\n\n // localSyncKey feature — modal state: the version list (metadata only)\n // plus which one is selected/previewed. `selectedVersion` holds that\n // version's full { id, data, createdAt, updatedAt } for the nested-grid\n // preview (see EgLocalSyncModal.vue).\n localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },\n // True when one or more versions already existed at mount and the user\n // hasn't opened the modal yet — drives the toolbar button's \"unseen\n // local versions\" dot. See _loadLocalVersionsOnMount() for the full lifecycle.\n localSyncAvailable: false,\n\n resizing: {\n active: false,\n type: null, // 'col' | 'row'\n index: -1,\n startX: 0,\n startY: 0,\n startSize: 0,\n currentPos: 0\n },\n\n // Fill handle\n fillDrag: {\n active: false,\n sourceRange: null,\n currentRow: -1,\n currentCol: -1\n },\n\n // Drag-move\n dragMove: {\n active: false,\n sourceRange: null,\n startX: 0,\n startY: 0,\n targetRow: -1,\n targetCol: -1,\n copyMode: false\n },\n\n // Clipboard (internal)\n clipboardMatrix: null,\n clipboardRange: null,\n clipboardMode: null, // 'copy' | 'cut'\n\n // Context menu\n contextMenu: { visible: false, x: 0, y: 0, row: -1, col: -1 },\n // false while the menu's real size hasn't been measured yet — kept hidden\n // (not painted) so the flip-to-fit repositioning in _positionContextMenu()\n // doesn't cause a visible jump.\n contextMenuPositioned: false,\n\n // Sort popup menu (header sort icon click) — { visible, x, y, key }\n sortMenu: { visible: false, x: 0, y: 0, key: null },\n sortMenuPositioned: false,\n\n // devMode — column/row config info modal ({ title, json } | null)\n infoModal: null,\n\n // Select dropdown\n selectDropdown: null, // { row, col, options, currentValue, rect }\n\n // Date picker overlay\n datePickerOverlay: null, // { row, col, format, value, rect }\n\n // Cell value validation (cellValidator)\n invalidCell: null, // { row, col, message, rect }\n\n // Merges (controlled via prop + update:merges)\n internalMerges: [],\n\n // Toolbar (controlled via prop + update:locked / update:size / update:theme)\n internalLocked: false,\n internalSize: 'middle',\n internalTheme: 'excel',\n // Pinned columns — merges col.pin (columns config default) with pinnedColumns prop.\n // context-menu pin/unpin updates this directly so .sync is not required.\n internalPinnedColumns: {},\n // Brief centered icon feedback whenever the lock state actually changes\n // (toolbar click, toggleLock(), or a host-driven :locked.sync change).\n lockFlashVisible: false,\n lockFlashLocked: false,\n isFullscreen: false,\n // Bumped whenever _history/_historyIndex change so canUndo/canRedo stay reactive.\n historyVersion: 0,\n\n // Groups\n internalGroups: [], // [{ type, startIndex, endIndex }]\n internalCollapsedGroups: [],\n\n // Header\n headerDepth: 1,\n headerRows: [],\n\n // Leaf columns cache\n leafCols: [],\n\n // Footer column totals (flat col index → number | null)\n columnTotals: {},\n\n // Filter row (controlled via prop + update:filters)\n internalFilters: {},\n // Row indices currently hidden by an active filter — merged into\n // hiddenRowSet, so virtual scroll/export/navigation need no separate path.\n filteredRowIndices: [],\n\n // Sort (controlled via prop + update:sort) — { key, direction } | null\n internalSort: null,\n\n // Column drag-and-drop state\n colDrag: {\n pending: false, // mousedown received, waiting for movement threshold\n active: false, // drag in progress\n sourceFirstLeaf: -1, // first leaf flat-index of the dragged node\n sourceLeafCount: 1, // number of leaves in the dragged node (1=leaf, N=group)\n dropIndex: -1, // insert-before flat index (0..N)\n startX: 0,\n startY: 0,\n ghostLabel: '',\n ghostWidth: 0, // ghost mirrors the grabbed header's real width (capped)\n grabOffsetX: 0 // cursor offset inside the grabbed header — keeps the grab point under the cursor\n },\n }\n },\n\n computed: {\n t() { return getMessages(this.locale) },\n\n // localSyncKey feature — the actual IndexedDB storage key. idbStore.js\n // itself knows nothing about \"tenants\"; this is the one place that\n // namespaces localSyncKey by tenant when one is given, so every\n // storage-layer call site below just uses this instead of\n // this.localSyncKey directly. null whenever localSyncKey itself is\n // unset — tenant alone doesn't opt into the feature.\n effectiveSyncKey() {\n if (!this.localSyncKey) return null\n return this.tenant ? `${this.tenant}::${this.localSyncKey}` : this.localSyncKey\n },\n\n pinnedLeftSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'left') s.add(col._flatIndex) })\n return s\n },\n\n pinnedRightSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'right') s.add(col._flatIndex) })\n return s\n },\n\n pinnedLeftCols() { return this.leafCols.filter(col => this.pinnedLeftSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n pinnedRightCols() { return this.leafCols.filter(col => this.pinnedRightSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n\n hasPinnedLeft() { return this.pinnedLeftSet.size > 0 },\n hasPinnedRight() { return this.pinnedRightSet.size > 0 },\n\n hasFooter() { return this.leafCols.some(col => this.isColumnTotalVisible(col)) },\n\n pinnedLeftWidth() {\n let w = 0\n for (const col of this.pinnedLeftCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n pinnedRightWidth() {\n let w = 0\n for (const col of this.pinnedRightCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n totalRows() { return this.data.length },\n\n // See the `toolbar` prop's JSDoc for the full Boolean|Object contract.\n _toolbarVisible() {\n if (typeof this.toolbar === 'boolean') return this.toolbar\n return !!this.toolbar && this.toolbar.visible !== false\n },\n _toolbarList() {\n if (this.toolbar && Array.isArray(this.toolbar.list)) return this.toolbar.list\n return DEFAULT_TOOLBAR_LIST\n },\n // Same key set as _toolbarList, but for the disabled (grayed out, still visible) state\n // instead of visibility — see toolbar prop JSDoc.\n _toolbarDisabledList() {\n if (this.toolbar && Array.isArray(this.toolbar.disabled)) return this.toolbar.disabled\n return []\n },\n\n // Columns shown in the local-sync modal's preview table — action columns\n // are pure interactive UI (buttons), not printable/previewable data,\n // same exclusion printGrid()/exportToExcel() already apply.\n _localSyncPreviewColumns() {\n return this.leafCols.filter(col => col.type !== 'action')\n },\n\n // Passed to EgLocalSyncModal so it can render the snapshot preview as a\n // real, nested ExcelGrid instance — see the comment on ExcelGridComponent\n // above for why this is the module-level const and not `this.$options`.\n _selfComponentDef() {\n return ExcelGridComponent\n },\n\n _sizeConf() { return SIZE_CONFIG[this.internalSize] || SIZE_CONFIG.middle },\n\n sizeOptions() { return Object.keys(SIZE_CONFIG) },\n _rowH() { return this._sizeConf.row },\n _headerRowH() { return this._sizeConf.header },\n _footerRowH() { return this._sizeConf.footer },\n _filterRowH() { return this.filterRow ? this._sizeConf.filter : 0 },\n\n headerRowHeight() { return this._headerRowH },\n footerRowHeight() { return this._footerRowH },\n\n // Pre-built cumulative height array — O(n) once, then cached by Vue.\n // findVisibleRange() uses binary search against this cached array → O(log n) per scroll.\n // Invalidated only when row count, custom heights, or hidden rows change.\n cumulativeHeights() {\n return buildCumulativeHeights(this.totalRows, this._rowH, this.rowHeights, this.hiddenRowSet, ROW_LIST_EDGE_PAD, ROW_LIST_EDGE_PAD)\n },\n\n // height prop omitted → fit header + filter row + all (visible) rows + footer exactly.\n _autoContentHeight() {\n const headerH = this._headerRowH * this.headerDepth + this._filterRowH\n const bodyH = this.cumulativeHeights[this.totalRows] || 0\n const footerH = this.hasFooter ? this._footerRowH : 0\n return headerH + bodyH + footerH\n },\n\n // Resolved grid-area height: explicit `height` prop wins as-is (old\n // behavior); omitted → auto-fit content, capped at 70% of the viewport.\n effectiveHeight() {\n if (this.height != null) return this.height\n return Math.min(this._autoContentHeight, this.windowInnerH * 0.7)\n },\n\n hiddenColSet() { return new Set(this.hiddenColumns) },\n\n hiddenRowSet() {\n const s = new Set(this.hiddenRows)\n // Also add rows hidden by collapsed row groups\n const collapsed = getCollapsedRowIndices(this.internalGroups, this.internalCollapsedGroups)\n collapsed.forEach(i => s.add(i))\n // Also add rows hidden by the filter row (see filteredRowIndices/_recomputeRowFilters)\n this.filteredRowIndices.forEach(i => s.add(i))\n // Also add rows the host's rowVisible(row, rowIndex) rejects — a static/\n // dynamic rule (status, parity, ...) independent of the toggleable\n // hiddenRows list and the interactive filter row.\n if (typeof this.rowVisible === 'function') {\n const rows = this.data\n for (let i = 0; i < rows.length; i++) {\n if (this.rowVisible(rows[i], i) === false) s.add(i)\n }\n }\n return s\n },\n\n hiddenColFromGroupSet() {\n return getCollapsedColIndices(this.internalGroups, this.internalCollapsedGroups)\n },\n\n effectiveHiddenColSet() {\n const s = new Set(this.hiddenColSet)\n this.hiddenColFromGroupSet.forEach(i => s.add(i))\n // col.visible === false — a schema-level hide, independent of the\n // toggleable hiddenColumns list. \"Show All Columns\" never reveals it.\n // Boolean or Function(column, colIndex) => boolean — like rowVisible (see\n // hiddenRowSet above), the function form is called fresh on every\n // evaluation of this computed, so it stays reactive to whatever host\n // state its closure reads even though leafCols itself (built by\n // flattenColumns' `{...col}` shallow clone) is only rebuilt when the\n // columns prop reference changes. A function value survives that clone\n // unchanged (functions copy by reference), so the closure keeps running\n // fresh — no need to restructure columns into a computed just to react.\n this.leafCols.forEach(col => {\n const v = typeof col.visible === 'function' ? col.visible(col, col._flatIndex) : col.visible\n if (v === false) s.add(col._flatIndex)\n })\n return s\n },\n\n rowGroups() { return this.internalGroups.filter(g => g.type === 'row') },\n colGroups() { return this.internalGroups.filter(g => g.type === 'column') },\n\n visibleRowRange() {\n const rows = []\n for (let i = this.virtualStartIndex; i <= this.virtualEndIndex; i++) rows.push(i)\n return rows\n },\n\n primaryRange() {\n return getPrimaryRange(this.selectionRanges, this.activeCell)\n },\n\n fillHandleVisible() {\n return this.primaryRange && !this.editingCell\n },\n\n fillHandleStyle() {\n if (!this.primaryRange) return {}\n const r = normalizeRange(this.primaryRange)\n // O(1) lookup via cumulativeHeights instead of O(endRow) getRowTop loop\n const bottom = this.cumulativeHeights[r.endRow + 1] || 0\n const right = this.getColLeft(r.endCol + 1)\n return {\n position: 'absolute',\n width: '6px',\n height: '6px',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n border: '1px solid white',\n bottom: (this.totalVirtualHeight - bottom + 1) + 'px',\n right: (this.scrollContentWidth - right + 1) + 'px',\n cursor: 'crosshair',\n zIndex: 10\n }\n },\n\n\n invalidTooltipStyle() {\n if (!this.invalidCell || !this.invalidCell.rect) return {}\n const { rect } = this.invalidCell\n return {\n position: 'fixed',\n top: (rect.bottom + 4) + 'px',\n left: rect.left + 'px',\n zIndex: 9999\n }\n },\n\n dragGhostStyle() {\n if (!this.dragMove.active || this.dragMove.targetRow < 0) return {}\n const r = normalizeRange(this.dragMove.sourceRange)\n const rows = r.endRow - r.startRow\n const cols = r.endCol - r.startCol\n // O(1) lookup via cumulativeHeights instead of O(targetRow) getRowTop loop\n const top = this.cumulativeHeights[this.dragMove.targetRow] || 0\n const left = this.getColLeft(this.dragMove.targetCol)\n const width = this.getRangeWidth(this.dragMove.targetCol, this.dragMove.targetCol + cols)\n const height = this.getRangeHeight(this.dragMove.targetRow, this.dragMove.targetRow + rows)\n return {\n position: 'absolute',\n top: top + 'px',\n left: left + 'px',\n width: width + 'px',\n height: height + 'px',\n border: '2px dashed #1a73e8',\n pointerEvents: 'none',\n zIndex: 20\n }\n },\n\n // widthMode:'auto' — the grid's natural (max-content) width: row-number\n // gutter + outline gutter (when row groups exist) + every visible leaf\n // column's current width + the measured vertical scrollbar width (0 when\n // rows fit / overlay scrollbars). Fully reactive: column resize,\n // hide/show, col.visible, and group changes all re-derive it.\n naturalGridWidth() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return ROW_HEADER_WIDTH + outlineW + this.getColLeft(this.leafCols.length) + this.vScrollbarW + this.shellBorderW\n },\n\n // Reference width for resolving relative (%, css) column widths — always\n // hostContainerWidth (the grid's DOM parent), in both widthMode values.\n // See hostContainerWidth's declaration comment for why headerViewportWidth\n // (the grid's own scrollBody.clientWidth) isn't used for this in either\n // mode: circular in 'auto', and independently unstable in 'full' (a\n // vertical scrollbar toggling flips it without the shell's outer size\n // ever changing).\n _relativeWidthContainer() {\n return this.hostContainerWidth\n },\n\n // The CSS width both the shell and the wrapper use in non-fullscreen mode.\n _hostWidth() {\n if (this.widthMode === 'auto') return this.naturalGridWidth + 'px'\n return typeof this.width === 'number' ? this.width + 'px' : this.width\n },\n\n wrapperStyle() {\n if (this.isFullscreen) {\n return {\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n }\n const style = {\n width: this._hostWidth,\n height: this.effectiveHeight + 'px',\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n shellStyle() {\n if (this.isFullscreen) return {}\n const style = {\n width: this._hostWidth,\n display: 'flex',\n flexDirection: 'column'\n }\n // The cap that keeps a too-wide column set at the host's edge\n // (internal horizontal scroll takes over from there).\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n canUndo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex >= 0\n },\n\n canRedo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex < this._history.length - 1\n },\n\n headerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n position: 'sticky',\n top: 0,\n zIndex: 30,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n cornerStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: (this._headerRowH * this.headerDepth + this._filterRowH) + 'px',\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderBottom: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0\n }\n },\n\n rowOutlineGutterStyle() {\n return { width: OUTLINE_GUTTER + 'px', flexShrink: 0 }\n },\n\n frozenColHeaderStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n scrollColHeaderStyle() {\n // Once measured, lock the header to the body's exact clientWidth (flex: 0 0 <px>)\n // instead of growing independently via flex:1 — this guarantees identical\n // scrollWidth-clientWidth (max scrollLeft) on both, so syncing scrollLeft never clamps\n // one before the other. (padding-right can't do this: clientWidth always includes\n // padding, so padding never actually shrinks it.)\n //\n // The pinned zones must be subtracted from the measured clientWidth: in the\n // body they live INSIDE .eg-scroll-body as sticky overlays (so clientWidth\n // already spans them), but in the header/footer rows they are separate flex\n // siblings rendered BEFORE/AFTER this strip. Without the subtraction the\n // header/footer rows end up frozenWidth+pinnedRightWidth wider than the\n // container, giving .eg-container (overflow:auto) its own second horizontal\n // scrollbar. With it, this strip's viewport exactly matches the body's\n // scrollable window between the sticky zones, and — together with the strip\n // inner width being scrollableContentWidth — both max scrollLefts stay equal.\n // A falsy 0 (not just null/undefined) means \"not really measured yet\" — e.g. the grid\n // mounted while its container was display:none (a hidden v-show tab). Locking to '0 0 0px'\n // in that case would collapse the header/footer strip to invisible; fall back to the same\n // natural flex:1 used pre-measurement instead. The ResizeObserver in mounted() re-measures\n // once the container becomes visible, replacing this with a real px lock.\n if (this.headerViewportWidth) {\n const w = Math.max(\n 0,\n this.headerViewportWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n )\n return { flex: '0 0 ' + w + 'px', overflow: 'hidden' }\n }\n return { flex: 1, overflow: 'hidden' }\n },\n\n bodyAreaStyle() {\n // Fullscreen: let flexbox fill the viewport dynamically (mirrors\n // _containerH()'s fullscreen branch, which reads the resulting\n // clientHeight back for virtual-scroll math) — an explicit height\n // here would fight that.\n if (this.isFullscreen) {\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: 1,\n position: 'relative'\n }\n }\n // Fixed/auto height: an explicit px height (matching _containerH()'s\n // non-fullscreen formula) instead of flex:1 growth. flex:1 here only\n // ever has ONE sibling competing for space mathematically, so it\n // should be exact — but nested flex cross-axis stretch (this box's\n // height flows down to .eg-scroll-body/.eg-row-header-col via\n // align-items:stretch) is vulnerable to sub-pixel rounding between\n // browser-rendered header/footer row heights and the integer px\n // this component assumes, occasionally landing 1px short. That 1px\n // shortfall is enough to make .eg-scroll-body's overflow:auto show a\n // vertical scrollbar even when content should fit exactly (reported\n // with a single data row and no `height` prop — content height ==\n // container height, so any shortfall trips overflow instantly).\n // flex: '0 0 auto' overrides the .eg-body-area class rule's own\n // `flex: 1` (flex-basis: 0%) — with a non-auto flex-basis, flexbox\n // ignores this element's own `height` entirely for main-axis sizing,\n // so basis must explicitly go back to `auto` for `height` to apply.\n const h = this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - (this.hasFooter ? this._footerRowH : 0)\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: '0 0 auto',\n height: Math.max(h, 0) + 'px',\n position: 'relative'\n }\n },\n\n rowHeaderColStyle() {\n return {\n width: ROW_HEADER_WIDTH + 'px',\n minWidth: ROW_HEADER_WIDTH + 'px',\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n position: 'sticky',\n left: 0,\n zIndex: 20,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n rowOutlineColStyle() {\n return {\n width: OUTLINE_GUTTER + 'px',\n minWidth: OUTLINE_GUTTER + 'px',\n flexShrink: 0,\n position: 'relative',\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))'\n }\n },\n\n scrollBodyStyle() {\n return {\n flex: 1,\n overflow: 'auto',\n position: 'relative',\n overflowAnchor: 'none' /* prevent Chrome scroll anchoring from fighting virtual scroll translateY */\n }\n },\n\n frozenColBodyStyle() {\n const w = this.getFrozenWidth()\n return {\n position: 'sticky',\n left: 0,\n width: w + 'px',\n flexShrink: 0,\n zIndex: 10,\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))',\n overflow: 'hidden'\n }\n },\n\n mainCellsStyle() {\n const left = this.getFrozenWidth()\n return {\n position: 'absolute',\n left: left + 'px',\n top: 0,\n bottom: 0\n }\n },\n\n scrollContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && !this.pinnedRightSet.has(i)) w += this.getColW(i)\n }\n // Extra right padding so pinned-right zone doesn't cover the last scrollable column\n if (this.hasPinnedRight) w += this.pinnedRightWidth\n return w\n },\n\n scrollableContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && this.isColumnScrollable(i)) w += this.getColW(i)\n }\n return w\n },\n\n pinnedRightBodyStyle() {\n return {\n position: 'sticky',\n right: 0,\n // Natural position must be the inner's right edge for sticky right:0 to\n // engage (see the .eg-scroll-body-inner flex comment in the template).\n marginLeft: 'auto',\n width: this.pinnedRightWidth + 'px',\n zIndex: 10,\n background: 'white',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightHeaderStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n scrollRowHeadersStyle() {\n return { flex: 1, overflow: 'hidden', position: 'relative', overflowAnchor: 'none' }\n },\n\n footerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n flexShrink: 0,\n position: 'sticky',\n bottom: 0,\n zIndex: 30,\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n // box-shadow (not a real border, like .eg-header-area's own\n // separator) — a real border would add an unaccounted extra 1px to\n // the footer's layout height (nothing in the height math knows\n // about it), throwing off effectiveHeight/_autoContentHeight and\n // tripping a phantom vertical scrollbar on .eg-scroll-body even\n // when content should fit exactly.\n boxShadow: '0 -2px 0 0 var(--eg-color-border-strong, var(--eg-color-border-strong-default))'\n }\n },\n\n footerLabelStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: this._footerRowH + 'px',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderTop: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0,\n fontSize: 'var(--eg-font-size-header, var(--eg-font-size-header-default))',\n fontWeight: 600\n }\n },\n\n frozenColFooterStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightFooterStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n colOutlineRowStyle() {\n return {\n position: 'absolute',\n top: -OUTLINE_GUTTER + 'px',\n left: 0,\n right: 0,\n height: OUTLINE_GUTTER + 'px'\n }\n },\n\n resizeLineStyle() {\n if (!this.resizing.active) return {}\n if (this.resizing.type === 'col') {\n return {\n position: 'absolute',\n top: 0,\n left: (this.resizing.currentPos - (this.resizing.containerLeft || 0)) + 'px',\n width: '2px',\n height: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n } else {\n return {\n position: 'absolute',\n left: 0,\n top: (this.resizing.currentPos - (this.resizing.containerTop || 0)) + 'px',\n height: '2px',\n width: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n }\n }\n },\n\n watch: {\n size: {\n immediate: true,\n handler(v) { this.internalSize = v || 'middle' }\n },\n\n theme: {\n immediate: true,\n handler(v) { this.internalTheme = v || 'excel' }\n },\n\n // Runs before the `columns`/`data` watchers below (both call\n // _recomputeRowFilters()) so internalFilters already reflects the prop\n // by the time either of them fires during the initial immediate pass.\n filters: {\n immediate: true,\n handler(v) { this.internalFilters = v ? { ...v } : {} }\n },\n\n internalFilters() { this._scheduleFilterRecompute() },\n\n filterRow() {\n this._recomputeRowFilters()\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n sort: {\n immediate: true,\n handler(v) { this.internalSort = v ? { key: v.key, direction: v.direction } : null }\n },\n\n internalSize() {\n this.rowHeights = {}\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n autoWidth(v) {\n if (v) this._applyAutoWidth()\n },\n\n fitColumns(v) {\n if (v) {\n this.fitColumnsOverrides = {}\n this._applyFitColumns()\n } else {\n // Back to normal per-column resolution (same as the columns\n // watcher's non-fit branch, without keyToWidth — fitColumns already\n // discarded any remembered manual-resize widths when it turned on).\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n this.fitColumnsOverrides = {}\n this._applyAutoWidth()\n }\n },\n\n columns: {\n immediate: true,\n handler(cols) {\n // Build key→width map from current state before rebuilding leafCols.\n // This preserves user-resized widths across column reorders: when columns\n // are moved, _flatIndex values shift but col.key stays the same.\n const keyToWidth = {}\n const keyToSpec = {}\n ;(this.leafCols || []).forEach(col => {\n if (this.colWidths[col._flatIndex] !== undefined) {\n keyToWidth[col.key] = this.colWidths[col._flatIndex]\n }\n if (this.relativeColSpecs[col._flatIndex] !== undefined) {\n keyToSpec[col.key] = this.relativeColSpecs[col._flatIndex]\n }\n })\n\n this.leafCols = flattenColumns(cols)\n this.headerDepth = columnTreeDepth(cols)\n this.headerRows = buildHeaderRows(cols, this.headerDepth)\n this._maskCache = {}\n\n if (this.fitColumns) {\n // fitColumns is the highest-priority width control — it overrides\n // even remembered manual-resize widths (keyToWidth above), so it\n // deliberately does NOT participate in that preservation logic.\n // fitColumnsOverrides (per-column fixed-width overrides set by\n // manually resizing while fitColumns is on) IS preserved, by key,\n // dropping any keys that no longer exist in the new column set.\n this.colWidths = {}\n this.relativeColSpecs = {}\n const validKeys = new Set(this.leafCols.map(c => c.key))\n const newOverrides = {}\n Object.keys(this.fitColumnsOverrides).forEach(k => {\n if (validKeys.has(k)) newOverrides[k] = true\n })\n this.fitColumnsOverrides = newOverrides\n this._applyFitColumns()\n } else {\n // Rebuild colWidths by new flat index, using col.key to look up saved widths\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n if (keyToWidth[col.key] !== undefined) {\n newColWidths[col._flatIndex] = keyToWidth[col.key]\n if (keyToSpec[col.key] !== undefined) newRelativeSpecs[col._flatIndex] = keyToSpec[col.key]\n } else {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n }\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n }\n\n this.computeColumnTotals()\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this._applyAutoWidth()\n\n // Merge col.pin entries for columns not yet tracked in internalPinnedColumns.\n // Existing entries (from prop or prior runtime pin/unpin) are not overwritten.\n const pinFromCols = this._colPinMap(this.leafCols)\n Object.keys(pinFromCols).forEach(key => {\n if (!(key in this.internalPinnedColumns)) this.$set(this.internalPinnedColumns, key, pinFromCols[key])\n })\n }\n },\n pinnedColumns: {\n immediate: true,\n handler(v) {\n // Rebuild: col.pin is the base default, prop value wins on conflict.\n this.internalPinnedColumns = { ...this._colPinMap(this.leafCols), ...(v || {}) }\n }\n },\n merges: {\n immediate: true,\n handler(v) { this.internalMerges = v ? [...v] : [] }\n },\n collapsedGroups: {\n immediate: true,\n handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }\n },\n // `immediate: true` guarantees this fires once, synchronously, right at\n // setup regardless of `v` — that's the one reliable signal that THIS\n // particular call is the initial sync, not a real host-driven change\n // (e.g. via `:locked.sync`). Every call after the first is a genuine\n // change, so only those flash. (Watching internalLocked itself for this\n // instead — the previous approach — can't tell \"initial value happened\n // to be true\" from \"the user's first-ever real toggle\": both look like\n // \"the first time internalLocked actually changed value\".)\n locked: {\n immediate: true,\n handler(v) {\n this.internalLocked = !!v\n if (this._lockPropSynced) this._flashLock(this.internalLocked)\n this._lockPropSynced = true\n }\n },\n data: {\n immediate: true,\n handler(newData) {\n // Keep _dataRaw in sync so template uses fresh data without triggering dependArray\n this._dataRaw = newData || []\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this.computeColumnTotals()\n // Force re-render so cell values refresh even when virtualScroll indices don't change\n // (e.g. editing a cell: same row count, same scroll position, only content changed)\n this.$forceUpdate()\n this._applyAutoWidth()\n }\n },\n totalRows() { this.updateVirtualScroll() },\n hiddenRowSet() { this.updateVirtualScroll() },\n totalVirtualHeight() { this.$nextTick(this.syncHeaderViewportWidth) },\n // widthMode:'auto' — the shell resizes with its columns (resize, hide/\n // show), so the header/body viewport lock must be re-measured after the\n // DOM picks up the new width. (The runaway-loop safety net lives in\n // _widthSyncChainOk(), gating the actual colWidths mutation inside\n // syncHeaderViewportWidth() — not here — since that single choke point\n // catches every trigger path, not just this one.)\n naturalGridWidth() {\n if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)\n },\n widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },\n\n // localSyncKey/tenant feature — mounted() only calls\n // _loadLocalVersionsOnMount() once, at mount time. Without these\n // watchers, a key/tenant that starts empty (a host conditionally\n // passing `:local-sync-key=\"ready ? id : null\"`, or a `tenant` only\n // known after an async \"current organization\" load) and later becomes\n // truthy would never load its version list, never promote a leftover\n // buffer, and never show the toolbar badge — the feature would look\n // \"broken\" for the rest of that page's lifetime even though everything\n // else works. Both share _reloadLocalSyncForKeyChange() since either one\n // changing means the effective storage key changed.\n localSyncKey(newKey, oldKey) {\n if (newKey === oldKey) return\n this._reloadLocalSyncForKeyChange()\n },\n tenant(newTenant, oldTenant) {\n if (newTenant === oldTenant) return\n this._reloadLocalSyncForKeyChange()\n }\n },\n\n created() {\n // Non-reactive row data reference — avoids Vue 2 dependArray(data) O(n) loop.\n // Vue 2 calls dependArray() for EVERY `data[ri]` access in the template,\n // iterating all 50,000 rows each time. With 1200 cells per render that's\n // 60 million iterations per render (~600ms). _dataRaw bypasses this.\n this._dataRaw = this.data || []\n this._hoveredRi = -1\n\n // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes\n this._cellStyleCache = Object.create(null)\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n // rAF handles for scroll/resize throttle\n this._scrollRAF = null\n this._resizeRAF = null\n\n // Undo/redo history — non-reactive to avoid Vue overhead.\n // Each entry: { type: 'data'|'merges', before: {...}, after: {...} }\n // 'data' stores only diff rows: { indices: number[], rows: object[] }\n this._history = []\n this._historyIndex = -1\n this._historySkip = false // true while undo/redo is in progress\n\n // Column drag preview offsets — non-reactive so direct DOM updates bypass Vue re-renders.\n // Keys are column keys, values are pixel offsets.\n this._dragOffsets = {}\n\n // Column drag internals (all non-reactive):\n // _dragBounds — snapshot of every visible leaf header's ORIGINAL viewport\n // rect taken once at drag start; drop-index math reads only this snapshot\n // (adjusted by scrollLeft delta), never live DOM rects — live rects carry\n // in-flight CSS transforms and give unstable intermediate positions.\n this._dragBounds = null\n this._dragStartScrollLeft = 0\n this._pendingDragHeaderRect = null\n this._lastDragClientX = 0\n this._dragScrollV = 0 // px/frame edge auto-scroll velocity (0 = idle)\n this._dragScrollRAF = null\n this._dragCleanupTimer = null\n\n // Fill-handle drag edge auto-scroll (see _updateFillDragAutoScroll) — same\n // idle-when-0 convention as _dragScrollV/_dragScrollRAF above, just for\n // both axes since a fill can extend down OR right.\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragScrollRAF = null\n this._fillDragLastEvent = null\n\n // localSyncKey — the last `data` reference a version was actually saved\n // for. Initialized to the current (mount-time) value so an untouched grid\n // that's merely tab-switched away from doesn't spam a \"nothing happened\"\n // version on every hide — only a REAL edit (which replaces this array\n // reference, see _emitDataChange) makes the next hide event flush.\n this._lastFlushedDataRef = this.data\n },\n\n mounted() {\n this.updateVirtualScroll()\n this.$nextTick(this.syncHeaderViewportWidth)\n document.addEventListener('mousemove', this.onGlobalMouseMove)\n document.addEventListener('mouseup', this.onGlobalMouseUp)\n document.addEventListener('keydown', this.onGlobalKeyDown)\n document.addEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.addEventListener('resize', this.syncHeaderViewportWidth)\n window.addEventListener('scroll', this._onWindowScrollForDropdown, true)\n // Self-heals the \"mounted while display:none\" case (e.g. a v-show tab that isn't\n // active yet) — scrollBody.clientWidth reads 0 at mount, and nothing but a real\n // window resize would otherwise ever re-measure it. ResizeObserver also fires when\n // the container regains real dimensions (tab becomes visible), so headers/footer\n // (scrollColHeaderStyle) get correctly re-locked with no host code changes needed.\n // rAF-batched like onBodyScroll's _scrollRAF, to avoid thrashing during a live drag-resize.\n if (typeof ResizeObserver !== 'undefined' && this.$refs.scrollBody) {\n this._scrollBodyResizeObserver = new ResizeObserver(() => {\n if (this._roRAF) return\n this._roRAF = requestAnimationFrame(() => {\n this._roRAF = null\n this.syncHeaderViewportWidth()\n })\n })\n this._scrollBodyResizeObserver.observe(this.$refs.scrollBody)\n // Also observe the grid's own DOM parent — needed in BOTH widthMode\n // values to keep hostContainerWidth (relative column widths' reference\n // in both modes) fresh when the host's layout changes.\n if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)\n }\n this._loadLocalVersionsOnMount()\n // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).\n // 'visibilitychange'->hidden is the primary trigger: it fires for tab\n // switches, minimizing, AND reliably before actual tab close/navigation on\n // both desktop and mobile, while the page is still very likely alive —\n // giving the async worker-hash + IndexedDB write a real chance to finish.\n // 'pagehide' is a backstop for the cases visibilitychange misses; it's\n // idempotent with visibilitychange (same content -> same hash -> no-op\n // beyond a timestamp touch) so firing both is harmless.\n document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.addEventListener('pagehide', this._onPageHideForLocalSync)\n },\n\n updated() {\n // After Vue re-renders (e.g., virtual scroll adds new rows during drag),\n // apply missing transforms to newly rendered cells that don't have one yet.\n // The transform lands with transitions disabled (a fresh cell must appear\n // in place, not fly in from its untransformed position); the drag\n // transition is re-armed on the next frame.\n if (!this.colDrag.active || !this.$el) return\n const offsets = this._dragOffsets\n if (!offsets || !Object.keys(offsets).length) return\n const fresh = []\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n if (el.style.transform) return\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transition = 'none'\n el.style.transform = `translateX(${dx}px)`\n fresh.push(el)\n })\n if (fresh.length) {\n requestAnimationFrame(() => {\n if (!this.colDrag.active) return\n fresh.forEach(el => { el.style.transition = COL_DRAG_TRANSITION })\n })\n }\n },\n\n beforeDestroy() {\n clearTimeout(this._filterRecomputeTimer)\n clearTimeout(this._onInputTimer)\n clearTimeout(this._dragCleanupTimer)\n clearTimeout(this._widthSyncChainResetTimer)\n clearTimeout(this._bufferWriteTimer)\n if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)\n if (this._fillDragScrollRAF) cancelAnimationFrame(this._fillDragScrollRAF)\n document.removeEventListener('mousemove', this.onGlobalMouseMove)\n document.removeEventListener('mouseup', this.onGlobalMouseUp)\n document.removeEventListener('keydown', this.onGlobalKeyDown)\n document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.removeEventListener('resize', this.syncHeaderViewportWidth)\n window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)\n document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.removeEventListener('pagehide', this._onPageHideForLocalSync)\n // Best-effort — the component may be torn down (SPA route change) without\n // the page itself unloading, which visibilitychange/pagehide won't catch.\n // sync: true — see _flushLocalVersionOnHide()'s comment; a component\n // being destroyed is exactly the same \"might not survive an async hop\"\n // situation as the other two triggers.\n this._flushLocalVersionOnHide(false, true)\n if (this.isFullscreen) {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (this._scrollBodyResizeObserver) { this._scrollBodyResizeObserver.disconnect(); this._scrollBodyResizeObserver = null }\n if (this._roRAF) { cancelAnimationFrame(this._roRAF); this._roRAF = null }\n },\n\n methods: {\n // =========================================================\n // VIRTUAL SCROLL\n // =========================================================\n // In fullscreen, .eg-scroll-body's real height comes from flexbox filling\n // the viewport (which can be far taller than the `height` prop the host\n // originally passed for the fixed-size layout) — measure it directly\n // instead of trusting `height`, otherwise virtual scroll only ever\n // renders enough rows for the old fixed size, leaving the rest of the\n // (visually stretched) container blank.\n _containerH() {\n if (this.isFullscreen && this.$refs.scrollBody) return this.$refs.scrollBody.clientHeight\n const footerH = this.hasFooter ? this._footerRowH : 0\n return this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - footerH\n },\n\n // Same fullscreen-aware measurement as _containerH(), but mirrors PageUp/\n // PageDown's pre-existing (footer-agnostic) row-span formula exactly for\n // the non-fullscreen case, so normal-mode behavior is unchanged.\n _pageRowSpan() {\n const h = this.isFullscreen && this.$refs.scrollBody\n ? this.$refs.scrollBody.clientHeight\n : (this.effectiveHeight - this._headerRowH * this.headerDepth - this._filterRowH)\n return Math.floor(h / this._rowH)\n },\n\n updateVirtualScroll() {\n // Use binary search against the Vue-cached cumulativeHeights → O(log n) per scroll.\n // overscan=8 (≈224px at default row height) gives enough buffer for fast scroll\n // without blank-band artifacts between rAF updates.\n const result = findVisibleRange(\n this.scrollTop, Math.max(this._containerH(), 100), this.totalRows, this.cumulativeHeights, 8\n )\n this.virtualStartIndex = result.startIndex\n this.virtualEndIndex = result.endIndex\n this.virtualOffsetTop = result.offsetTop\n this.totalVirtualHeight = result.totalHeight\n },\n\n onBodyScroll(e) {\n const target = e.target\n // Sync sibling scroll containers immediately (visual alignment, no layout trigger)\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = target.scrollLeft\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = target.scrollLeft\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = target.scrollTop\n\n // Close dropdown on scroll — fixed-position dropdown drifts away from the moving cell\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n\n // Throttle reactive state updates + virtual scroll recalc to one rAF per frame.\n // Read scrollTop from the DOM ref at rAF time (not from the stale event closure)\n // so that multiple scroll events arriving between frames still resolve to the\n // final position — eliminates the flickering caused by render/scroll mismatch.\n if (!this._scrollRAF) {\n this._scrollRAF = requestAnimationFrame(() => {\n this._scrollRAF = null\n const sb = this.$refs.scrollBody\n if (!sb) return\n this.scrollTop = sb.scrollTop\n this.scrollLeft = sb.scrollLeft\n this.updateVirtualScroll()\n })\n }\n },\n\n onScroll(e) {},\n\n // .eg-scroll-col-header (overflow:hidden, synced via JS) has no native scrollbar, but\n // .eg-scroll-body (overflow:auto) does — its real vertical scrollbar eats into its\n // clientWidth. If both containers just grow via flex:1, the header ends up wider than\n // the body, so its max scrollLeft is smaller and syncing clamps it before the body\n // reaches its rightmost scroll position — column borders desync right at the scroll\n // end. Locking the header to the body's exact measured clientWidth (scrollColHeaderStyle)\n // makes both max scrollLeft values identical.\n syncHeaderViewportWidth() {\n // Auto-height's 70vh cap tracks the real viewport — keep it current on resize.\n if (typeof window !== 'undefined' && this.windowInnerH !== window.innerHeight) {\n this.windowInnerH = window.innerHeight\n }\n const el = this.$refs.scrollBody\n if (!el) return\n // headerViewportWidth is still tracked in both modes (scrollColHeaderStyle/\n // virtual scroll need the grid's OWN viewport width) — but no longer\n // feeds relative column-width resolution at all (see\n // _relativeWidthContainer/hostContainerWidth's comments for why: it's\n // circular in 'auto' and independently scrollbar-toggle-unstable in\n // 'full'). _widthSyncChainOk() still guards the assignment itself,\n // since it's reactive (scrollColHeaderStyle reads it) — at fractional\n // page-zoom levels el.clientWidth can genuinely oscillate by ±1px\n // indefinitely (a vertical scrollbar toggling right at that subpixel\n // rounding boundary), which would otherwise re-render on every flip\n // forever even with nothing left downstream to actually resolve.\n if (this.headerViewportWidth !== el.clientWidth && this._widthSyncChainOk()) {\n this.headerViewportWidth = el.clientWidth\n }\n // Reference for relative (%, css) column widths, in BOTH widthMode\n // values — see hostContainerWidth's declaration comment. Measured from\n // the grid's DOM PARENT (not the grid itself, and not its scrollBody),\n // which is stable regardless of the grid's own rendered size or\n // internal scrollbar state — UNLESS the host's own layout also\n // auto-sizes around the grid (e.g. the parent shrink-wraps to its\n // content), which recreates a similar cycle outside our control.\n // _widthSyncChainOk() guards the assignment itself against that\n // residual case — the single choke point both a resized parent (this\n // block) and a resized shell (the naturalGridWidth watcher, via a\n // fresh call to this method, 'auto' mode only) funnel through.\n if (this.$el && this.$el.parentElement) {\n const pw = this.$el.parentElement.clientWidth\n if (pw !== this.hostContainerWidth && this._widthSyncChainOk()) {\n this.hostContainerWidth = pw\n if (this.fitColumns) this._applyFitColumns()\n else this._resolveRelativeColWidths()\n }\n }\n const sbw = el.offsetWidth - el.clientWidth\n if (sbw >= 0 && sbw !== this.vScrollbarW) this.vScrollbarW = sbw\n // Shell border total (see naturalGridWidth) — offsetWidth-clientWidth\n // is exactly the horizontal borders here (the shell itself never scrolls).\n const shell = this.$el\n if (shell && shell.offsetWidth) {\n const bw = shell.offsetWidth - shell.clientWidth\n if (bw >= 0 && bw !== this.shellBorderW) this.shellBorderW = bw\n }\n // Window resize while fullscreen changes the real available row-viewport\n // height too (see _containerH()) — recompute which rows are rendered.\n // Same for auto-height (height prop omitted): the 70vh cap just moved.\n if (this.isFullscreen || this.height == null) this.updateVirtualScroll()\n },\n\n // Runaway width-measurement guard — used in BOTH widthMode values.\n // In 'auto' mode, hostContainerWidth re-resolving colWidths is DESIGNED\n // to settle in 1-2 calls, but a host layout that also auto-sizes AROUND\n // the grid can recreate a circular dependency outside our control. In\n // 'full' mode there's no such circularity by construction, but\n // el.clientWidth is still a real browser measurement, and at fractional\n // page-zoom levels it can genuinely oscillate by ±1px indefinitely\n // (scrollbar toggling right at the rounding boundary, or ResizeObserver's\n // own well-known \"callback reads a size that changes again\" footgun).\n // Either way the symptom is the same: syncHeaderViewportWidth keeps\n // getting re-triggered forever. Caps consecutive calls instead of\n // chasing forever; 400ms of quiet resets the count, so a legitimately\n // slow (but finite) settle is never penalized — only a genuinely\n // runaway chain trips it.\n _widthSyncChainOk() {\n this._widthSyncChainCount = (this._widthSyncChainCount || 0) + 1\n clearTimeout(this._widthSyncChainResetTimer)\n this._widthSyncChainResetTimer = setTimeout(() => { this._widthSyncChainCount = 0 }, 400)\n if (this._widthSyncChainCount > 30) {\n if (!this._widthSyncChainWarned) {\n this._widthSyncChainWarned = true\n // eslint-disable-next-line no-console\n console.warn('[vue2-excel-grid] widthMode=\"' + this.widthMode + '\": column width measurement did not stabilize after 30 consecutive re-measures (possibly fractional browser zoom, or a host layout that also auto-sizes around the grid) — freezing at the last measured size instead of re-rendering forever.')\n }\n return false // stop chasing; keep the last measured/resolved widths instead of looping forever\n }\n return true\n },\n\n // =========================================================\n // HELPERS\n // =========================================================\n isRowHidden(ri) { return this.hiddenRowSet.has(ri) },\n isColHidden(ci) { return this.effectiveHiddenColSet.has(ci) },\n\n getColW(ci) {\n return this.colWidths[ci] !== undefined ? this.colWidths[ci] : (this.leafCols[ci] ? this.resolveColWidth(this.leafCols[ci].width, this._relativeWidthContainer) : DEFAULT_COL_WIDTH)\n },\n\n // Turns a col.width spec (number, 'Npx', 'N%', or any other CSS length like\n // 'Nem'/'calc(...)') into a concrete px number. Percent is resolved against\n // containerWidth (see _relativeWidthContainer — always hostContainerWidth,\n // in both widthMode values); other CSS units are resolved via a hidden\n // DOM probe (_measureCssWidth).\n resolveColWidth(spec, containerWidth) {\n const parsed = parseColWidthSpec(spec)\n if (parsed.type === 'px') return Math.max(20, parsed.value)\n if (parsed.type === 'percent') {\n if (!containerWidth) return DEFAULT_COL_WIDTH\n return Math.max(20, (parsed.value / 100) * containerWidth)\n }\n const measured = this._measureCssWidth(parsed.value)\n return Math.max(20, measured || DEFAULT_COL_WIDTH)\n },\n\n // Measures an arbitrary CSS width string (em/rem/vw/calc/...) by applying it\n // to a hidden probe element, so it inherits ambient font-size for em/rem.\n // Parented in the grid's DOM PARENT (not the grid root or its scrollBody)\n // in both widthMode values, matching _relativeWidthContainer — a\n // %/calc(%...) value would otherwise resolve against the grid's own\n // rendered size, which is either circular ('auto') or independently\n // scrollbar-toggle-unstable ('full'). Falls back to the grid root if\n // there's no parent (e.g. a detached/test mount). Returns null before\n // mount (no DOM yet).\n _measureCssWidth(cssValue) {\n if (typeof document === 'undefined' || !this.$el || !this.$el.appendChild) return null\n const parent = this.$el.parentElement || this.$el\n if (!this._widthProbeEl || this._widthProbeEl.parentNode !== parent) {\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n const el = document.createElement('div')\n el.style.position = 'absolute'\n el.style.visibility = 'hidden'\n el.style.height = '0'\n el.style.overflow = 'hidden'\n el.style.pointerEvents = 'none'\n parent.appendChild(el)\n this._widthProbeEl = el\n }\n this._widthProbeEl.style.width = cssValue\n return this._widthProbeEl.offsetWidth\n },\n\n // Re-resolves every column whose width spec is relative (%, em, vw, ...)\n // against the current _relativeWidthContainer — called whenever that\n // reference width changes (initial mount + window resize), so\n // percent/relative columns stay responsive like real CSS, matching a\n // plain numeric/px width's stability.\n _resolveRelativeColWidths() {\n const specs = this.relativeColSpecs\n const keys = Object.keys(specs)\n if (!keys.length) return\n keys.forEach(ci => {\n this.$set(this.colWidths, ci, this.resolveColWidth(specs[ci], this._relativeWidthContainer))\n })\n this._cellStyleCache = Object.create(null)\n },\n\n getRowH(ri) {\n if (this.isRowHidden(ri)) return 0\n return this.rowHeights[ri] || this._rowH\n },\n\n getColLeft(ci) {\n let left = 0\n for (let i = 0; i < ci && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) left += this.getColW(i)\n }\n return left\n },\n\n getFrozenWidth() {\n return this.hasPinnedLeft ? this.pinnedLeftWidth : 0\n },\n\n // Scroll-position-aware pinned-column edge shadow (mirrors Ant Design/AG Grid's own\n // convention): the shadow only appears while there's actually scrolled-away content\n // hiding under the pinned zone on that side, not as a permanent decoration — a static\n // shadow reads as background noise and the user stops noticing it exactly when it\n // matters (mid-scroll). Read scrollLeft (already tracked reactively, updated every\n // rAF tick in onBodyScroll) so template :class bindings re-evaluate on every scroll frame.\n hasHiddenScrollLeft() {\n return this.getFrozenWidth() > 0 && this.scrollLeft > 0\n },\n hasHiddenScrollRight() {\n if (!this.hasPinnedRight) return false\n const sb = this.$refs.scrollBody\n if (!sb) return false\n return this.scrollLeft < sb.scrollWidth - sb.clientWidth - 1\n },\n\n isColumnPinnedLeft(ci) {\n return this.pinnedLeftSet.has(ci)\n },\n\n isColumnPinnedRight(ci) {\n return this.pinnedRightSet.has(ci)\n },\n\n isColumnScrollable(ci) {\n return !this.isColumnPinnedLeft(ci) && !this.isColumnPinnedRight(ci)\n },\n\n getZoneColLeft(ci, zone) {\n let left = 0\n if (zone === 'left') {\n for (const col of this.pinnedLeftCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else if (zone === 'right') {\n for (const col of this.pinnedRightCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else {\n for (let i = 0; i < this.leafCols.length; i++) {\n const col = this.leafCols[i]\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex) && this.isColumnScrollable(col._flatIndex)) {\n left += this.getColW(col._flatIndex)\n }\n }\n }\n return left\n },\n\n isCellInZone(cell, rowIdx, zone) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n const z = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n return z === zone\n },\n\n getHeaderCellStyleForZone(cell, rowIndex, zone) {\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z !== zone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getRangeWidth(startCol, endCol) {\n let w = 0\n for (let i = startCol; i <= endCol && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) w += this.getColW(i)\n }\n return w\n },\n\n getRangeHeight(startRow, endRow) {\n let h = 0\n for (let i = startRow; i <= endRow && i < this.totalRows; i++) {\n if (!this.isRowHidden(i)) h += this.getRowH(i)\n }\n return h\n },\n\n getCellValue(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return ''\n const row = this._dataRaw[ri]\n if (!row) return ''\n const v = row[col.key]\n return v !== undefined ? v : ''\n },\n\n isEditing(ri, ci) {\n return this.editingCell && this.editingCell.row === ri && this.editingCell.col === ci\n },\n\n isMergedAway(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return false\n const nm = normalizeRange(merge)\n return !(nm.startRow === ri && nm.startCol === ci)\n },\n\n getMergeSpan(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return { rowspan: 1, colspan: 1 }\n const nm = normalizeRange(merge)\n return {\n rowspan: nm.endRow - nm.startRow + 1,\n colspan: nm.endCol - nm.startCol + 1\n }\n },\n\n isSelected(ri, ci) {\n return isCellInAnyRange(ri, ci, this.selectionRanges)\n },\n\n isActiveCell(ri, ci) {\n return this.activeCell.row === ri && this.activeCell.col === ci\n },\n\n isCopyHighlighted(ri, ci) {\n if (!this.clipboardRange || this.clipboardMode !== 'copy') return false\n return isCellInRange(ri, ci, this.clipboardRange)\n },\n\n // =========================================================\n // STYLES\n // =========================================================\n getRowStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowStyleCache[key]) return this._rowStyleCache[key]\n const style = { height: h + 'px', minHeight: h + 'px' }\n this._rowStyleCache[key] = style\n return style\n },\n\n getRowHeaderStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowHeaderStyleCache[key]) return this._rowHeaderStyleCache[key]\n const style = { height: h + 'px' }\n this._rowHeaderStyleCache[key] = style\n return style\n },\n\n getCellStyle(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n const nm = merge ? normalizeRange(merge) : null\n let w = this.getColW(ci)\n let h = this.getRowH(ri)\n\n if (nm && nm.startRow === ri && nm.startCol === ci) {\n w = this.getRangeWidth(nm.startCol, nm.endCol)\n h = this.getRangeHeight(nm.startRow, nm.endRow)\n }\n\n // Position cells with the same absolute-left arithmetic the header uses\n // (getZoneColLeft), instead of relying on flex layout to accumulate widths —\n // flex and a JS integer sum can drift apart by a few px over many columns,\n // desyncing the body's column borders from the header's.\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n\n // Cache by dimensions+position — same (w,h,left) → same object ref → Vue 2 skips DOM style writes\n const key = w + '_' + h + '_' + left\n if (this._cellStyleCache[key]) return this._cellStyleCache[key]\n const style = { position: 'absolute', left: left + 'px', top: '0px', width: w + 'px', minWidth: w + 'px', height: h + 'px' }\n this._cellStyleCache[key] = style\n return style\n },\n\n // rowStyle prop — deliberately NOT merged into getCellStyle()'s return value:\n // that object is cached/shared by (w,h,left) across unrelated rows/cells (see\n // comment above), so mutating it here would leak one row's style onto every\n // other cell that happens to share the same cache key. Kept as a second,\n // uncached :style array entry instead (see template) — cheap, and correctly\n // reactive to the row's actual data instead of just its geometry.\n getRowCustomStyle(ri) {\n if (!this.rowStyle) return null\n return this.rowStyle(this._dataRaw[ri], ri) || null\n },\n\n getCellClasses(ri, ci) {\n const col = this.leafCols[ci]\n const active = this.isActiveCell(ri, ci)\n // Non-active selected cell: border only on edges NOT shared with another\n // selected neighbor, so a multi-cell range gets one continuous outline\n // around its outer perimeter (Excel-style) instead of each cell only\n // getting a flat background tint. Works for disjoint multi-range\n // selections too (e.g. ctrl+click rows) since it's a pure neighbor check.\n const rangeSelected = this.isSelected(ri, ci) && !active\n return {\n 'eg-cell-active': active,\n 'eg-cell-selected': rangeSelected,\n 'eg-cell-range-top': rangeSelected && !this.isSelected(ri - 1, ci),\n 'eg-cell-range-bottom': rangeSelected && !this.isSelected(ri + 1, ci),\n 'eg-cell-range-left': rangeSelected && !this.isSelected(ri, ci - 1),\n 'eg-cell-range-right': rangeSelected && !this.isSelected(ri, ci + 1),\n 'eg-cell-fill-preview': this.isInFillPreview(ri, ci),\n 'eg-cell-copy': this.isCopyHighlighted(ri, ci),\n 'eg-cell-merged-hidden': this.isMergedAway(ri, ci),\n 'eg-cell-select': !!(col && this.isSelectLikeCol(col)),\n 'eg-cell-date': !!(col && col.type === 'date'),\n 'eg-cell-checkbox': !!(col && col.type === 'checkbox'),\n 'eg-cell-action': !!(col && col.type === 'action'),\n 'eg-cell-readonly': !this.isCellEditable(ri, ci),\n 'eg-cell-invalid': !!(this.invalidCell && this.invalidCell.row === ri && this.invalidCell.col === ci),\n 'eg-cell--has-suffix': !!(col && this.hasSuffixSlot(col)),\n 'eg-cell--drag-source': this.colDrag.active && ci >= this.colDrag.sourceFirstLeaf && ci < this.colDrag.sourceFirstLeaf + this.colDrag.sourceLeafCount,\n 'eg-cell-align-right': !!(col && col.align === 'right'),\n 'eg-cell-align-center': !!(col && col.align === 'center')\n }\n },\n\n isInFillPreview(ri, ci) {\n if (!this.fillDrag.active || !this.fillDrag.sourceRange) return false\n const src = this.fillDrag.sourceRange\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n if (targetRow > src.endRow) {\n return ri > src.endRow && ri <= targetRow && ci >= src.startCol && ci <= src.endCol\n }\n if (targetCol > src.endCol) {\n return ci > src.endCol && ci <= targetCol && ri >= src.startRow && ri <= src.endRow\n }\n return false\n },\n\n getHeaderCellStyle(cell, rowIndex, isFrozen) {\n const zone = isFrozen ? 'left' : 'scroll'\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n if (isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (!inZone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getHeaderDragTransform(cell) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking, no re-render on offset change\n let key\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n } else {\n key = cell.column.key\n }\n if (!key) return null\n const dx = this._dragOffsets[key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n getCellDragTransform(ci) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking\n const col = this.leafCols[ci]\n if (!col) return null\n const dx = this._dragOffsets[col.key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n // =========================================================\n // FOOTER (column totals)\n // =========================================================\n\n // col.totalConfig.visible (boolean) takes priority over col.showTotal when present.\n isColumnTotalVisible(col) {\n if (col.totalConfig && typeof col.totalConfig.visible === 'boolean') return col.totalConfig.visible\n return !!col.showTotal\n },\n\n // Columns with a visible total are implicitly numeric — computeColumnTotals()\n // only sums typeof === 'number' cells, so a value written as a string (every\n // edit path hands over a string: the built-in text <input>, pasted TSV text,\n // fill-handle/drag-move source values) would silently drop out of the sum\n // with no error. Coerce user-entered values that fully parse as a number into\n // an actual Number for these columns; anything else (blank, non-numeric text)\n // passes through unchanged so it still fails validation/renders as typed.\n _coerceCellValue(ci, value) {\n if (typeof value !== 'string') return value\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return value\n const trimmed = value.trim()\n if (trimmed === '' || isNaN(Number(trimmed))) return value\n return Number(trimmed)\n },\n\n // Default value for a column on a row auto-created by pasteGrowsRows.\n _blankValueForCol(col) {\n return col.type === 'checkbox' ? false : ''\n },\n\n // One `Mask` instance per masked flat column index, reused across calls\n // (maska memoizes internally too, but this avoids re-parsing col.mask on\n // every keystroke/paste/fill). Invalidated whenever columns changes (see\n // the `columns` watcher, which resets `_maskCache = {}`).\n _getColMask(ci) {\n if (!this._maskCache) this._maskCache = {}\n if (this._maskCache[ci] !== undefined) return this._maskCache[ci]\n const col = this.leafCols[ci]\n const spec = col && col.mask\n let mask = null\n if (spec) {\n const options = (typeof spec === 'string' || Array.isArray(spec)) ? { mask: spec } : spec\n mask = new Mask(options)\n }\n this._maskCache[ci] = mask\n return mask\n },\n\n // Reformats a value through col.mask, if the column has one. Used at every\n // path that writes a value without going through the live MaskInput-driven\n // editor input (commit of a custom edit slot, paste, fill handle/double-fill)\n // — the live-typing case is handled by MaskInput itself (see startEditing).\n // Idempotent: masking an already-masked value returns it unchanged.\n _applyColMask(ci, value) {\n const mask = this._getColMask(ci)\n if (!mask || value === '' || value === null || value === undefined) return value\n if (typeof value !== 'string' && typeof value !== 'number') return value\n return mask.masked(value)\n },\n\n computeColumnTotals() {\n const totals = {}\n const rows = this._dataRaw || []\n this.leafCols.forEach(col => {\n if (!this.isColumnTotalVisible(col)) return\n let sum = 0\n let hasNumber = false\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n const v = row ? row[col.key] : undefined\n if (typeof v === 'number' && isFinite(v)) {\n sum += v\n hasNumber = true\n }\n }\n totals[col._flatIndex] = hasNumber ? Math.round(sum * 1e6) / 1e6 : null\n })\n this.columnTotals = totals\n },\n\n getColumnTotalDisplay(ci) {\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return ''\n const raw = this.columnTotals[ci]\n const cfg = col.totalConfig\n if (cfg && typeof cfg.formatter === 'function') {\n return cfg.formatter(raw, this._dataRaw, col, ci)\n }\n return raw === null || raw === undefined ? '-' : raw\n },\n\n getFooterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n const style = {\n position: 'absolute',\n left: left + 'px',\n top: '0px',\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px'\n }\n const col = this.leafCols[ci]\n const extra = col && col.totalConfig && col.totalConfig.style\n return extra ? { ...style, ...extra } : style\n },\n\n getFooterCellClass(ci) {\n const col = this.leafCols[ci]\n if (!col) return null\n const classList = col.totalConfig && col.totalConfig.classList\n const base = this.isColumnTotalVisible(col) ? 'eg-footer-cell-total' : null\n if (!base) return classList || null\n return classList ? [base, classList] : base\n },\n\n isCellVisible(cell, rowIndex, isFrozen) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (inZone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n return isFrozen ? this.isColumnPinnedLeft(ci) : this.isColumnScrollable(ci)\n },\n\n getRowGroupBtnStyle(group) {\n const top = getRowTop(group.startIndex, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD) - this.scrollTop\n return {\n position: 'absolute',\n top: top + 'px',\n left: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px',\n zIndex: 5\n }\n },\n\n getColGroupBtnStyle(group) {\n const left = this.getColLeft(group.startIndex) - this.scrollLeft\n return {\n position: 'absolute',\n left: left + 'px',\n top: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px'\n }\n },\n\n // =========================================================\n // CELL SELECTION (3.1)\n // =========================================================\n selectCell(row, col, addToSelection = false) {\n if (row < 0 || row >= this.totalRows || col < 0 || col >= this.leafCols.length) return\n this.activeCell = { row, col }\n if (addToSelection) {\n this.selectionRanges.push(singleCellRange(row, col))\n } else {\n this.selectionRanges = [singleCellRange(row, col)]\n }\n this.emitSelectionChange()\n },\n\n selectRange(startRow, startCol, endRow, endCol, replace = true) {\n const range = { startRow, startCol, endRow, endCol }\n if (replace) {\n this.selectionRanges = [range]\n } else {\n this.selectionRanges.push(range)\n }\n this.emitSelectionChange()\n },\n\n selectRow(ri) {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [{ startRow: ri, endRow: ri, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n selectColumn(ci) {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n this.emitSelectionChange()\n },\n\n selectAll() {\n this.activeCell = { row: 0, col: 0 }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n emitSelectionChange() {\n this.$emit('selection-change', {\n activeCell: { ...this.activeCell },\n ranges: this.selectionRanges.map(r => normalizeRange(r))\n })\n },\n\n onCellMouseDown(e, ri, ci) {\n this.invalidCell = null\n if (this.editingCell) {\n const editCol = this.leafCols[this.editingCell.col]\n if (editCol && this._hasCustomEdit(editCol)) {\n // Custom cell/edit slots commit via their own @blur/@done handler\n // (see commitEditSlot()), and the browser fires that native blur\n // *after* this mousedown listener returns (blur is the default\n // action of the focus shift, which runs post-dispatch). Cancelling\n // here immediately would null out editingCell before the slot's\n // blur handler gets to call done(), silently discarding the typed\n // value. Defer the cleanup instead, and only run it if the slot's\n // own commit hasn't already resolved editingCell by then.\n const staleEditingCell = this.editingCell\n this.$nextTick(() => {\n if (this.editingCell === staleEditingCell) this.cancelEdit()\n })\n } else {\n this.commitEdit()\n }\n }\n this.hideContextMenu()\n this.$refs.wrapper && this.$refs.wrapper.focus()\n\n if (e.ctrlKey || e.metaKey) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Ctrl+click: start new independent range\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges.push(this.mouseDownRange)\n this.isCtrlDrag = true\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Shift+click: extend primary range to this cell\n this.selectionRanges = [buildRange(this.activeCell, { row: ri, col: ci })]\n } else {\n const wasActive = this.activeCell.row === ri && this.activeCell.col === ci\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges = [this.mouseDownRange]\n this.isCtrlDrag = false\n\n // Open select/date dropdown on click if cell was already active (2nd click)\n const col = this.leafCols[ci]\n if (wasActive && col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openSelectDropdown(ri, ci, e)\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (wasActive && col && col.type === 'date' && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openDatePickerForActive()\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n }\n\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onCellMouseOver(e, ri, ci) {\n if (!this.isMouseDown) return\n if (this.fillDrag.active) return\n if (this.dragMove.active) {\n this.dragMove.targetRow = ri\n this.dragMove.targetCol = ci\n return\n }\n\n // Extend current drag range\n if (this.mouseDownRange) {\n this.mouseDownRange.endRow = ri\n this.mouseDownRange.endCol = ci\n this.emitSelectionChange()\n }\n },\n\n onCellMouseUp() {\n this.isMouseDown = false\n },\n\n onCellDblClick(ri, ci) {\n const col = this.leafCols[ci]\n if (!this.isCellEditable(ri, ci)) return\n if (col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col)) {\n this.openSelectDropdownForActive()\n return\n }\n if (col && col.type === 'date' && !this._hasCustomEdit(col)) {\n this.openDatePickerForActive()\n return\n }\n if (col && col.type === 'checkbox') return\n if (col && col.type === 'action') return\n this.startEditing(ri, ci, null, true)\n },\n\n onRowHeaderMouseDown(e, ri) {\n const endCol = this.leafCols.length - 1\n const fullRow = { startRow: ri, endRow: ri, startCol: 0, endCol }\n\n if (e.ctrlKey || e.metaKey) {\n if (e.shiftKey && this.selectionRanges.length > 0) {\n // Ctrl+Shift: active qatordan ri gacha yangi range qo'sh\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [...this.selectionRanges, { startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n // Ctrl+click: qatorni toggle qil\n const idx = this.selectionRanges.findIndex(r =>\n r.startRow === ri && r.endRow === ri && r.startCol === 0 && r.endCol === endCol\n )\n if (idx >= 0) {\n const next = this.selectionRanges.filter((_, i) => i !== idx)\n this.selectionRanges = next.length ? next : [fullRow]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [...this.selectionRanges, fullRow]\n }\n }\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [{ startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [fullRow]\n }\n\n this.mouseDownRange = this.selectionRanges[this.selectionRanges.length - 1]\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderMouseDown(e, cell) {\n // Start pending drag (activates after 5 px horizontal movement)\n // Works for both leaf and group headers\n if (this.columnDraggable && !this.internalLocked && !e.shiftKey && !e.ctrlKey && !e.metaKey) {\n this.colDrag.pending = true\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = cell._firstLeafFlatIndex\n this.colDrag.sourceLeafCount = cell.isGroup ? cell.colspan : 1\n this.colDrag.startX = e.clientX\n this.colDrag.startY = e.clientY\n this.colDrag.ghostLabel = cell.column.title || ''\n // Grabbed header's rect sizes the ghost and anchors the grab point if\n // the 5px threshold later activates the drag (see _startColDrag).\n this._pendingDragHeaderRect = e.currentTarget ? e.currentTarget.getBoundingClientRect() : null\n }\n\n if (cell.isGroup) {\n // Group header click: select all leaf columns within the group\n const startCol = cell._firstLeafFlatIndex\n const endCol = startCol + cell.colspan - 1\n this.activeCell = { row: 0, col: startCol }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol, endCol }]\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n return\n }\n\n const ci = cell.column._flatIndex\n if (e.shiftKey && this.selectionRanges.length > 0) {\n const sc = Math.min(this.activeCell.col, ci)\n const ec = Math.max(this.activeCell.col, ci)\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: sc, endCol: ec }]\n } else {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n }\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderDblClick(e, cell) {\n if (cell.isGroup) return\n this.autoFitColumn(cell.column._flatIndex)\n },\n\n onColHeaderContextMenu(e, cell) {\n if (!cell.isGroup) {\n this._openContextMenu(e, -1, cell.column._flatIndex)\n }\n },\n\n onRowContextMenu(e, ri) {\n this._openContextMenu(e, ri, -1)\n },\n\n onCellContextMenu(e, ri, ci) {\n if (!this.isSelected(ri, ci)) {\n this.selectCell(ri, ci)\n }\n this._openContextMenu(e, ri, ci)\n },\n\n _openContextMenu(e, row, col) {\n this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row, col }\n this.contextMenuPositioned = false\n this.$nextTick(this._positionContextMenu)\n },\n\n // Flips the menu up/left if it would overflow the viewport at the raw\n // click coordinates — measured against the actual rendered size (item\n // count varies with devMode/pin/row-vs-col context, so an estimate isn't\n // reliable). Rendered off-screen (left:-9999px, see template) while\n // unpositioned so this first measurement reflects the menu's true\n // natural (single-line) width — measuring it in place near a viewport\n // edge would let the browser's own shrink-to-fit sizing wrap the text\n // to fit the cramped space, which then \"sticks\" even after flipping to\n // a roomier position since we only move it, we don't force a width.\n // Stays hidden (visibility, not v-if) until repositioned so there's no\n // visible jump.\n _positionContextMenu() {\n const el = this.$refs.contextMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.contextMenu.x\n let y = this.contextMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.contextMenu.x = x\n this.contextMenu.y = y\n this.contextMenuPositioned = true\n },\n\n hideContextMenu() {\n this.contextMenu.visible = false\n },\n\n // General click-anywhere-in-the-grid dismissal for both floating menus —\n // each menu item stops propagation (@click.stop) so a click *inside*\n // either menu never reaches this handler.\n hideFloatingMenus() {\n this.hideContextMenu()\n this.hideSortMenu()\n },\n\n\n // =========================================================\n // HOVER HIGHLIGHT\n // =========================================================\n onGridMouseMove(e) {\n const rowNode = e.target.closest('[data-row]')\n const newRi = rowNode ? parseInt(rowNode.getAttribute('data-row'), 10) : -1\n\n if (this._hoveredRi !== newRi) {\n if (this._hoveredRi >= 0) {\n const oldEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < oldEls.length; i++) {\n oldEls[i].classList.remove('eg-row--hover')\n }\n }\n \n this._hoveredRi = newRi\n\n if (this._hoveredRi >= 0) {\n const newEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < newEls.length; i++) {\n newEls[i].classList.add('eg-row--hover')\n }\n }\n }\n },\n \n onGridMouseLeave() {\n if (this._hoveredRi >= 0) {\n if (this.$refs.wrapper) {\n const oldEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < oldEls.length; i++) {\n oldEls[i].classList.remove('eg-row--hover')\n }\n }\n this._hoveredRi = -1\n }\n },\n\n // =========================================================\n // KEYBOARD NAVIGATION (3.2)\n // =========================================================\n onKeyDown(e) {\n // Delegate keyboard navigation to the active dropdown component\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n if (comp && comp.handleKey) comp.handleKey(e)\n return\n }\n\n if (this.infoModal) {\n if (e.key === 'Escape') this.closeInfoModal()\n return\n }\n\n if (this.editingCell) {\n // Normally handled by onEditKeyDown on the built-in <input> (which stops\n // propagation, so this branch never runs for it). Custom #cell-{key} edit\n // slots own their own input and focus it themselves — if that focus didn't\n // land (e.g. the host relies on the unreliable HTML `autofocus` attribute\n // on a freshly-inserted element), Escape falls through to here instead.\n // Without this, editingCell stays stuck and even arrow-key navigation is\n // swallowed by this same guard on every subsequent keydown.\n if (e.key === 'Escape') this.cancelEdit()\n return\n }\n const key = e.key\n // Layout-independent letter for the Ctrl/Cmd shortcuts below (z/y/a/c/x/v/d/r).\n // e.key reports the character a layout PRODUCES — on a non-Latin layout\n // (Cyrillic, etc.) the physical C key can report e.key: 'с' (Cyrillic es)\n // even with Ctrl held and the physical key/position unchanged, silently\n // breaking `key === 'c'`. e.code is the physical key position and is\n // immune to layout, so it's used here instead wherever available;\n // falls back to e.key when e.code is missing (e.g. synthetic events in\n // tests that don't set it).\n const shortcutKey = (e.code && e.code.length === 4 && e.code.slice(0, 3) === 'Key')\n ? e.code.charAt(3).toLowerCase()\n : key\n\n if ((e.ctrlKey || e.metaKey) && shortcutKey === 'z' && !e.shiftKey) {\n e.preventDefault()\n this.undo()\n return\n }\n if ((e.ctrlKey || e.metaKey) && (shortcutKey === 'y' || (shortcutKey === 'z' && e.shiftKey))) {\n e.preventDefault()\n this.redo()\n return\n }\n\n if (shortcutKey === 'a' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.selectAll()\n return\n }\n\n if (shortcutKey === 'c' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.copySelection()\n return\n }\n if (shortcutKey === 'x' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.cutSelection()\n return\n }\n if (shortcutKey === 'v' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.pasteAt(this.activeCell.row, this.activeCell.col)\n return\n }\n\n if (shortcutKey === 'd' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillDown()\n return\n }\n if (shortcutKey === 'r' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillRight()\n return\n }\n\n if (key === 'Delete' || key === 'Backspace') {\n e.preventDefault()\n this.clearSelection()\n return\n }\n\n if (key === 'F2') {\n e.preventDefault()\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openSelectDropdownForActive()\n } else if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openDatePickerForActive()\n } else if (activeCol && activeCol.type === 'checkbox' && activeEditable) {\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n } else if (activeCol && activeCol.type === 'action' && activeEditable) {\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n } else if (activeEditable) {\n this.startEditing(this.activeCell.row, this.activeCell.col, null, true)\n }\n return\n }\n\n if (key === ' ') {\n const activeCol = this.leafCols[this.activeCell.col]\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n return\n }\n }\n\n if (key === 'Escape') {\n this.clipboardRange = null\n this.clipboardMatrix = null\n this.clipboardMode = null\n return\n }\n\n if (key === 'Tab') {\n e.preventDefault()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n return\n }\n if (key === 'Enter') {\n e.preventDefault()\n this.moveActive(e.shiftKey ? -1 : 1, 0, e)\n return\n }\n\n const arrows = { ArrowUp: [-1,0], ArrowDown: [1,0], ArrowLeft: [0,-1], ArrowRight: [0,1] }\n if (arrows[key]) {\n e.preventDefault()\n const [dr, dc] = arrows[key]\n if (e.shiftKey) {\n this.extendSelection(dr, dc)\n } else {\n this.moveActive(dr, dc, e)\n }\n return\n }\n\n if (key === 'Home') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(0, 0)\n } else {\n this.selectCell(this.activeCell.row, 0)\n }\n return\n }\n if (key === 'End') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(this.totalRows - 1, this.leafCols.length - 1)\n } else {\n this.selectCell(this.activeCell.row, this.leafCols.length - 1)\n }\n return\n }\n if (key === 'PageUp') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(-this._pageRowSpan(), 0)\n } else {\n this.moveActive(-this._pageRowSpan(), 0, e)\n }\n return\n }\n if (key === 'PageDown') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(this._pageRowSpan(), 0)\n } else {\n this.moveActive(this._pageRowSpan(), 0, e)\n }\n return\n }\n },\n\n onKeyPress(e) {\n if (this.editingCell) return\n if (this.selectDropdown) return\n if (this.datePickerOverlay) return\n if (e.ctrlKey || e.metaKey || e.altKey) return\n if (e.key && e.key.length === 1) {\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openSelectDropdownForActive()\n return\n }\n if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openDatePickerForActive()\n return\n }\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n return\n }\n // Without this, the browser's own native \"insert this character\" action\n // (queued for whatever element ends up focused) still fires after\n // startEditing()'s $nextTick focuses the freshly-created edit <input> —\n // which is already seeded with this same character via editValue — so\n // the char gets inserted a second time (e.g. \"1\" -> \"11\").\n e.preventDefault()\n this.startEditing(this.activeCell.row, this.activeCell.col, e.key, false)\n }\n },\n\n onGlobalKeyDown(e) {\n if (e.key === 'Escape' && (this.colDrag.active || this.colDrag.pending)) {\n this._resetColDrag(true)\n e.preventDefault()\n }\n },\n\n // Shift+Arrow / Shift+PageUp/PageDown: extend or shrink the selection\n // relative to the FIXED anchor (activeCell) — not just always growing in\n // the pressed direction. The \"far\" corner is whichever end of the current\n // (normalized) range isn't the anchor; per axis, _extendAxis grows it\n // further out while it's already on the pressed side, or shrinks it back\n // toward the anchor (never past it in one step) while it's on the\n // opposite side — matching Excel/Sheets. Only the axis(es) with a\n // non-zero delta are touched, so a pure vertical extend preserves the\n // current column range untouched, and vice versa.\n extendSelection(dr, dc) {\n const anchor = this.activeCell\n const pr = this.primaryRange || singleCellRange(anchor.row, anchor.col)\n const nm = normalizeRange(pr)\n let farRow = nm.startRow === anchor.row ? nm.endRow : nm.startRow\n let farCol = nm.startCol === anchor.col ? nm.endCol : nm.startCol\n if (dr !== 0) farRow = this._extendAxis(anchor.row, farRow, dr, this.totalRows - 1)\n if (dc !== 0) farCol = this._extendAxis(anchor.col, farCol, dc, this.leafCols.length - 1)\n this.selectionRanges = [buildRange(anchor, { row: farRow, col: farCol })]\n this.emitSelectionChange()\n // Unlike moveActive(), this doesn't move the active cell — but the newly\n // extended (far) corner of the range must still be scrolled into view,\n // exactly like moveActive() does for the active cell itself.\n this.scrollToCell(farRow, farCol)\n },\n\n _extendAxis(anchorIdx, farIdx, delta, maxIdx) {\n if (delta > 0) {\n return farIdx >= anchorIdx ? Math.min(maxIdx, farIdx + delta) : Math.min(anchorIdx, farIdx + delta)\n }\n return farIdx <= anchorIdx ? Math.max(0, farIdx + delta) : Math.max(anchorIdx, farIdx + delta)\n },\n\n moveActive(dr, dc, e) {\n let row = this.activeCell.row + dr\n let col = this.activeCell.col + dc\n\n // Tab: wrap around rows\n if (e && e.key === 'Tab') {\n if (col >= this.leafCols.length) { col = 0; row++ }\n if (col < 0) { col = this.leafCols.length - 1; row-- }\n }\n\n row = Math.max(0, Math.min(this.totalRows - 1, row))\n col = Math.max(0, Math.min(this.leafCols.length - 1, col))\n\n // Skip hidden cols\n while (this.isColHidden(col) && col > 0 && col < this.leafCols.length - 1) {\n col += dc >= 0 ? 1 : -1\n }\n // Skip hidden rows\n while (this.isRowHidden(row) && row > 0 && row < this.totalRows - 1) {\n row += dr >= 0 ? 1 : -1\n }\n\n // Handle merge navigation\n const resolved = resolveMergeAnchor(row, col, this.internalMerges)\n row = resolved.row\n col = resolved.col\n\n this.activeCell = { row, col }\n this.selectionRanges = [singleCellRange(row, col)]\n this.emitSelectionChange()\n this.scrollToCell(row, col)\n },\n\n scrollToCell(ri, ci) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return\n const rowTop = getRowTop(ri, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD)\n const rowBottom = rowTop + this.getRowH(ri)\n\n if (rowTop < scrollBody.scrollTop) scrollBody.scrollTop = rowTop\n if (rowBottom > scrollBody.scrollTop + scrollBody.clientHeight) {\n scrollBody.scrollTop = rowBottom - scrollBody.clientHeight\n }\n\n // Pinned columns are sticky — always horizontally in view, nothing to do.\n if (!this.isColumnScrollable(ci)) return\n const zoneLeft = this.getZoneColLeft(ci, 'scroll')\n const zoneRight = zoneLeft + this.getColW(ci)\n // A scrollable cell only counts as visible inside the window the sticky\n // pinned zones don't cover — without subtracting them, a cell could sit\n // fully underneath a pinned zone yet be treated as already in view.\n const visibleW = scrollBody.clientWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n if (zoneLeft < scrollBody.scrollLeft) scrollBody.scrollLeft = zoneLeft\n if (zoneRight > scrollBody.scrollLeft + visibleW) {\n scrollBody.scrollLeft = zoneRight - visibleW\n }\n },\n\n // =========================================================\n // EDITING (used by 3.1 + 3.2)\n // =========================================================\n startEditing(ri, ci, initialChar = null, keepValue = false) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (ri < 0 || ri >= this.totalRows) return\n if (!this._hasCustomEdit(col)) {\n if (this.isSelectLikeCol(col)) { this.openSelectDropdownForActive(); return }\n if (col.type === 'date') { this.openDatePickerForActive(); return }\n if (col.type === 'checkbox') return\n if (col.type === 'action') return\n }\n\n this.invalidCell = null\n this.editingCell = { row: ri, col: ci }\n if (initialChar !== null) {\n this.editValue = initialChar\n } else if (keepValue) {\n this.editValue = String(this.getCellValue(ri, ci) || '')\n } else {\n this.editValue = ''\n }\n this.editOriginalValue = String(this.getCellValue(ri, ci) || '')\n\n this.$nextTick(() => {\n const inputs = this.$refs.editInput\n if (inputs) {\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) {\n // col.mask: MaskInput owns the element's live formatting/caret while\n // typing and re-dispatches a corrected native 'input' event after\n // every keystroke, which the existing v-model=\"editValue\" binding\n // picks up normally — no separate sync code needed. It also masks\n // whatever value is already in the input (seeded above) on attach.\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (col.mask) {\n const options = (typeof col.mask === 'string' || Array.isArray(col.mask)) ? { mask: col.mask } : col.mask\n this._maskInput = new MaskInput(input, options)\n }\n input.focus()\n if (keepValue) {\n input.setSelectionRange(input.value.length, input.value.length)\n }\n }\n }\n })\n },\n\n commitEdit() {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n\n // editValue is already masked live by MaskInput (see startEditing) — this\n // is a defensive final pass, cheap/idempotent, and covers any commit that\n // skipped the live-typing path (e.g. programmatic editValue changes).\n const value = this._coerceCellValue(col, this._applyColMask(col, this.editValue))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n cancelEdit() {\n clearTimeout(this._onInputTimer)\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Fires while the user is actively typing in the plain-text edit input —\n // debounced (col.debounceMs, default 400ms) so col.onInput isn't called\n // on every keystroke. `row` is the pre-edit row (not yet committed);\n // `value` is the live in-progress text.\n onEditInput(e) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n this._scheduleOnInput(row, col, e.target.value)\n },\n\n _scheduleOnInput(ri, ci, value) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n clearTimeout(this._onInputTimer)\n this._onInputPending = true\n const delay = typeof col.debounceMs === 'number' ? col.debounceMs : 400\n this._onInputTimer = setTimeout(() => {\n this._onInputPending = false\n const row = this._dataRaw[ri]\n if (row) {\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row, ri, col)\n // col.onInput is a common place for a host to derive/write another\n // cell's value on the same (already-reactive) row object — but cell\n // rendering reads through the non-reactive _dataRaw (see created()),\n // so an in-place mutation like that is otherwise invisible until the\n // next time `data` itself gets a new reference (e.g. on commit).\n this.$forceUpdate()\n }\n }, delay)\n },\n\n // Cancels any pending debounced call and fires col.onInput immediately —\n // used at commit points (Enter/blur, date pick, checkbox toggle) so a\n // still-in-flight debounce timer never drops the final value. If nothing\n // is pending (the debounce already fired naturally with this exact\n // value), skip re-firing — commitEdit() calls this unconditionally, so\n // without this guard a slow typist would get onInput called twice for\n // the same settled value.\n _flushOnInput(ri, ci, value, row) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n const wasPending = this._onInputPending\n clearTimeout(this._onInputTimer)\n this._onInputPending = false\n const last = this._lastFiredOnInput\n if (!wasPending && last && last.ri === ri && last.ci === ci && last.value === value) return\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row || this._dataRaw[ri], ri, col)\n // See _scheduleOnInput — a same-tick mutation to another cell on `row`\n // needs its own forceUpdate, since it happens after the data-watcher's\n // own forceUpdate (triggered by the commit that led here) already ran.\n this.$forceUpdate()\n },\n\n hasCellSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['cell-' + col.key])\n },\n hasLegacyEditSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['edit-' + col.key])\n },\n hasPrefixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['prefix-' + col.key])\n },\n hasSuffixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['suffix-' + col.key])\n },\n _hasCustomEdit(col) {\n // For select/treeSelect/date: cell-{key} slot only customizes display; picker still opens normally\n if (col.type === 'select' || col.type === 'treeSelect' || col.type === 'date') {\n return this.hasLegacyEditSlot(col)\n }\n return this.hasCellSlot(col) || this.hasLegacyEditSlot(col)\n },\n\n commitEditSlot(value) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n value = this._coerceCellValue(col, this._applyColMask(col, value))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // CHECKBOX CELLS\n // =========================================================\n\n isCheckboxChecked(ri, ci) {\n const v = this.getCellValue(ri, ci)\n if (v === true || v === 1) return true\n if (typeof v === 'string') return ['true', '1'].includes(v.trim().toLowerCase())\n return false\n },\n\n setCheckboxValue(ri, ci, value) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (!this.isCellValueValid(ri, ci, value)) {\n this._showInvalidCell(ri, ci, value)\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[ri] = { ...oldData[ri], [col.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(ri, ci, value, newData[ri])\n },\n\n toggleCheckbox(ri, ci) {\n this.setCheckboxValue(ri, ci, !this.isCheckboxChecked(ri, ci))\n },\n\n // =========================================================\n // ACTION CELLS (col.type === 'action')\n // =========================================================\n\n // Dynamic v-on argument expressions can't contain quotes/spaces in Vue 2.6\n // templates, so the 'click' fallback is resolved here instead of inline.\n actionHandlerEvent(col) {\n return (col && col.handlerType) || 'click'\n },\n\n triggerCellAction(ri, ci, e) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || col.type !== 'action' || typeof col.handler !== 'function') return\n if (!this.isCellEditable(ri, ci)) return\n col.handler(this._dataRaw[ri], ri, col, ci, e)\n },\n\n // =========================================================\n // CELL EDITABILITY (3.17)\n // =========================================================\n\n isCellEditable(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return false\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellEditable === 'function') return !!this.cellEditable(row, ri, col, ci)\n if (typeof col.editable === 'function') return !!col.editable(row, ri)\n if (col.editable === false) return false\n return true\n },\n\n // =========================================================\n // CELL VALUE VALIDATION (cellValidator)\n // =========================================================\n\n isCellValueValid(ri, ci, value) {\n if (typeof this.cellValidator !== 'function') return true\n const col = this.leafCols[ci]\n if (!col) return true\n const row = this._dataRaw[ri] || {}\n return !!this.cellValidator(value, row, ri, col, ci)\n },\n\n _resolveValidationMessage(ri, ci, value) {\n const col = this.leafCols[ci]\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellValidationMessage === 'function') return this.cellValidationMessage(value, row, ri, col, ci)\n if (typeof this.cellValidationMessage === 'string' && this.cellValidationMessage) return this.cellValidationMessage\n return this.t.invalidValue\n },\n\n // Find the on-screen rect of the cell currently being committed — the edit\n // input if still mounted (commitEdit, before editingCell is cleared), else\n // the active cell element (checkbox/select commits, which never enter edit mode).\n _invalidCellRect() {\n const inputs = this.$refs.editInput\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) return input.getBoundingClientRect()\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n return el ? el.getBoundingClientRect() : null\n },\n\n _showInvalidCell(ri, ci, value) {\n const message = this._resolveValidationMessage(ri, ci, value)\n this.invalidCell = { row: ri, col: ci, message, rect: this._invalidCellRect() }\n this.$emit('cell-validation-error', { row: ri, col: ci, value, message })\n },\n\n // Resolve+emit messages for a batch of rejected { row, col, value } entries\n // from a bulk write (paste / fill / clear). blocked=true means the whole\n // operation was aborted (strictBulkValidation); false means these cells\n // were skipped but the rest of the write still applied.\n _emitBulkValidationError(skipped, blocked) {\n const cells = skipped.map(c => ({\n row: c.row, col: c.col, value: c.value,\n message: this._resolveValidationMessage(c.row, c.col, c.value)\n }))\n this.$emit('bulk-validation-error', { blocked, cells })\n },\n\n // =========================================================\n // SELECT DROPDOWN (3.16) + TREE SELECT (3.22)\n // =========================================================\n\n isSelectLikeCol(col) {\n return !!(col && (col.type === 'select' || col.type === 'treeSelect'))\n },\n\n treeOptionKeys(col) {\n return {\n valueKey: col.optionValueKey || 'value',\n labelKey: col.optionLabelKey || 'label',\n childrenKey: col.optionChildrenKey || 'children'\n }\n },\n\n // Returns { [colKey]: 'left'|'right' } for columns that declare col.pin.\n _colPinMap(cols) {\n const map = {}\n ;(cols || []).forEach(col => {\n if (col.pin === 'left' || col.pin === 'right') map[col.key] = col.pin\n })\n return map\n },\n\n _treeFindLabel(nodes, keys, value) {\n for (const node of (nodes || [])) {\n // A malformed col.options (e.g. a host array with a hole/undefined entry,\n // often from an unguarded Array.prototype.find() result spread back in)\n // shouldn't crash the whole grid's render — skip it like a missing option.\n if (!node) continue\n if (String(node[keys.valueKey]) === value) {\n return node[keys.labelKey] != null ? node[keys.labelKey] : String(node[keys.valueKey])\n }\n const found = this._treeFindLabel(node[keys.childrenKey], keys, value)\n if (found != null) return found\n }\n return null\n },\n\n // col.options may be a static array OR a function (row, rowIndex, column, colIndex) => array,\n // letting hosts return a different option list per cell.\n hasOptionsSource(col) {\n return !!(col && (Array.isArray(col.options) || typeof col.options === 'function'))\n },\n\n resolveOptionsArray(col, ri, ci) {\n if (!col) return []\n if (typeof col.options === 'function') {\n const row = ri != null ? (this._dataRaw[ri] || {}) : {}\n const result = col.options(row, ri, col, ci != null ? ci : col._flatIndex)\n return Array.isArray(result) ? result : []\n }\n return Array.isArray(col.options) ? col.options : []\n },\n\n colOptions(ci, ri) {\n const col = this.leafCols[ci]\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return []\n // optionValueKey/optionLabelKey apply to type:'select' the same way they\n // already do for type:'treeSelect' — same config, same defaults.\n const { valueKey, labelKey } = this.treeOptionKeys(col)\n return raw.map(o =>\n typeof o === 'string' ? { value: o, label: o } : { value: o[valueKey], label: o[labelKey] != null ? o[labelKey] : String(o[valueKey]), color: o.color }\n )\n },\n\n getSelectLabel(ci, value, ri) {\n const col = this.leafCols[ci]\n const v = value == null ? '' : String(value)\n if (col && col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return v\n const found = this._treeFindLabel(raw, this.treeOptionKeys(col), v)\n return found != null ? found : v\n }\n const opts = this.colOptions(ci, ri)\n if (!opts.length) return v\n const found = opts.find(o => String(o.value) === v)\n return found ? found.label : v\n },\n\n _buildSelectDropdownState(ri, ci, col, currentValue, rect) {\n const rectObj = { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n return {\n row: ri, col: ci,\n tree: col.type === 'treeSelect',\n // valueKey/labelKey apply to both select and treeSelect (childrenKey\n // only matters for the tree dropdown, harmlessly unused otherwise).\n keys: this.treeOptionKeys(col),\n rawOptions: this.resolveOptionsArray(col, ri, ci),\n currentValue,\n rect: rectObj,\n // Defaults to on (opt out with enableSearch: false) — same convention\n // as clearable below.\n enableSearch: col.enableSearch !== false,\n clearable: col.clearable !== false\n }\n },\n\n openSelectDropdown(ri, ci, e) {\n const col = this.leafCols[ci]\n if (!this.hasOptionsSource(col)) return\n const el = e.currentTarget\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(ri, ci) != null ? this.getCellValue(ri, ci) : '')\n this.selectDropdown = this._buildSelectDropdownState(ri, ci, col, currentValue, rect)\n },\n\n openSelectDropdownForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!this.hasOptionsSource(colDef)) return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(row, col) != null ? this.getCellValue(row, col) : '')\n this.selectDropdown = this._buildSelectDropdownState(row, col, colDef, currentValue, rect)\n })\n },\n\n onComponentSelect({ value, label }) {\n if (this.internalLocked) { this.closeSelectDropdown(); return }\n if (!this.selectDropdown) return\n const { row, col } = this.selectDropdown\n const colDef = this.leafCols[col]\n if (colDef) {\n // Clearing (value === '') always goes through, same as onDatePick()'s\n // empty-string bypass — \"no selection\" is a valid state regardless of\n // what cellValidator requires of a real picked value.\n if (value !== '' && !this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.closeSelectDropdown()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n if (typeof colDef.onSelect === 'function') {\n // Deferred the same way as _fireCellCallbacksForRange (paste/fill/drag-move) —\n // a host watching the emitted data (e.g. mirroring it into its own\n // store) needs that watcher to run first, or onSelect reads stale state.\n this.$nextTick(() => {\n colDef.onSelect({ value, label }, newData[row], row, colDef)\n // See _scheduleOnInput — picks up any same-tick mutation the callback\n // made to another cell on this (already-reactive) row object.\n this.$forceUpdate()\n })\n }\n }\n this.closeSelectDropdown()\n },\n\n // Always returns focus to the grid wrapper — the dropdown (especially with\n // enableSearch) can hold real DOM focus on its own search input, and every\n // close path (Escape, outside click, picking a value) must hand focus back\n // or keyboard navigation on the grid goes dead until the user clicks a cell.\n closeSelectDropdown() {\n this.selectDropdown = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // DATE PICKER\n // =========================================================\n\n getCellDisplayValue(ci, val, ri) {\n const col = this.leafCols[ci]\n if (!col) return val == null ? '' : val\n if (typeof col.formatter === 'function') {\n const out = col.formatter(val, this._dataRaw[ri], ri, col, ci)\n return out == null ? '' : out\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, val, ri)\n if (col.type === 'date') {\n const d = parseDateValue(val)\n return d ? formatDate(d, col.format || DEFAULT_DATE_FORMAT) : (val == null ? '' : val)\n }\n return val == null ? '' : val\n },\n\n // =========================================================\n // FILTER ROW\n // =========================================================\n\n // Which control the filter cell renders for this column.\n // 'none' cells still render (empty) so column alignment stays intact.\n filterControlType(col) {\n if (!col || col.filterable === false || col.type === 'action') return 'none'\n if (col.type === 'checkbox') return 'checkbox'\n // Function-based options can't be resolved into a fixed list outside a\n // specific row, so those columns fall back to the text control.\n if ((col.type === 'select' || col.type === 'treeSelect') && Array.isArray(col.options)) return 'select'\n return 'text'\n },\n\n // Flat { value, label } list for a select-control filter cell. treeSelect\n // options are recursively flattened, each depth indented for readability.\n filterOptionsFor(col) {\n if (!col) return []\n if (col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, null, col._flatIndex)\n const out = []\n this._flattenTreeOptionsForFilter(raw, this.treeOptionKeys(col), 0, out)\n return out\n }\n return this.colOptions(col._flatIndex, null)\n },\n\n _flattenTreeOptionsForFilter(nodes, keys, depth, out) {\n for (const node of (nodes || [])) {\n const value = node[keys.valueKey]\n const label = node[keys.labelKey] != null ? node[keys.labelKey] : String(value)\n out.push({ value: String(value), label: '  '.repeat(depth) + label })\n this._flattenTreeOptionsForFilter(node[keys.childrenKey], keys, depth + 1, out)\n }\n },\n\n getFilterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n return {\n position: 'absolute',\n left: left + 'px',\n top: (this._headerRowH * this.headerDepth) + 'px',\n width: w + 'px',\n height: this._filterRowH + 'px',\n boxSizing: 'border-box'\n }\n },\n\n setFilter(key, value) {\n if (!this.leafCols.some(c => c.key === key)) return\n const next = { ...this.internalFilters }\n if (value === '' || value === null || value === undefined) {\n delete next[key]\n } else {\n next[key] = value\n }\n this.internalFilters = next\n this.$emit('update:filters', { ...next })\n this.$emit('filter-change', { key, value, filters: { ...next } })\n },\n\n clearFilters() {\n if (!Object.keys(this.internalFilters).length) return\n this.internalFilters = {}\n // A deliberate, infrequent action (unlike typing) — recompute immediately\n // rather than waiting out the debounce, and drop any timer already pending.\n clearTimeout(this._filterRecomputeTimer)\n this._recomputeRowFilters()\n this.$emit('update:filters', {})\n this.$emit('filter-change', { key: null, value: null, filters: {} })\n },\n\n // Debounced — recomputing filteredRowIndices scans every row, so typing\n // in the text filter shouldn't trigger it on every keystroke.\n _scheduleFilterRecompute() {\n clearTimeout(this._filterRecomputeTimer)\n this._filterRecomputeTimer = setTimeout(() => { this._recomputeRowFilters() }, 150)\n },\n\n _activeFilterCols() {\n const cols = []\n for (const col of this.leafCols) {\n if (this.filterControlType(col) === 'none') continue\n const v = this.internalFilters[col.key]\n if (v === undefined || v === null || v === '') continue\n cols.push(col)\n }\n return cols\n },\n\n _cellMatchesFilter(row, ri, col) {\n const ci = col._flatIndex\n const filterValue = this.internalFilters[col.key]\n if (typeof col.filterFn === 'function') {\n return !!col.filterFn(this.getCellValue(ri, ci), filterValue, row, ri, col)\n }\n const kind = this.filterControlType(col)\n if (kind === 'checkbox') {\n const checked = this.isCheckboxChecked(ri, ci)\n return filterValue === 'true' ? checked : !checked\n }\n if (kind === 'select') {\n const raw = this.getCellValue(ri, ci)\n return String(raw == null ? '' : raw) === String(filterValue)\n }\n const display = this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri)\n return String(display == null ? '' : display).toLowerCase().indexOf(String(filterValue).toLowerCase()) !== -1\n },\n\n _recomputeRowFilters() {\n // Can be reached (via the `columns`/`filterRow` watchers) during initWatch(),\n // before created() has set up _dataRaw — getCellValue() depends on it. The\n // `data` watcher's own immediate handler sets _dataRaw first, then always\n // calls this again, so the initial mount still ends up correct.\n if (!this._dataRaw) return\n if (!this.filterRow) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const activeCols = this._activeFilterCols()\n if (!activeCols.length) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const rows = this.data\n const out = []\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n let matches = true\n for (const col of activeCols) {\n if (!this._cellMatchesFilter(row, ri, col)) { matches = false; break }\n }\n if (!matches) out.push(ri)\n }\n this.filteredRowIndices = out\n },\n\n // =========================================================\n // COLUMN SORTING\n // =========================================================\n\n isColSortable(col) {\n return !!(this.sortable && col && col.sortable !== false && col.type !== 'action')\n },\n\n sortIconClass(col) {\n const active = !!(this.internalSort && this.internalSort.key === col.key)\n return active ? 'eg-icon-sort-menu eg-sort-icon-active' : 'eg-icon-sort-menu'\n },\n\n sortIconTitle() {\n return this.t.sortMenuTooltip\n },\n\n // Opens the sort popup menu (A → Z / Z → A / Cancel) anchored to the\n // clicked header icon. Replaces the old direct cycle-on-click behavior.\n openSortMenu(e, col) {\n if (this.internalLocked) return\n this.sortMenu = { visible: true, x: e.clientX, y: e.clientY, key: col.key }\n this.sortMenuPositioned = false\n this.$nextTick(this._positionSortMenu)\n },\n\n // Same flip-to-fit reasoning as _positionContextMenu().\n _positionSortMenu() {\n const el = this.$refs.sortMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.sortMenu.x\n let y = this.sortMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.sortMenu.x = x\n this.sortMenu.y = y\n this.sortMenuPositioned = true\n },\n\n hideSortMenu() {\n this.sortMenu.visible = false\n },\n\n // Marks whichever direction is currently applied to the menu's own\n // column so the popup shows which option is active (checkmark, see CSS).\n isSortMenuOptionActive(direction) {\n return !!(\n this.internalSort &&\n this.sortMenu.key &&\n this.internalSort.key === this.sortMenu.key &&\n this.internalSort.direction === direction\n )\n },\n\n sortMenuPickAsc() {\n this.setSort(this.sortMenu.key, 'asc')\n this.hideSortMenu()\n },\n\n sortMenuPickDesc() {\n this.setSort(this.sortMenu.key, 'desc')\n this.hideSortMenu()\n },\n\n sortMenuPickClear() {\n this.clearSort()\n this.hideSortMenu()\n },\n\n // Cycles a column through none → asc → desc → none. Kept as a public\n // method for programmatic/custom #header-{key} slot use — the built-in\n // header icon now opens the popup menu (openSortMenu) instead.\n toggleSort(key) {\n if (this.internalLocked) return\n const current = this.internalSort\n if (!current || current.key !== key) { this.setSort(key, 'asc'); return }\n if (current.direction === 'asc') { this.setSort(key, 'desc'); return }\n this.clearSort()\n },\n\n setSort(key, direction) {\n if (direction !== 'asc' && direction !== 'desc') { this.clearSort(); return }\n this.sortByColumn(key, direction)\n },\n\n // Clicking a third time (or calling this directly) clears the sort\n // indicator AND restores the row order from immediately before the\n // first sort of this session (see _preSortData in sortByColumn()) —\n // switching columns or direction mid-session doesn't move that\n // reference point, so this always lands back on the true original\n // order. Emitted as a normal data change (_emitDataChange), so it's\n // itself undoable/redoable like any other edit.\n clearSort() {\n if (!this.internalSort) return\n this.internalSort = null\n const oldData = this.data\n const restored = this._preSortData\n this._preSortData = null\n // Row count mismatch means rows were added/removed since the sort —\n // the snapshot no longer lines up with the current data, so skip the\n // restore rather than silently drop/duplicate rows (indicator still\n // clears either way).\n if (restored && restored.length === oldData.length) {\n this._emitDataChange(oldData, restored)\n }\n this.$emit('update:sort', null)\n this.$emit('sort-change', { key: null, direction: null })\n },\n\n sortByColumn(key, direction) {\n if (this.internalLocked) return\n // Merges and row groups are anchored to row *position* — a reorder\n // would silently invalidate them, so sorting is blocked while either\n // exists (same \"blocked while locked\" no-op convention as elsewhere).\n if (this.internalMerges.length || this.rowGroups.length) return\n const col = this.leafCols.find(c => c.key === key)\n if (!this.isColSortable(col)) return\n\n const oldData = this.data\n // Remember the order from before the *first* sort of this session —\n // re-sorting the same or a different column (asc<->desc, or switching\n // columns) without clearing first must not move this reference point,\n // so clearSort() always lands back on the true original order.\n if (!this.internalSort) this._preSortData = oldData\n const pairs = oldData.map((row, ri) => ({ row, ri }))\n pairs.sort((a, b) => this._compareForSort(a.row, a.ri, b.row, b.ri, col, direction))\n const newData = pairs.map(p => p.row)\n\n this.internalSort = { key, direction }\n this._emitDataChange(oldData, newData)\n this.$emit('update:sort', { key, direction })\n this.$emit('sort-change', { key, direction })\n\n this.scrollTop = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) this.$refs.scrollBody.scrollTop = 0\n })\n },\n\n _sortValueFor(row, ri, col) {\n const ci = col._flatIndex\n if (col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? 1 : 0\n if (col.type === 'date') {\n const d = parseDateValue(this.getCellValue(ri, ci))\n return d ? d.getTime() : null\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, this.getCellValue(ri, ci), ri)\n return this.getCellValue(ri, ci)\n },\n\n _compareForSort(rowA, riA, rowB, riB, col, direction) {\n if (typeof col.sortFn === 'function') {\n const result = col.sortFn(rowA, rowB, col)\n return direction === 'desc' ? -result : result\n }\n const a = this._sortValueFor(rowA, riA, col)\n const b = this._sortValueFor(rowB, riB, col)\n const aEmpty = a === null || a === undefined || a === ''\n const bEmpty = b === null || b === undefined || b === ''\n if (aEmpty && bEmpty) return 0\n if (aEmpty) return 1\n if (bEmpty) return -1\n const cmp = (typeof a === 'number' && typeof b === 'number')\n ? a - b\n : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n return direction === 'desc' ? -cmp : cmp\n },\n\n openDatePickerForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!colDef || colDef.type !== 'date') return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const val = this.getCellValue(row, col)\n this.datePickerOverlay = {\n row, col,\n format: colDef.format || DEFAULT_DATE_FORMAT,\n value: val == null ? '' : String(val),\n rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n }\n })\n },\n\n onDatePick(formattedVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n const colDef = this.leafCols[col]\n if (colDef) {\n if (formattedVal !== '' && !this.isCellValueValid(row, col, formattedVal)) {\n this._showInvalidCell(row, col, formattedVal)\n this.closeDatePicker()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: formattedVal }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, formattedVal, newData[row])\n }\n this.closeDatePicker()\n },\n\n // Raw keystrokes while typing in the date picker's manual text field —\n // debounced, same as onEditInput(). onDatePick() above flushes the final,\n // validated value immediately when the date actually commits.\n onDateTyping(rawVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n this._scheduleOnInput(row, col, rawVal)\n },\n\n // See closeSelectDropdown() — same reasoning: every close path must return\n // focus to the grid wrapper or keyboard navigation goes dead afterward.\n closeDatePicker() {\n clearTimeout(this._onInputTimer)\n this.datePickerOverlay = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n _onWindowScrollForDropdown(e) {\n if (this.contextMenu.visible) this.hideContextMenu()\n if (this.sortMenu.visible) this.hideSortMenu()\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n _onDocMouseDownForDropdown(e) {\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n onEditKeyDown(e) {\n if (e.key === 'Escape') {\n e.preventDefault()\n this.cancelEdit()\n } else if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(1, 0, e)\n } else if (e.key === 'Tab') {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n }\n },\n\n clearSelection() {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n let hasChanges = false\n const skipped = []\n for (const range of this.selectionRanges) {\n const r = {\n startRow: Math.min(range.startRow, range.endRow),\n endRow: Math.max(range.startRow, range.endRow),\n startCol: Math.min(range.startCol, range.endCol),\n endCol: Math.max(range.startCol, range.endCol)\n }\n for (let ri = r.startRow; ri <= r.endRow && ri < newData.length; ri++) {\n let rowUpdated = null\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n if (!this.isCellValueValid(ri, ci, '')) { skipped.push({ row: ri, col: ci, value: '' }); continue }\n if (!rowUpdated) rowUpdated = { ...newData[ri] }\n rowUpdated[col.key] = ''\n hasChanges = true\n }\n }\n if (rowUpdated) newData[ri] = rowUpdated\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n if (hasChanges) this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // FILL HANDLE (3.3)\n // =========================================================\n startFillDrag(e) {\n if (this.internalLocked) return\n const pr = this.primaryRange\n if (!pr) return\n this.fillDrag.active = true\n this.fillDrag.sourceRange = normalizeRange(pr)\n this.fillDrag.currentRow = pr.endRow\n this.fillDrag.currentCol = pr.endCol\n e.preventDefault()\n },\n\n onGlobalMouseMove(e) {\n // Detect column drag threshold (5 px)\n if (this.colDrag.pending) {\n if (Math.abs(e.clientX - this.colDrag.startX) > 5) {\n this.colDrag.pending = false\n this.colDrag.active = true\n this.colDrag.dropIndex = this.colDrag.sourceFirstLeaf\n this.isMouseDown = false // cancel cell-selection drag\n this._startColDrag()\n }\n }\n if (this.colDrag.active) {\n this.handleColDragMove(e)\n return\n }\n\n if (this.resizing.active) {\n this.handleResizeMove(e)\n return\n }\n if (this.fillDrag.active) {\n this.handleFillDragMove(e)\n return\n }\n if (this.dragMove.active) {\n this.handleDragMoveMove(e)\n return\n }\n },\n\n onGlobalMouseUp(e) {\n if (this.colDrag.active) {\n this.commitColDrag()\n return\n }\n if (this.colDrag.pending) {\n this._resetColDrag()\n // fall through — normal mouseup\n }\n\n if (this.resizing.active) {\n this.commitResize()\n return\n }\n if (this.fillDrag.active) {\n this.commitFillDrag()\n return\n }\n if (this.dragMove.active) {\n this.commitDragMove(e)\n return\n }\n this.isMouseDown = false\n },\n\n // Resolves which (row, col) the mouse is currently over, given the\n // scroll body's CURRENT scrollTop/scrollLeft — called both directly from\n // mousemove and repeatedly from the edge-auto-scroll rAF loop below (as\n // scrollTop changes on its own, the row under a STATIONARY cursor changes\n // with it).\n _fillDragTargetFromEvent(e) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return null\n const rect = scrollBody.getBoundingClientRect()\n const relY = e.clientY - rect.top + scrollBody.scrollTop\n const relX = e.clientX - rect.left + scrollBody.scrollLeft\n\n // Binary search on cumulativeHeights — O(log n) instead of O(n)\n const cum = this.cumulativeHeights\n const total = this.totalRows\n let lo = 0, hi = total - 1\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if (cum[mid + 1] <= relY) lo = mid + 1\n else hi = mid\n }\n const row = Math.min(lo, total - 1)\n\n // Columns are few — O(columns) is fast\n let left = this.getFrozenWidth()\n let col = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColumnScrollable(i)) continue\n left += this.getColW(i)\n if (relX < left) { col = i; break }\n col = i\n }\n return { row, col }\n },\n\n handleFillDragMove(e) {\n this._fillDragLastEvent = e\n const target = this._fillDragTargetFromEvent(e)\n if (!target) return\n this.fillDrag.currentRow = target.row\n this.fillDrag.currentCol = target.col\n // Covers the common case of a single big mousemove jump landing the\n // cursor (and its computed target row/col) outside the visible window.\n this.scrollToCell(target.row, target.col)\n // Covers the — far more common in practice — case of the cursor\n // PINNED at the container's edge (it usually can't travel further,\n // since the grid typically fills its host layout down to that edge):\n // keep scrolling for as long as it stays there. Same rAF/edge-zone\n // pattern as _updateDragAutoScroll (column-drag reordering) below.\n this._updateFillDragAutoScroll(e.clientX, e.clientY)\n },\n\n // Edge auto-scroll for fill-handle dragging — vertical AND horizontal\n // (column-drag's _updateDragAutoScroll is horizontal-only). Speed scales\n // with how deep into the 24px edge zone the cursor sits.\n _updateFillDragAutoScroll(clientX, clientY) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._fillDragScrollV = 0; this._fillDragScrollH = 0; return }\n const rect = sb.getBoundingClientRect()\n const ZONE = FILL_DRAG_EDGE_ZONE\n\n let v = 0\n if (clientY < rect.top + ZONE) v = -Math.ceil((rect.top + ZONE - clientY) / 4)\n else if (clientY > rect.bottom - ZONE) v = Math.ceil((clientY - (rect.bottom - ZONE)) / 4)\n this._fillDragScrollV = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, v))\n\n let h = 0\n if (clientX < rect.left + ZONE) h = -Math.ceil((rect.left + ZONE - clientX) / 4)\n else if (clientX > rect.right - ZONE) h = Math.ceil((clientX - (rect.right - ZONE)) / 4)\n this._fillDragScrollH = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, h))\n\n if ((this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) && !this._fillDragScrollRAF) {\n const step = () => {\n this._fillDragScrollRAF = null\n if (!this.fillDrag.active) return\n const body = this.$refs.scrollBody\n if (body) {\n if (this._fillDragScrollV) body.scrollTop = Math.max(0, Math.min(body.scrollHeight - body.clientHeight, body.scrollTop + this._fillDragScrollV))\n if (this._fillDragScrollH) body.scrollLeft = Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollLeft + this._fillDragScrollH))\n // The cursor itself hasn't moved, but the content under it has —\n // re-derive the fill target at the new scroll position.\n if (this._fillDragLastEvent) {\n const target = this._fillDragTargetFromEvent(this._fillDragLastEvent)\n if (target) { this.fillDrag.currentRow = target.row; this.fillDrag.currentCol = target.col }\n }\n }\n if (this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopFillDragAutoScroll() {\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragLastEvent = null\n if (this._fillDragScrollRAF) {\n cancelAnimationFrame(this._fillDragScrollRAF)\n this._fillDragScrollRAF = null\n }\n },\n\n async commitFillDrag() {\n this._stopFillDragAutoScroll()\n if (this.internalLocked) { this.fillDrag.active = false; return }\n const src = this.fillDrag.sourceRange\n if (!src) { this.fillDrag.active = false; return }\n\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n\n const oldData = this.data\n const newData = oldData.slice() // only affected rows get new objects\n let targetRange\n const skipped = []\n\n if (targetRow > src.endRow) {\n // Fill down\n const count = targetRow - src.endRow\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n targetRange = { startRow: src.endRow + 1, endRow: targetRow, startCol: src.startCol, endCol: src.endCol }\n } else if (targetCol > src.endCol) {\n // Fill right\n const count = targetCol - src.endCol\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n const srcValues = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n srcValues.push(col && oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ci = src.endCol + 1 + k\n if (ci < this.leafCols.length) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n }\n targetRange = { startRow: src.startRow, endRow: src.endRow, startCol: src.endCol + 1, endCol: targetCol }\n }\n\n let fireOnSelect = false\n if (targetRange) {\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n } else {\n this._emitDataChange(oldData, newData)\n this.$emit('fill', { sourceRange: src, targetRange, values: newData })\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n fireOnSelect = true\n }\n }\n\n this.fillDrag.active = false\n this.fillDrag.sourceRange = null\n\n if (fireOnSelect) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n doubleFillDown() {\n const pr = this.primaryRange\n if (!pr) return\n const src = normalizeRange(pr)\n // Find adjacent column filled length\n const adjCol = src.startCol > 0 ? src.startCol - 1 : src.endCol + 1\n if (adjCol < 0 || adjCol >= this.leafCols.length) return\n const adjColDef = this.leafCols[adjCol]\n if (!adjColDef) return\n let lastFilledRow = src.endRow\n for (let ri = src.endRow + 1; ri < this.totalRows; ri++) {\n if (this.data[ri] && this.data[ri][adjColDef.key] !== '' && this.data[ri][adjColDef.key] !== undefined) {\n lastFilledRow = ri\n } else break\n }\n if (lastFilledRow <= src.endRow) return\n\n const count = lastFilledRow - src.endRow\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // Ctrl+D / Ctrl+R — Excel's standard fill-down/fill-right (3.96)\n // =========================================================\n // Plain copy, NOT the fill-handle's series continuation (generateFillValues,\n // 1,2,3->4,5,6) — matches real Excel: Ctrl+D/Ctrl+R just duplicates a row/\n // column's values, it never extrapolates a pattern.\n async _fillAxis(direction) {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n const appliedRanges = []\n\n for (const range of this.selectionRanges) {\n const r = normalizeRange(range)\n if (direction === 'down') {\n let srcRow, firstTargetRow, lastTargetRow\n if (r.startRow === r.endRow) {\n // Single-row selection: Excel fills from the row directly above.\n srcRow = r.startRow - 1\n if (srcRow < 0) continue\n firstTargetRow = r.startRow\n lastTargetRow = r.startRow\n } else {\n srcRow = r.startRow\n firstTargetRow = r.startRow + 1\n lastTargetRow = r.endRow\n }\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcVal = oldData[srcRow] ? oldData[srcRow][col.key] : ''\n for (let ri = firstTargetRow; ri <= lastTargetRow; ri++) {\n if (!this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: srcRow, endRow: srcRow, startCol: r.startCol, endCol: r.endCol },\n targetRange: { startRow: firstTargetRow, endRow: lastTargetRow, startCol: r.startCol, endCol: r.endCol }\n })\n } else {\n let srcCol, firstTargetCol, lastTargetCol\n if (r.startCol === r.endCol) {\n // Single-column selection: Excel fills from the column directly to the left.\n srcCol = r.startCol - 1\n if (srcCol < 0) continue\n firstTargetCol = r.startCol\n lastTargetCol = r.startCol\n } else {\n srcCol = r.startCol\n firstTargetCol = r.startCol + 1\n lastTargetCol = r.endCol\n }\n const srcColDef = this.leafCols[srcCol]\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n const srcVal = srcColDef && oldData[ri] ? oldData[ri][srcColDef.key] : ''\n for (let ci = firstTargetCol; ci <= lastTargetCol; ci++) {\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: r.startRow, endRow: r.endRow, startCol: srcCol, endCol: srcCol },\n targetRange: { startRow: r.startRow, endRow: r.endRow, startCol: firstTargetCol, endCol: lastTargetCol }\n })\n }\n }\n\n if (!appliedRanges.length) return\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n this._emitDataChange(oldData, newData)\n for (const ar of appliedRanges) {\n this.$emit('fill', { sourceRange: ar.sourceRange, targetRange: ar.targetRange, values: newData })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n\n // See pasteAt's/commitFillDrag's _fireCellCallbacksForRange call — same\n // nextTick deferral reasoning (col.onInput/onSelect after the DOM settles).\n await this.$nextTick()\n for (const ar of appliedRanges) {\n this._fireCellCallbacksForRange(oldData, newData, ar.targetRange.startRow, ar.targetRange.endRow, ar.targetRange.startCol, ar.targetRange.endCol)\n }\n },\n\n fillDown() { return this._fillAxis('down') },\n fillRight() { return this._fillAxis('right') },\n\n // =========================================================\n // COPY / PASTE (3.4)\n // =========================================================\n copySelection() {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n this.clipboardMatrix = extractMatrix(this.data, this.leafCols, r)\n this.clipboardRange = r\n this.clipboardMode = 'copy'\n\n // Write to system clipboard as TSV\n const tsv = matrixToTSV(this.clipboardMatrix)\n if (navigator.clipboard) {\n navigator.clipboard.writeText(tsv).catch(() => {})\n }\n },\n\n cutSelection() {\n this.copySelection()\n this.clipboardMode = 'cut'\n },\n\n // Bulk cell writes (paste, fill handle, drag-move) set row values directly\n // and skip the single-cell paths (onComponentSelect / commitEdit /\n // setCheckboxValue / onDatePick) — so col.onSelect / col.onInput never\n // fire for them unless we replay it here for every cell whose value\n // actually changed in the range. select/treeSelect columns get onSelect\n // (matching the single-pick shape); everything else gets onInput —\n // fired immediately, no debounce (this is already a single bulk commit,\n // not live keystrokes).\n _fireCellCallbacksForRange(oldData, newData, startRow, endRow, startCol, endCol) {\n let fired = false\n for (let ri = startRow; ri <= endRow; ri++) {\n const oldRow = oldData[ri]\n const newRow = newData[ri]\n if (!newRow || newRow === oldRow) continue\n for (let ci = startCol; ci <= endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const oldVal = oldRow ? oldRow[col.key] : undefined\n const newVal = newRow[col.key]\n if (newVal === oldVal) continue\n if (this.isSelectLikeCol(col)) {\n if (typeof col.onSelect === 'function') {\n col.onSelect({ value: newVal, label: this.getSelectLabel(ci, newVal, ri) }, newRow, ri, col)\n fired = true\n }\n } else if (typeof col.onInput === 'function') {\n col.onInput(newVal, newRow, ri, col)\n fired = true\n }\n }\n }\n // See _scheduleOnInput — one forceUpdate covers the whole range instead of\n // one per cell (this can fire many callbacks for a large paste/fill range).\n if (fired) this.$forceUpdate()\n },\n\n async pasteAt(targetRow, targetCol, options = {}) {\n if (this.internalLocked) return\n const { mode = 'all', transpose = false } = options\n\n let matrix = null\n\n // Try system clipboard first\n if (navigator.clipboard) {\n try {\n const text = await navigator.clipboard.readText()\n if (text) matrix = tsvToMatrix(text)\n } catch (_) {}\n }\n\n // Fall back to internal clipboard\n if (!matrix && this.clipboardMatrix) {\n matrix = this.clipboardMatrix\n }\n\n if (!matrix) return\n\n if (transpose) matrix = transposeMatrix(matrix)\n\n // ── Excel-style selection broadcast / tiling ──────────────────────────\n // When pasting over a multi-cell selection and the clipboard matrix is\n // smaller than the selection, tile the matrix to fill the selection —\n // provided the selection dimensions are exact multiples of the matrix\n // dimensions (matches Excel's tiling rule exactly).\n // Cut-paste is never tiled (source must be cleared exactly once).\n const isCopyPaste = !this.clipboardMode || this.clipboardMode === 'copy'\n if (isCopyPaste) {\n const pr = this.primaryRange\n if (pr) {\n const sel = normalizeRange(pr)\n const selRows = sel.endRow - sel.startRow + 1\n const selCols = sel.endCol - sel.startCol + 1\n const matRows = matrix.length\n const matCols = matrix[0] ? matrix[0].length : 0\n\n // Only tile when selection is strictly larger than clipboard\n if ((selRows > matRows || selCols > matCols) &&\n selRows % matRows === 0 && selCols % matCols === 0) {\n matrix = tileMatrix(matrix, selRows, selCols)\n // Paste at selection origin, not just at the active cell\n targetRow = sel.startRow\n targetCol = sel.startCol\n }\n }\n }\n\n const oldData = this.data\n // pasteGrowsRows: pre-extend the working copy with blank rows so\n // applyMatrix's own data.length clamp naturally covers the full\n // pasted range instead of truncating it. `oldData` itself (used below\n // for _emitDataChange's undo/redo diff) stays untouched.\n let pasteBase = oldData\n let addedStartIndex = 0\n let addedCount = 0\n if (this.pasteGrowsRows) {\n const neededLength = targetRow + matrix.length\n if (neededLength > oldData.length) {\n const blankRows = []\n for (let i = oldData.length; i < neededLength; i++) {\n const row = {}\n this.leafCols.forEach(col => { row[col.key] = this._blankValueForCol(col) })\n if (this.pasteRowDefaults) {\n const extra = typeof this.pasteRowDefaults === 'function' ? this.pasteRowDefaults(i) : this.pasteRowDefaults\n if (extra) Object.assign(row, extra)\n }\n blankRows.push(row)\n }\n pasteBase = oldData.concat(blankRows)\n addedStartIndex = oldData.length\n addedCount = blankRows.length\n }\n }\n const { newData, affectedRange, skipped } = applyMatrix(\n pasteBase, this.leafCols, targetRow, targetCol, matrix, mode,\n (ri, ci) => this.isCellEditable(ri, ci),\n (ri, ci, v) => this.isCellValueValid(ri, ci, v),\n (ri, ci, v) => this._coerceCellValue(ci, this._applyColMask(ci, v))\n )\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n if (this.clipboardMode === 'cut' && this.clipboardRange) {\n // Clear source — ensure each source row is a new copy before mutation\n const src = this.clipboardRange\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci) && this.isCellValueValid(ri, ci, '')) {\n newData[ri][col.key] = ''\n }\n }\n }\n this.clipboardRange = null\n this.clipboardMode = null\n }\n\n this._emitDataChange(oldData, newData)\n // Fired once per paste operation (never per row), after 'input' — so by\n // the time hosts receive it, v-model already holds the final rows.\n // Lets hosts do a single aggregate action instead of reacting to\n // pasteRowDefaults' per-row calls.\n if (addedCount > 0) {\n this.$emit('rows-added', {\n startIndex: addedStartIndex,\n count: addedCount,\n rows: newData.slice(addedStartIndex, addedStartIndex + addedCount)\n })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n // Wait for Vue's reactivity queue to flush before firing onSelect — hosts\n // commonly react to the emitted 'input' with their own watcher (e.g. to\n // mirror the row into an external store); firing onSelect synchronously\n // would run it against that store's pre-paste, stale state.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, affectedRange.startRow, affectedRange.endRow, affectedRange.startCol, affectedRange.endCol)\n },\n\n ctxCopy() { this.hideContextMenu(); this.copySelection() },\n ctxCut() { this.hideContextMenu(); this.cutSelection() },\n ctxPaste() { this.hideContextMenu(); this.pasteAt(this.activeCell.row, this.activeCell.col) },\n ctxPasteSpecial(mode, transpose = false) {\n this.hideContextMenu()\n this.pasteAt(this.activeCell.row, this.activeCell.col, { mode, transpose })\n },\n\n // =========================================================\n // COLUMN DRAG-AND-DROP\n // =========================================================\n\n _computeDragOffsets(dropIndex) {\n const { sourceFirstLeaf, sourceLeafCount } = this.colDrag\n if (dropIndex < 0 || (dropIndex >= sourceFirstLeaf && dropIndex <= sourceFirstLeaf + sourceLeafCount)) return {}\n\n const previewCols = moveColumnNodeInTree(this.columns, sourceFirstLeaf, sourceLeafCount, dropIndex)\n const previewLeaves = flattenColumns(previewCols)\n\n const byKey = {}\n this.leafCols.forEach(c => { byKey[c.key] = c })\n\n const offsets = {}\n for (const zone of ['left', 'scroll', 'right']) {\n const inZone = zone === 'left'\n ? ci => this.isColumnPinnedLeft(ci)\n : zone === 'right'\n ? ci => this.isColumnPinnedRight(ci)\n : ci => this.isColumnScrollable(ci)\n\n const origPos = {}\n let acc = 0\n this.leafCols.forEach(col => {\n const ci = col._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) return\n origPos[col.key] = acc\n acc += this.getColW(ci)\n })\n\n const previewPos = {}\n acc = 0\n for (const col of previewLeaves) {\n const origCol = byKey[col.key]\n if (!origCol) continue\n const ci = origCol._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) continue\n previewPos[col.key] = acc\n acc += this.getColW(ci)\n }\n\n for (const key of Object.keys(origPos)) {\n offsets[key] = (previewPos[key] !== undefined ? previewPos[key] : origPos[key]) - origPos[key]\n }\n }\n return offsets\n },\n\n // Called once when the 5px movement threshold flips the drag active:\n // snapshots the original header geometry the whole drag will be measured\n // against, sizes the ghost from the grabbed header, and arms the CSS\n // transitions on every column element.\n _startColDrag() {\n const sb = this.$refs.scrollBody\n this._dragStartScrollLeft = sb ? sb.scrollLeft : 0\n const bounds = []\n if (this.$el) {\n this.$el.querySelectorAll('.eg-header-cell:not(.eg-header-group)').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n if (isNaN(fi)) return\n const rect = el.getBoundingClientRect()\n bounds.push({ fi, left: rect.left, right: rect.right, scrollable: this.isColumnScrollable(fi) })\n })\n }\n this._dragBounds = bounds\n this._dragScrollV = 0\n\n const hr = this._pendingDragHeaderRect\n if (hr && hr.width > 0) {\n this.colDrag.ghostWidth = Math.min(Math.round(hr.width), 320)\n this.colDrag.grabOffsetX = Math.min(\n Math.max(Math.round(this.colDrag.startX - hr.left), 12),\n this.colDrag.ghostWidth - 12\n )\n } else {\n this.colDrag.ghostWidth = 140\n this.colDrag.grabOffsetX = 70\n }\n\n this._setDragTransitions(true)\n },\n\n _setDragTransitions(on) {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = on ? COL_DRAG_TRANSITION : ''\n })\n },\n\n _applyDragTransforms(offsets) {\n if (!this.$el) return\n // Just retarget the transforms — the CSS transition set at drag start\n // animates each element from wherever it currently is. An explicit\n // translateX(0px) (not '') keeps returning columns gliding back instead\n // of snapping.\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n this.$el.querySelectorAll('[data-start]').forEach(el => {\n const start = parseInt(el.dataset.start)\n const end = parseInt(el.dataset.end)\n if (isNaN(start)) return\n let key = null\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n const dx = key ? (offsets[key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n },\n\n // Instant cleanup: transition and transform drop in the same style write,\n // so the (now transition-less) transform change never animates.\n _clearDragTransforms() {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = ''\n el.style.transform = ''\n })\n },\n\n handleColDragMove(e) {\n // Ghost follows the cursor with the original grab point preserved —\n // direct DOM writes, no Vue re-render per mousemove.\n const ghost = this.$refs.colDragGhost\n if (ghost) {\n ghost.style.left = (e.clientX - this.colDrag.grabOffsetX) + 'px'\n ghost.style.top = (e.clientY - 14) + 'px'\n }\n this._lastDragClientX = e.clientX\n this._refreshDragPreview(e.clientX)\n this._updateDragAutoScroll(e.clientX)\n },\n\n _refreshDragPreview(clientX) {\n const prevDrop = this.colDrag.dropIndex\n this._updateColDragDropIndex(clientX)\n if (this.colDrag.dropIndex !== prevDrop) {\n this._dragOffsets = this._computeDragOffsets(this.colDrag.dropIndex)\n this._applyDragTransforms(this._dragOffsets)\n }\n },\n\n _updateColDragDropIndex(clientX) {\n const bounds = this._dragBounds\n if (!bounds || !bounds.length) { this.colDrag.dropIndex = this.leafCols.length; return }\n\n // Boundaries come from the drag-start snapshot, shifted by how far the\n // body has scrolled since (pinned zones don't move with the scroll).\n // Live DOM rects are never read — they carry in-flight preview\n // transforms whose intermediate values would corrupt the math.\n const sb = this.$refs.scrollBody\n const scrollDelta = sb ? sb.scrollLeft - this._dragStartScrollLeft : 0\n const adjusted = bounds\n .map(b => ({\n fi: b.fi,\n left: b.scrollable ? b.left - scrollDelta : b.left,\n right: b.scrollable ? b.right - scrollDelta : b.right\n }))\n .sort((a, b) => a.left - b.left)\n\n let dropIndex = adjusted[adjusted.length - 1].fi + 1\n for (const { fi, left, right } of adjusted) {\n if (clientX < right) {\n dropIndex = clientX < (left + right) / 2 ? fi : fi + 1\n break\n }\n }\n this.colDrag.dropIndex = dropIndex\n },\n\n // Edge auto-scroll: dragging near the left/right edge of the visible\n // scrollable window (the part not covered by sticky pinned zones) scrolls\n // the body horizontally, speed proportional to how deep into the 40px\n // edge zone the cursor sits — so far-away drop targets are reachable.\n _updateDragAutoScroll(clientX) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._dragScrollV = 0; return }\n const rect = sb.getBoundingClientRect()\n const leftEdge = rect.left + this.getFrozenWidth()\n const rightEdge = rect.left + sb.clientWidth - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n const ZONE = 40\n let v = 0\n if (clientX < leftEdge + ZONE) v = -Math.ceil((leftEdge + ZONE - clientX) / 4)\n else if (clientX > rightEdge - ZONE) v = Math.ceil((clientX - (rightEdge - ZONE)) / 4)\n this._dragScrollV = Math.max(-20, Math.min(20, v))\n\n if (this._dragScrollV !== 0 && !this._dragScrollRAF) {\n const step = () => {\n this._dragScrollRAF = null\n if (!this.colDrag.active || this._dragScrollV === 0) return\n const body = this.$refs.scrollBody\n if (body) {\n const before = body.scrollLeft\n body.scrollLeft += this._dragScrollV\n // Re-derive the drop slot at the new scroll position even though\n // the cursor itself hasn't moved.\n if (body.scrollLeft !== before) this._refreshDragPreview(this._lastDragClientX)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopDragAutoScroll() {\n this._dragScrollV = 0\n if (this._dragScrollRAF) {\n cancelAnimationFrame(this._dragScrollRAF)\n this._dragScrollRAF = null\n }\n },\n\n commitColDrag() {\n const from = this.colDrag.sourceFirstLeaf\n const leafCount = this.colDrag.sourceLeafCount\n const to = this.colDrag.dropIndex\n\n if (from < 0 || to < 0 || leafCount < 1) { this._resetColDrag(true); return }\n if (to >= from && to <= from + leafCount) { this._resetColDrag(true); return }\n\n // KEEP the preview transforms in the DOM while Vue re-renders with the\n // new column order. After Vue updates left positions ($nextTick), we\n // instantly clear them — both steps run as microtasks before the browser\n // paints, so there is never a visible intermediate state.\n this._stopDragAutoScroll()\n clearTimeout(this._dragCleanupTimer)\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n\n const newCols = moveColumnNodeInTree(this.columns, from, leafCount, to)\n this.$emit('update:columns', newCols)\n this.$emit('column-reorder', { fromIndex: from, toIndex: to, columns: newCols })\n\n // Clear transforms after Vue re-renders new column left positions\n this.$nextTick(() => { this._clearDragTransforms() })\n },\n\n // animateBack: cancelled drags (Escape, drop on the source slot) glide the\n // columns back to their original places instead of snapping.\n _resetColDrag(animateBack = false) {\n const wasActive = this.colDrag.active\n this._stopDragAutoScroll()\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n clearTimeout(this._dragCleanupTimer)\n if (animateBack && wasActive && this.$el) {\n this._applyDragTransforms({}) // everything retargets to translateX(0) with transitions still on\n this._dragCleanupTimer = setTimeout(() => this._clearDragTransforms(), 230)\n } else {\n this._clearDragTransforms()\n }\n },\n\n // =========================================================\n // RESIZE (3.5)\n // =========================================================\n startColResize(e, ci) {\n // Under fitColumns, resizing a column turns IT into a fixed px\n // override (see commitResize/_applyFitColumns) — the rest keep\n // scaling proportionally to fill what's left, so this can reintroduce\n // horizontal scroll. That's an accepted tradeoff for letting a column\n // be pinned to an exact width while fitColumns is on.\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'col',\n index: ci,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getColW(ci),\n currentPos: e.clientX,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n // Manual resize turns a relative-unit width ('20%', ...) into a fixed px\n // override — it should no longer rescale on window resize.\n this.$delete(this.relativeColSpecs, ci)\n e.preventDefault()\n },\n\n startRowResize(e, ri) {\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'row',\n index: ri,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getRowH(ri),\n currentPos: e.clientY,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n e.preventDefault()\n },\n\n handleResizeMove(e) {\n if (this.resizing.type === 'col') {\n this.resizing.currentPos = e.clientX\n const newWidth = Math.max(20, this.resizing.startSize + (e.clientX - this.resizing.startX))\n this.$set(this.colWidths, this.resizing.index, newWidth)\n this._cellStyleCache = Object.create(null)\n } else {\n this.resizing.currentPos = e.clientY\n this._resizePreviewHeight = Math.max(10, this.resizing.startSize + (e.clientY - this.resizing.startY))\n // Batch $set + updateVirtualScroll to rAF — prevents O(n) cumulativeHeights rebuild\n // on every mousemove (200+ Hz). At most 60 rebuilds/sec instead of 200+.\n if (!this._resizeRAF) {\n this._resizeRAF = requestAnimationFrame(() => {\n this._resizeRAF = null\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n this.updateVirtualScroll()\n })\n }\n }\n },\n\n commitResize() {\n if (this._resizeRAF) {\n cancelAnimationFrame(this._resizeRAF)\n this._resizeRAF = null\n // Apply the last pending preview height immediately on commit\n if (this.resizing.type === 'row' && this._resizePreviewHeight !== undefined) {\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n }\n }\n if (this.resizing.type === 'col') {\n this._cellStyleCache = Object.create(null)\n const newSize = this.colWidths[this.resizing.index]\n if (this.fitColumns) {\n const col = this.leafCols[this.resizing.index]\n if (col) this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: this.resizing.index, newSize })\n } else {\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n const newSize = this.rowHeights[this.resizing.index]\n this.$emit('row-resize', { index: this.resizing.index, newSize })\n this.updateVirtualScroll()\n }\n this.resizing.active = false\n },\n\n // Double-click-the-header \"AutoFit column width\" — same idea as Excel/Sheets'\n // double-click-the-border shortcut. Under fitColumns, same override\n // treatment as a manual drag-resize (see commitResize/_applyFitColumns):\n // this column becomes a fixed px width excluded from the proportional\n // fit, the rest keep scaling in what's left.\n autoFitColumn(ci) {\n const col = this.leafCols[ci]\n if (!col) return\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n const newWidth = Math.max(50, maxLen * 8 + 16)\n this.$set(this.colWidths, ci, newWidth)\n this.$delete(this.relativeColSpecs, ci)\n this._cellStyleCache = Object.create(null)\n if (this.fitColumns) {\n this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: ci, newSize: newWidth })\n },\n\n _applyAutoWidth() {\n // fitColumns is the higher-priority width control — see its own JSDoc.\n if (!this.autoWidth || this.fitColumns || !this.leafCols.length) return\n this.leafCols.forEach((col, ci) => {\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n this.$set(this.colWidths, ci, Math.max(50, maxLen * 8 + 16))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n // fitColumns prop — ignores col.width as an absolute value, using it only\n // as a relative WEIGHT so every visible leaf column (pinned or not)\n // scales by one shared factor to exactly fill _relativeWidthContainer\n // (the same circular-dependency-safe reference %/CSS-length widths\n // already resolve against — see its declaration comment). Mirrors\n // naturalGridWidth's formula in reverse: that computed derives the\n // grid's natural width FROM the column widths; this derives the column\n // widths FROM the available width.\n //\n // Columns in fitColumnsOverrides (manually resized while fitColumns was\n // on — see commitResize) are excluded from the scaling pool and keep\n // their current colWidths px value untouched; the remaining columns\n // scale to fill whatever space is left after subtracting the overrides'\n // total width, which can go negative (horizontal scroll returns) if the\n // overrides alone exceed the available width.\n _applyFitColumns() {\n if (!this.fitColumns || !this.leafCols.length) return\n const containerWidth = this._relativeWidthContainer\n if (!containerWidth) return // not measured yet — re-run once hostContainerWidth arrives\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n const available = containerWidth - ROW_HEADER_WIDTH - outlineW - this.vScrollbarW - this.shellBorderW\n if (available <= 0) return\n\n const visible = []\n let totalWeight = 0\n let overriddenWidth = 0\n this.leafCols.forEach((col, ci) => {\n if (this.isColHidden(ci)) return\n if (this.fitColumnsOverrides[col.key]) {\n const w = this.colWidths[ci] !== undefined ? this.colWidths[ci] : this.resolveColWidth(col.width, containerWidth)\n overriddenWidth += w\n return\n }\n const w = this.resolveColWidth(col.width, containerWidth)\n visible.push({ ci, weight: w })\n totalWeight += w\n })\n if (!visible.length || totalWeight <= 0) return\n\n const remaining = available - overriddenWidth\n const scale = remaining > 0 ? remaining / totalWeight : 0\n visible.forEach(({ ci, weight }) => {\n this.$set(this.colWidths, ci, Math.max(FIT_COLUMNS_MIN_WIDTH, weight * scale))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n ctxHideRow() {\n const targetRow = this.contextMenu.row\n // Row header: row >= 0 AND col = -1 (no column context)\n const fromRowHeader = targetRow >= 0 && this.contextMenu.col < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenRows]\n if (fromRowHeader) {\n if (!newHidden.includes(targetRow)) newHidden.push(targetRow)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n if (!newHidden.includes(ri)) newHidden.push(ri)\n }\n }\n this.$emit('update:hiddenRows', newHidden)\n },\n\n ctxHideColumn() {\n const targetCol = this.contextMenu.col\n // Column header: col >= 0 AND row = -1 (no row context)\n const fromColHeader = targetCol >= 0 && this.contextMenu.row < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenColumns]\n if (fromColHeader) {\n if (!newHidden.includes(targetCol)) newHidden.push(targetCol)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n if (!newHidden.includes(ci)) newHidden.push(ci)\n }\n }\n this.$emit('update:hiddenColumns', newHidden)\n },\n\n ctxUnhideRows() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenRows', [])\n },\n\n ctxUnhideColumns() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenColumns', [])\n },\n\n ctxPinLeft() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'left')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxPinRight() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'right')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxUnpin() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$delete(this.internalPinnedColumns, col.key)\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n isColPinned(ci) {\n const col = this.leafCols[ci]\n return !!(col && this.internalPinnedColumns[col.key])\n },\n\n // =========================================================\n // DEV MODE — column/row config info (context menu → modal)\n // =========================================================\n _stringifyForInfo(value) {\n const seen = new WeakSet()\n return JSON.stringify(value, (key, val) => {\n if (typeof val === 'function') return 'ƒ ' + (val.name || 'anonymous') + '()'\n if (val && typeof val === 'object') {\n if (seen.has(val)) return '[Circular]'\n seen.add(val)\n }\n return val\n }, 2)\n },\n\n ctxShowColumnInfo() {\n const ci = this.contextMenu.col\n this.hideContextMenu()\n const col = this.leafCols[ci]\n if (!col) return\n const { _flatIndex, ...rest } = col\n this.infoModal = { title: `${this.t.columnInfo}: ${col.title || col.key}`, json: this._stringifyForInfo(rest) }\n },\n\n ctxShowRowInfo() {\n const ri = this.contextMenu.row\n this.hideContextMenu()\n const row = this._dataRaw[ri]\n if (!row) return\n this.infoModal = { title: `${this.t.rowInfo} #${ri + 1}`, json: this._stringifyForInfo(row) }\n },\n\n closeInfoModal() {\n this.infoModal = null\n },\n\n // =========================================================\n // MERGE CELLS (3.8)\n // =========================================================\n mergeCells(range) {\n if (this.internalLocked) return false\n const oldMerges = this.internalMerges.slice()\n const { merges: newMerges, conflict } = addMerge(range, this.internalMerges)\n if (conflict) {\n this.$emit('merge-error', { range, reason: 'Overlaps existing merge' })\n return false\n }\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n return true\n },\n\n unmergeCells(row, col) {\n if (this.internalLocked) return\n const oldMerges = this.internalMerges.slice()\n const newMerges = removeMergeAt(row, col, this.internalMerges)\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n },\n\n ctxMerge() {\n this.hideContextMenu()\n const pr = this.primaryRange\n if (!pr) return\n this.mergeCells(normalizeRange(pr))\n },\n\n ctxUnmerge() {\n this.hideContextMenu()\n this.unmergeCells(this.activeCell.row, this.activeCell.col)\n },\n\n // =========================================================\n // GROUPING (3.7)\n // =========================================================\n groupRows(startIndex, endIndex) {\n this.internalGroups = addGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n groupColumns(startIndex, endIndex) {\n this.internalGroups = addGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupRows(startIndex, endIndex) {\n this.internalGroups = removeGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupColumns(startIndex, endIndex) {\n this.internalGroups = removeGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n toggleGroup(group) {\n this.internalCollapsedGroups = toggleGroupCollapse(group, this.internalCollapsedGroups)\n this.$emit('update:collapsedGroups', [...this.internalCollapsedGroups])\n this.$emit('group-toggle', { group, collapsed: _isGroupCollapsed(group, this.internalCollapsedGroups) })\n this.updateVirtualScroll()\n },\n\n isGroupCollapsed(group) {\n return _isGroupCollapsed(group, this.internalCollapsedGroups)\n },\n\n expandAll() {\n this.internalCollapsedGroups = []\n this.$emit('update:collapsedGroups', [])\n this.updateVirtualScroll()\n },\n\n collapseAll() {\n this.internalCollapsedGroups = [...this.internalGroups]\n this.$emit('update:collapsedGroups', [...this.internalGroups])\n this.updateVirtualScroll()\n },\n\n // =========================================================\n // DRAG AND DROP (3.9)\n // =========================================================\n startDragMove(e, ri, ci) {\n const pr = this.primaryRange\n if (!pr) return\n this.dragMove = {\n active: true,\n sourceRange: normalizeRange(pr),\n startX: e.clientX,\n startY: e.clientY,\n targetRow: ri,\n targetCol: ci,\n copyMode: e.ctrlKey || e.metaKey\n }\n this.$emit('cell-drag-start', { sourceRange: this.dragMove.sourceRange })\n e.preventDefault()\n },\n\n handleDragMoveMove(e) {\n this.dragMove.copyMode = e.ctrlKey || e.metaKey\n this.$emit('cell-drag-move', {\n sourceRange: this.dragMove.sourceRange,\n targetRow: this.dragMove.targetRow,\n targetCol: this.dragMove.targetCol\n })\n },\n\n async commitDragMove(e) {\n if (!this.dragMove.active) return\n const { sourceRange, targetRow, targetCol, copyMode } = this.dragMove\n\n const targetRange = {\n startRow: targetRow,\n startCol: targetCol,\n endRow: targetRow + (sourceRange.endRow - sourceRange.startRow),\n endCol: targetCol + (sourceRange.endCol - sourceRange.startCol)\n }\n\n let proceed = true\n if (this.beforeDrop) {\n proceed = await Promise.resolve(this.beforeDrop(sourceRange, targetRange))\n }\n\n let oldData = null\n let newData = null\n if (proceed) {\n oldData = this.data\n const matrix = extractMatrix(oldData, this.leafCols, sourceRange)\n newData = applyMatrix(oldData, this.leafCols, targetRow, targetCol, matrix, 'all', (ri, ci) => this.isCellEditable(ri, ci)).newData\n\n if (!copyMode) {\n // Clear source — ensure each source row is a new copy before mutation\n for (let ri = sourceRange.startRow; ri <= sourceRange.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = sourceRange.startCol; ci <= sourceRange.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) newData[ri][col.key] = ''\n }\n }\n }\n this._emitDataChange(oldData, newData)\n this.$emit('cell-drop', { sourceRange, targetRange })\n }\n\n this.dragMove = { active: false, sourceRange: null, startX: 0, startY: 0, targetRow: -1, targetCol: -1, copyMode: false }\n this.isMouseDown = false\n\n if (proceed) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n // =========================================================\n // EXCEL EXPORT (3.11)\n // =========================================================\n async exportToExcel(options = {}) {\n const {\n filename = 'export.xlsx',\n sheetName = 'Sheet1',\n scope = 'all',\n includeHidden = false\n } = options\n\n this.$emit('export-start')\n this.exportState = { active: true }\n\n const workerPayload = {\n data: this.data,\n columns: this.columns,\n // width resolved to a concrete px number (getColW handles px/%/other CSS\n // units) — excelExport.js's char-width math needs a real number, and a\n // worker has no DOM to resolve a raw '20%'/'10em' spec itself.\n leafCols: this.leafCols.map((col, ci) => ({ ...col, width: this.getColW(ci) })),\n merges: this.internalMerges,\n hiddenCols: [...this.effectiveHiddenColSet],\n hiddenRows: [...this.hiddenRowSet],\n range: scope === 'selection' ? this.primaryRange : null,\n filename,\n sheetName,\n scope,\n includeHidden\n }\n\n try {\n const worker = createExcelExportWorker()\n if (worker) {\n await this._exportViaWorker(worker, workerPayload)\n } else {\n // Fallback: synchronous export on main thread\n _exportToExcel({\n ...workerPayload,\n hiddenColSet: this.effectiveHiddenColSet,\n hiddenRowSet: this.hiddenRowSet\n })\n }\n this.$emit('export-success')\n } catch (err) {\n this.$emit('export-error', err)\n } finally {\n this.exportState = { active: false }\n }\n },\n\n /**\n * Print all (visible) grid data as a plain, theme-independent HTML\n * table — not the styled data-grid itself. Honors hidden columns/rows,\n * merged cells, and footer totals, same sources `exportToExcel()` uses.\n * `type: 'action'` columns (buttons — add/remove row, etc.) are always\n * excluded: they're interactive UI, not printable data.\n */\n printGrid() {\n const rowIndices = []\n for (let ri = 0; ri < this.totalRows; ri++) {\n if (!this.hiddenRowSet.has(ri)) rowIndices.push(ri)\n }\n const printExcludedColSet = new Set(this.effectiveHiddenColSet)\n this.leafCols.forEach(col => { if (col.type === 'action') printExcludedColSet.add(col._flatIndex) })\n const html = buildPrintTableHtml({\n columns: this.columns,\n leafCols: this.leafCols,\n rowIndices,\n merges: this.internalMerges,\n hiddenColSet: printExcludedColSet,\n // Resolved px width per column (same getColW() exportToExcel() uses)\n // so print columns stay proportional to their on-screen width.\n colWidths: this.leafCols.map((col, ci) => this.getColW(ci)),\n getCellText: (ri, ci) => {\n const col = this.leafCols[ci]\n if (col && col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? '✓' : ''\n return String(this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri))\n },\n showFooter: this.hasFooter,\n getFooterText: ci => this.getColumnTotalDisplay(ci),\n printTitle: this.printTitle\n })\n this._openPrintFrame(html)\n },\n\n // Hidden same-origin <iframe> instead of window.open(): can't be\n // popup-blocked, and works the same when the host app itself runs\n // inside an iframe. srcdoc's document is fully self-contained (its own\n // <style>, nothing inherited from the host page), so the printed table\n // never picks up the grid's current theme.\n _openPrintFrame(html) {\n const iframe = document.createElement('iframe')\n iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden;'\n document.body.appendChild(iframe)\n const cleanup = () => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe) }\n iframe.onload = () => {\n const win = iframe.contentWindow\n win.addEventListener('afterprint', cleanup)\n win.focus()\n win.print()\n // Fallback in case `afterprint` never fires (observed on some\n // browsers when the print dialog is dismissed a certain way).\n setTimeout(cleanup, 2000)\n }\n iframe.srcdoc = html\n },\n\n _exportViaWorker(worker, payload) {\n return new Promise((resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, buffer, filename, message } = e.data\n if (type === 'result') {\n // Trigger browser download from the main thread (DOM required)\n const blob = new Blob([new Uint8Array(buffer)], {\n type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n URL.revokeObjectURL(url)\n finish(resolve)\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Export worker xatosi'))\n\n worker.postMessage(payload)\n })\n },\n\n // =========================================================\n // EXCEL IMPORT (3.12)\n // =========================================================\n async importFromFile(file, options = {}) {\n this.importState = { active: true, percent: 5, message: \"Fayl o'qilmoqda...\" }\n\n try {\n let result\n const worker = createExcelImportWorker()\n\n if (worker) {\n result = await this._importViaWorker(worker, file, options)\n } else {\n // Fallback: chunked async on main thread\n result = await _importFromFile(\n file,\n this.columns,\n options,\n (percent, message) => { this.importState = { active: true, percent, message } }\n )\n }\n\n const { columns, merges } = result\n // Freeze each row so Vue 2 skips deep Object.defineProperty observation.\n // Vue 2 checks Object.isExtensible() — frozen objects are skipped entirely.\n // All edit paths already use spread ({ ...row }) so frozen rows are safe.\n const data = result.data.map(row => Object.freeze(row))\n this.$emit('input', data)\n this._scheduleBufferWrite(data)\n this.$emit('update:columns', columns)\n\n const hasMerges = merges && merges.length > 0\n this.internalMerges = hasMerges ? merges : []\n this.$emit('update:merges', hasMerges ? merges : [])\n this.$emit('update:collapsedGroups', [])\n this.$emit('update:hiddenRows', [])\n this.$emit('import-success', { data, columns, merges })\n\n // Reset scroll state and re-derive virtual scroll after DOM settles.\n // Without this, stale scrollTop + old rowHeights cause blank bands on scroll.\n this.rowHeights = {}\n this.scrollTop = 0\n this.scrollLeft = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) {\n this.$refs.scrollBody.scrollTop = 0\n this.$refs.scrollBody.scrollLeft = 0\n }\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = 0\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = 0\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = 0\n this.updateVirtualScroll()\n })\n } catch (err) {\n this.$emit('import-error', err)\n } finally {\n this.importState = { active: false, percent: 0, message: '' }\n }\n },\n\n _importViaWorker(worker, file, options) {\n return new Promise(async (resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, percent, message, data, columns, merges } = e.data\n if (type === 'progress') {\n this.importState = { active: true, percent, message }\n } else if (type === 'result') {\n finish(resolve, { data, columns, merges })\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Import worker xatosi'))\n\n try {\n // Transfer the ArrayBuffer zero-copy into the worker\n const buffer = await file.arrayBuffer()\n worker.postMessage(\n { buffer, existingColumns: this.columns, options },\n [buffer]\n )\n } catch (err) {\n finish(reject, err)\n }\n })\n },\n\n // =========================================================\n // UNDO / REDO (3.13)\n // =========================================================\n\n // Push one entry onto the non-reactive history stack.\n // Truncates the redo tail, caps at 100 entries.\n _pushHistory(entry) {\n if (this._historyIndex < this._history.length - 1) {\n this._history.splice(this._historyIndex + 1)\n }\n this._history.push(entry)\n if (this._history.length > 100) {\n this._history.shift()\n // historyIndex stays the same — it now points to the new last entry\n } else {\n this._historyIndex++\n }\n this.historyVersion++\n },\n\n // Emit 'input' and record a data diff entry (only changed row indices).\n // Use slice() + per-row spread in callers so unchanged rows keep the\n // same reference — then the identity check here catches only real changes.\n _emitDataChange(oldData, newData) {\n if (!this._historySkip) {\n const indices = []\n const beforeRows = []\n const afterRows = []\n const len = Math.max(oldData.length, newData.length)\n for (let i = 0; i < len; i++) {\n if (oldData[i] !== newData[i]) {\n indices.push(i)\n beforeRows.push(oldData[i])\n afterRows.push(newData[i])\n }\n }\n if (indices.length > 0) {\n // rowCount lets undo() truncate the array back down when this change\n // added rows (e.g. pasteGrowsRows) — a no-op for every change that\n // doesn't touch row count, since rowCount then matches on both sides.\n this._pushHistory({\n type: 'data',\n before: { indices, rows: beforeRows, rowCount: oldData.length },\n after: { indices, rows: afterRows, rowCount: newData.length }\n })\n }\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n },\n\n // Emit 'update:merges' and record a merges diff entry.\n _emitMergeChange(oldMerges, newMerges) {\n if (!this._historySkip) {\n this._pushHistory({\n type: 'merges',\n before: oldMerges,\n after: newMerges\n })\n }\n this.$emit('update:merges', newMerges)\n },\n\n undo() {\n if (this.internalLocked) return\n if (this._historyIndex < 0) return\n const entry = this._history[this._historyIndex--]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.before.indices.length; i++) {\n newData[entry.before.indices[i]] = entry.before.rows[i]\n }\n // Assigning `undefined` to a trailing index doesn't shrink the\n // array — truncate explicitly when this entry added rows (e.g.\n // pasteGrowsRows), so undo actually removes them instead of\n // leaving `undefined` rows at the tail.\n if (entry.before.rowCount != null && entry.before.rowCount < newData.length) {\n newData.length = entry.before.rowCount\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.before.slice()\n this.$emit('update:merges', entry.before.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n redo() {\n if (this.internalLocked) return\n if (this._historyIndex >= this._history.length - 1) return\n const entry = this._history[++this._historyIndex]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.after.indices.length; i++) {\n newData[entry.after.indices[i]] = entry.after.rows[i]\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.after.slice()\n this.$emit('update:merges', entry.after.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n // =========================================================\n // TOOLBAR (fullscreen / lock / export / import / custom buttons)\n // =========================================================\n\n // Public, ref-callable: returns keyboard focus to the grid. Meant for\n // hosts whose action-cell `handler` opens a modal/popup outside the\n // grid's control — call this once that modal closes, otherwise the\n // browser leaves focus on document.body (its subtree was removed) and\n // arrow-key navigation goes dead until a cell is clicked again.\n focus() {\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Public, ref-callable: formats a number with a custom thousands\n // separator (decimal point stays '.', no forced/padded decimals). Any\n // value that isn't a finite number — already-formatted text like\n // \"6 000 000,00\", arbitrary strings, etc. — is returned unchanged, so\n // hosts can call this on raw cell values without checking the type first.\n formatNumber(value, separator) {\n return _formatNumber(value, separator)\n },\n\n // =========================================================\n // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by\n // localSyncKey (optionally namespaced by tenant), only when the page is\n // being left (not per-edit).\n // =========================================================\n\n // Shared by the localSyncKey and tenant watchers above — either one\n // changing means effectiveSyncKey now points at a (possibly) different\n // version history, so the badge/version-list state must be reloaded\n // from scratch rather than left stale.\n _reloadLocalSyncForKeyChange() {\n this.localSyncAvailable = false\n if (this.effectiveSyncKey) this._loadLocalVersionsOnMount()\n },\n\n // Internal, called once from mounted(): populates the toolbar \"unseen\n // local versions\" dot and emits local-sync-available if any versions\n // already exist for this key (e.g. saved in an earlier visit).\n async _loadLocalVersionsOnMount() {\n if (!this.localSyncKey) return\n await this._promoteBufferIfAny()\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n if (!versions.length) return\n this.localSyncAvailable = true\n this.$emit('local-sync-available', {\n key: this.localSyncKey,\n versions: versions.length,\n mostRecentUpdatedAt: versions[0].updatedAt\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n }\n },\n\n // Debounced (800ms), called from the grid's own real-change points\n // (_emitDataChange, import, undo, redo — the exact same \"user worked\"\n // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,\n // for the same reason spelled out on the localSyncKey prop's JSDoc: a\n // host loading rows from an API must never look like a user edit).\n //\n // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no\n // hash, no history) at a moment with no teardown race at all — the user\n // is actively still on the page. This is what actually fixes reload/\n // close data loss: a version-store write ATTEMPTED at the leave-signal\n // has no guarantee of finishing before an actual page teardown (verified\n // directly — even a trivial, unrelated IndexedDB write failed to survive\n // a real page.reload() in Chromium), but a buffer write that already\n // completed minutes or seconds earlier, during ordinary editing, needs no\n // such guarantee — it's already durably there. _promoteBufferIfAny()\n // turns it into a real version next time the grid mounts.\n _scheduleBufferWrite(data) {\n if (!this.localSyncKey) return\n const key = this.effectiveSyncKey // captured now, not re-read 800ms later —\n // a host that swaps localSyncKey/tenant mid-edit (rare, but now\n // reactive, see the watchers below) must not have this land under the\n // NEW key with the OLD key's data.\n clearTimeout(this._bufferWriteTimer)\n this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)\n },\n\n async _writeBufferNow(key, data) {\n if (!key) return\n try {\n await putBuffer(key, data)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Called once from _loadLocalVersionsOnMount(), before this key's version\n // list is read — folds a leftover buffer (from an edit that was saved\n // during the PREVIOUS visit but never made it into 'versions' — the\n // reload/close case this whole mechanism exists for) into a real version.\n // No time pressure here, so this uses the Worker-backed hashRowsAsync()\n // like any other non-leave-signal path.\n async _promoteBufferIfAny() {\n try {\n const buffered = await getBuffer(this.effectiveSyncKey)\n if (!buffered) return\n const hash = await hashRowsAsync(buffered.data)\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, buffered.data, hash, this.localSyncMaxVersions\n )\n await clearBuffer(this.effectiveSyncKey)\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: buffered.data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the\n // passive leave-triggers can't afford a Worker round-trip.\n _onVisibilityChangeForLocalSync() {\n if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)\n },\n\n _onPageHideForLocalSync() {\n this._flushLocalVersionOnHide(false, true)\n },\n\n // The core save-on-leave path — replaces a per-edit autosave (see the\n // localSyncKey prop's JSDoc for why: an autosave tied to \"data changed\"\n // can't distinguish a user's edit from a host loading rows from an API,\n // and would silently overwrite a user's saved work the moment a host\n // does the latter). Triggered from mounted()'s visibilitychange/pagehide\n // listeners and beforeDestroy() — see those for the three trigger points.\n //\n // Content-hashes the current `data` and either touches an existing local\n // version with the same hash (just refreshes its timestamp — \"ustiga\n // yoziladi, alohida versiya qilinmaydi\") or creates a brand-new one,\n // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's\n // saveOrTouchVersion()). Never throws — IndexedDB failures become\n // local-sync-error.\n //\n // `force` skips the \"nothing changed since the last flush\" short-circuit\n // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)\n // fire often on an idle tab and must NOT wake IndexedDB for no reason, so\n // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,\n // host/modal-initiated \"checkpoint now\" action — passes force so it never\n // silently does nothing: even unchanged data still gets its existing\n // version's timestamp refreshed via the same hash-dedup path, rather than\n // the caller seeing no effect at all.\n //\n // `sync` picks hashRows() (main thread) over the Worker-based\n // hashRowsAsync(). This was a real, observed bug, not a theoretical one:\n // a Worker round-trip (postMessage out, worker computes, postMessage\n // back) is one more hop through the event loop than a page that's\n // actually being torn down (as opposed to merely hidden — e.g. a tab\n // switch, where the page stays alive indefinitely and the extra hop\n // costs nothing) can be relied on to survive. visibilitychange/pagehide/\n // beforeDestroy all fire in exactly that \"might be torn down any moment\"\n // window, so they hash synchronously to remove that hop — the whole\n // flush then only has the unavoidable async part (the IndexedDB write\n // itself) left, giving it the best realistic chance to land before\n // teardown. saveLocalVersionNow() (an explicit host/modal action with no\n // teardown racing it) keeps the Worker, so hashing a large `data` array\n // doesn't block the main thread during ordinary use.\n async _flushLocalVersionOnHide(force = false, sync = false) {\n if (!this.localSyncKey) return\n if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush\n const data = this.data\n // Fires independently of the version-save attempt below, started as\n // early as possible — see _scheduleBufferWrite()'s comment. This is\n // the save that's actually likely to survive an imminent teardown;\n // the version-store write below is best-effort on top of it, reliable\n // for the common case (tab switch/background, page stays alive).\n clearTimeout(this._bufferWriteTimer)\n this._writeBufferNow(this.effectiveSyncKey, data)\n try {\n const hash = sync ? hashRows(data) : await hashRowsAsync(data)\n if (this.data !== data) return // data changed again mid-hash — a later flush will catch it\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, data, hash, this.localSyncMaxVersions\n )\n this._lastFlushedDataRef = data\n // The version store now has this content durably — the buffer's job\n // for it is done. Best-effort like everything else here: if this\n // doesn't complete before teardown, _promoteBufferIfAny() finds the\n // (now-redundant but harmless, dedup makes it a no-op touch) buffer\n // on next mount anyway.\n clearBuffer(this.effectiveSyncKey).catch(() => {})\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Public, ref-callable: runs the save-on-leave flow immediately, for a\n // host that wants its own explicit \"save checkpoint\" button instead of\n // waiting for a page-hide. Always goes through IndexedDB (force — see\n // _flushLocalVersionOnHide()), but still through the same hash-dedup\n // logic, so repeated calls with unchanged data don't spam duplicate\n // versions — they just refresh the existing matching version.\n async saveLocalVersionNow() {\n await this._flushLocalVersionOnHide(true)\n },\n\n // Public, ref-callable: lightweight metadata for every saved version of\n // this localSyncKey, newest first — no row data (use getLocalVersion(id)\n // for that). Powers the built-in modal's list; also usable to build a\n // custom UI.\n async listLocalVersions() {\n if (!this.localSyncKey) return []\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return []\n }\n },\n\n // Public, ref-callable: the full { id, data, createdAt, updatedAt } for\n // one saved version, WITHOUT applying it to v-model — the pure \"Read\"\n // for a custom UI (as opposed to restoreLocalVersion(), which reads AND\n // commits in one step).\n async getLocalVersion(id) {\n try {\n return await getVersion(id)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return null\n }\n },\n\n // Public, ref-callable: writes one saved version back into v-model\n // (input/update:data) — the modal's \"Restore\" button calls this too.\n async restoreLocalVersion(id) {\n try {\n const version = await this.getLocalVersion(id)\n if (!version) return\n this.$emit('input', version.data)\n this.$emit('update:data', version.data)\n this._lastFlushedDataRef = version.data // what's now on screen is already what's stored\n this.localSyncAvailable = false\n this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })\n this.closeLocalSyncModal()\n } catch (error) {\n this.$emit('local-sync-error', { action: 'restore', error })\n }\n },\n\n // Public, ref-callable: deletes one saved version — the modal's \"Delete\"\n // button calls this too (after its own inline confirm step).\n async deleteLocalVersion(id) {\n try {\n await deleteVersion(id)\n if (this.localSyncModal.selectedId === id) {\n this.localSyncModal.selectedId = null\n this.localSyncModal.selectedVersion = null\n }\n this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)\n this.$emit('local-sync-clear', { key: this.localSyncKey, id })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'clear', error })\n }\n },\n\n // Public, ref-callable: opens the local-sync modal (also reachable via\n // the toolbar button), loading the version list fresh each time.\n async openLocalSyncModal() {\n if (!this.localSyncKey) return\n this.localSyncModal.visible = true\n this.localSyncAvailable = false // the version(s) have now been seen\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Internal: modal's list-selection handler — loads the selected\n // version's full data for the nested-grid preview (EgLocalSyncModal.vue).\n async _selectLocalSyncModalVersion(id) {\n this.localSyncModal.selectedId = id\n this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null\n },\n\n // Modal's \"Save\" button — same as saveLocalVersionNow(), then refreshes\n // the modal's own list/selection in place so the user sees it happened.\n async _onLocalSyncModalSave() {\n await this.saveLocalVersionNow()\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Modal's \"Delete\" button (after its own inline confirm) — deletes the\n // given version, then selects the next-most-recent one if the deleted\n // one was selected.\n async _onLocalSyncModalDelete(id) {\n await this.deleteLocalVersion(id)\n if (this.localSyncModal.selectedId == null) {\n const remaining = this.localSyncModal.versions\n await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)\n }\n },\n\n // Public, ref-callable.\n closeLocalSyncModal() {\n this.localSyncModal.visible = false\n },\n\n toggleLock() {\n this.internalLocked = !this.internalLocked\n this.$emit('update:locked', this.internalLocked)\n this.$emit('lock-change', { locked: this.internalLocked })\n this._flashLock(this.internalLocked)\n },\n\n // Pops a big lock/unlock icon in the center of the grid and fades it\n // out — pure visual feedback, mirrors the video-player OSD pattern.\n // Triggered by the internalLocked watcher, so it covers every path that\n // can change the lock state (toolbar click, toggleLock(), :locked.sync).\n _flashLock(locked) {\n this.lockFlashLocked = locked\n this.lockFlashVisible = true\n this.$nextTick(() => {\n const el = this.$refs.lockFlashIconEl\n if (!el) return\n anime.remove(el)\n anime.set(el, { opacity: 0, scale: 0.5 })\n anime.timeline()\n .add({ targets: el, opacity: 1, scale: 1.12, duration: 180, easing: 'easeOutBack' })\n .add({ targets: el, scale: 1, duration: 120, easing: 'easeOutQuad' })\n .add({ targets: el, opacity: 0, duration: 350, easing: 'easeInQuad' }, '+=250')\n .finished.then(() => { this.lockFlashVisible = false })\n })\n },\n\n toolbarHas(key) {\n return this._toolbarList.indexOf(key) !== -1\n },\n\n toolbarDisabled(key) {\n return this._toolbarDisabledList.indexOf(key) !== -1\n },\n\n setSize(newSize) {\n if (!SIZE_CONFIG[newSize] || newSize === this.internalSize) return\n this.internalSize = newSize\n this.$emit('update:size', newSize)\n this.$emit('size-change', { size: newSize })\n },\n\n setTheme(newTheme) {\n if (THEME_LIST.indexOf(newTheme) === -1 || newTheme === this.internalTheme) return\n this.internalTheme = newTheme\n this.$emit('update:theme', newTheme)\n this.$emit('theme-change', { theme: newTheme })\n },\n\n sizeTitle(key) {\n return { small: this.t.sizeSmall, middle: this.t.sizeMiddle, large: this.t.sizeLarge }[key] || key\n },\n\n toggleFullscreen() {\n this.isFullscreen = !this.isFullscreen\n if (this.isFullscreen) {\n this._prevBodyOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n document.addEventListener('keydown', this._onFullscreenKeyDown)\n } else {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n this.$emit('fullscreen-change', { fullscreen: this.isFullscreen })\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n _onFullscreenKeyDown(e) {\n if (e.key === 'Escape' && this.isFullscreen) this.toggleFullscreen()\n },\n\n toolbarExportClick() {\n this.exportToExcel()\n },\n\n toolbarImportClick() {\n if (this.internalLocked) return\n const input = this.$refs.toolbarImportInput\n if (input) input.click()\n },\n\n onToolbarImportFile(e) {\n const file = e.target.files && e.target.files[0]\n e.target.value = ''\n if (!file || this.internalLocked) return\n this.importFromFile(file)\n },\n\n onToolbarButtonClick(btn, event) {\n if (typeof btn.onClick === 'function') btn.onClick(event, this)\n }\n }\n}\n\nexport default ExcelGridComponent\n</script>\n\n<style scoped>\n.eg-shell {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n\n.eg-shell-fullscreen {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n\n.eg-wrapper {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-toolbar {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-group {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n\n.eg-toolbar-group + .eg-toolbar-group {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-spacer {\n flex: 1 1 auto;\n}\n\n.eg-toolbar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged {\n position: relative;\n}\n.eg-toolbar-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-btn:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-btn-active {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n\n.eg-toolbar-select {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n\n.eg-toolbar-select:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n.eg-toolbar-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n\n.eg-toolbar-select:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-segmented {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n\n.eg-toolbar-segment {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon { width: 12px; height: 12px; }\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon { width: 15px; height: 15px; }\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon { width: 18px; height: 18px; }\n\n.eg-toolbar-icon {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n\n.eg-icon-undo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); }\n.eg-icon-redo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); }\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); }\n.eg-icon-import { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); }\n.eg-icon-print { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); }\n.eg-icon-local-sync { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); }\n.eg-icon-lock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); }\n.eg-icon-unlock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); }\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n.eg-icon-compress{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); }\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-middle { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-large { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); }\n\n.eg-container {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n\n.eg-header-area {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n\n.eg-header-cell {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n\n.eg-header-cell:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-header-group { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default)); }\n\n.eg-sort-icon {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon { opacity: 0.65; }\n.eg-sort-icon:hover { opacity: 1; }\n.eg-sort-icon.eg-sort-icon-active {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default)); }\n\n.eg-filter-input,\n.eg-filter-select {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input:focus,\n.eg-filter-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n\n.eg-col-resize-handle {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n\n.eg-body-area {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer,\n.eg-scroll-col-footer,\n.eg-pinned-right-footer {\n position: relative;\n}\n\n.eg-footer-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total {\n font-weight: 700;\n}\n\n.eg-row {\n position: relative;\n}\n\n.eg-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n\n.eg-locked .eg-cell { cursor: not-allowed; }\n\n.eg-cell-value {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right { justify-content: flex-end; }\n.eg-cell-align-center { justify-content: center; }\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value { text-align: right; }\n.eg-cell-align-center .eg-cell-value { text-align: center; }\n.eg-cell--has-suffix.eg-cell-select::after { display: none; }\n.eg-cell-select-trigger-wrap {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n\n.eg-cell-selected {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n\n.eg-cell-fill-preview {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n\n.eg-cell-copy {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n\n.eg-cell-merged-hidden {\n display: none !important;\n}\n\n.eg-cell-invalid {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n\n.eg-cell-input {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n\n.eg-row-header-cell {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-row-resize-handle {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n\n.eg-fill-handle {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n\n.eg-context-menu {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu {\n min-width: 110px;\n}\n\n.eg-context-item {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n\n.eg-context-item:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n\n.eg-context-separator {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n\n.eg-context-submenu-parent::after { content: ' ▶'; float: right; }\n\n.eg-context-submenu {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-context-submenu-parent:hover .eg-context-submenu { display: block; }\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-info-modal {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-info-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n\n.eg-info-modal-title {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-info-modal-close {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n\n.eg-info-modal-close:hover { color: var(--eg-color-text, var(--eg-color-text-default)); }\n\n.eg-info-modal-body {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.eg-group-btn {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n\n.eg-group-btn.collapsed { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default)); }\n\n.eg-drag-ghost {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging,\n.eg-container.eg-col-dragging * {\n cursor: grabbing !important;\n}\n\n.eg-resize-line {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header, .eg-frozen-col-body, .eg-frozen-col-footer { position: relative; }\n.eg-pinned-right-header, .eg-pinned-right-footer { position: relative; }\n\n.eg-frozen-col-header::after, .eg-frozen-col-body::after, .eg-frozen-col-footer::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge::after,\n.eg-frozen-col-body.eg-shadow-edge::after,\n.eg-frozen-col-footer.eg-shadow-edge::after {\n opacity: 1;\n}\n\n.eg-pinned-right-body {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n\n.eg-pinned-right-header::before, .eg-pinned-right-body::before, .eg-pinned-right-footer::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge::before,\n.eg-pinned-right-body.eg-shadow-edge::before,\n.eg-pinned-right-footer.eg-shadow-edge::before {\n opacity: 1;\n}\n\n.eg-pinned-right-header {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n\n.eg-import-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n\n.eg-lock-flash {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n\n.eg-import-dialog {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n\n.eg-import-title {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n\n.eg-import-bar-wrap {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n\n.eg-import-bar {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n\n.eg-import-msg {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n\n.eg-import-pct {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n@keyframes eg-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n.eg-skeleton {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer {\n 0% { background-position: 100% 50%; }\n 100% { background-position: 0 50%; }\n}\n@media (prefers-reduced-motion: reduce) {\n .eg-skeleton-block, .eg-loading-spinner { animation: none; }\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date::after { display: none; }\n.eg-cell-date::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap { justify-content: flex-end; }\n.eg-cell-align-center .eg-cell-action-wrap { justify-content: center; }\n.eg-validation-tooltip {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview),\n.eg-row.eg-row--hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n.eg-row-header-cell:hover,\n.eg-row-header-cell.eg-row--hover {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu,\n.eg-size-small .eg-context-item {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input,\n.eg-size-small .eg-filter-select {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu,\n.eg-size-large .eg-context-item {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input,\n.eg-size-large .eg-filter-select {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon {\n width: 13px;\n height: 13px;\n}\n</style>\n\n<style>\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px; }\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default)); }\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0); }\n.eg-select-btn--clear:hover { background: #fff0f0; }\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden; }\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default)); }\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default; }\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed; }\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); }\n</style>\n"]}, media: undefined })
19849
+ ,inject("data-v-33b216ce_1", { source: "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n}\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n}\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n}\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px;\n}\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n}\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default));\n}\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n}\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0);\n}\n.eg-select-btn--clear:hover { background: #fff0f0;\n}\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden;\n}\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default;\n}\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n}\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed;\n}\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default));\n}\n", map: {"version":3,"sources":["/Users/macmini/Projects/Realsoft/vue2-excel-grid/src/components/ExcelGrid.vue"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAo8QA;;;;;;;;;;;;;;;;iDAgBA;AACA;EACA,kBAAA;EACA,qBAAA;EACA,gBAAA;EACA,2DAAA;EACA,oBAAA;AACA;AACA;EACA,aAAA;EACA,mBAAA;EACA,QAAA;EACA,WAAA;EACA,gBAAA;EACA,gBAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,oEAAA;EACA,eAAA;EACA,iBAAA;EACA,yDAAA;EACA,wDAAA;AACA;AACA,2BAAA,oEAAA;AAAA;AACA;EACA,oEAAA;EACA,sFAAA;AACA;AACA,+BAAA,gFAAA,EAAA,eAAA,EAAA,qEAAA;AAAA;AACA,2BAAA,OAAA,EAAA,gBAAA,EAAA,uBAAA,EAAA,mBAAA;AAAA;AACA,yBAAA,qEAAA;AAAA;AACA,mBAAA,cAAA,EAAA,qEAAA,EAAA,eAAA;AAAA;AACA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,wEAAA;EACA,gBAAA;EACA,iBAAA;EACA,cAAA;AACA;AACA;EACA,kBAAA;EACA,SAAA;EACA,OAAA;EACA,eAAA;EACA,YAAA;EACA,eAAA;AACA;AACA;EACA,gBAAA;EACA,yFAAA;EACA,gBAAA;EACA,MAAA;EACA,oEAAA;EACA,UAAA;AACA;AACA;EACA,WAAA;EACA,sBAAA;EACA,wEAAA;EACA,yDAAA;EACA,gBAAA;EACA,2DAAA;EACA,aAAA;EACA,yDAAA;EACA,oEAAA;EACA,oBAAA;AACA;AACA;EACA,oEAAA;EACA,sFAAA;AACA;AACA;EACA,aAAA;EACA,uBAAA;EACA,QAAA;EACA,iBAAA;EACA,2DAAA;EACA,eAAA;EACA,mBAAA;EACA,sBAAA;EACA,iBAAA;EACA,yDAAA;AACA;AACA;iCACA,oFAAA;AAAA;AACA,4BAAA,gBAAA,EAAA,6DAAA;AAAA;AACA;kCACA,kGAAA;AAAA;AACA;EACA,kBAAA;EACA,kBAAA;EACA,2DAAA;EACA,qEAAA;EACA,iBAAA;AACA;AACA;EACA,eAAA;EACA,gBAAA;EACA,oEAAA;EACA,wEAAA;EACA,yDAAA;EACA,sEAAA;EACA,yDAAA;EACA,2DAAA;EACA,iBAAA;EACA,oBAAA;EACA,mBAAA;EACA,sBAAA;EACA,cAAA;AACA;AACA;EACA,aAAA;EACA,yBAAA;EACA,oBAAA;EACA,4EAAA;AACA;AACA;EACA,gBAAA;EACA,wEAAA;EACA,yDAAA;EACA,iBAAA;EACA,eAAA;EACA,eAAA;EACA,yDAAA;AACA;AACA,uBAAA,oFAAA;AAAA;AACA,wBAAA,sCAAA,EAAA,6CAAA;AAAA;AACA,8BAAA,mBAAA;AAAA;AACA;;;;wBAIA;AACA;EACA,eAAA;EACA,iBAAA;AACA;AACA;EACA,eAAA;EACA,iBAAA;AACA;AACA;EACA,UAAA;EACA,WAAA;EACA,kBAAA;EACA,cAAA;EACA,qBAAA;EACA,eAAA;AACA;AACA,gBAAA;AACA;EACA,oBAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,cAAA;EACA,eAAA;EACA,qEAAA;EACA,eAAA;AACA;AACA,wBAAA,kBAAA;AAAA;AACA,eAAA;AACA;EACA,kBAAA;EACA,oBAAA;EACA,mBAAA;EACA,uBAAA;EACA,WAAA;EACA,YAAA;EACA,eAAA;AACA;AACA;EACA,kBAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;AACA;AACA;EACA,kBAAA;EACA,cAAA;EACA,WAAA;EACA,YAAA;EACA,sBAAA;EACA,0EAAA;EACA,4BAAA;EACA,2BAAA;EACA,oBAAA;EACA,4FAAA;EACA,yDAAA;EACA,yFAAA;AACA;AACA,sCAAA,oEAAA;AAAA;AACA;EACA,wEAAA;EACA,+SAAA;EACA,wBAAA;EACA,oEAAA;AACA;AACA,sDAAA,sFAAA;AAAA;AACA;4CACA,eAAA;AAAA;AACA,iDAAA,sFAAA,EAAA,8EAAA;AAAA;AACA;gCACA,mBAAA;AAAA;AACA,yDAAA,8GAAA,EAAA,0GAAA;AAAA","file":"ExcelGrid.vue","sourcesContent":["<template>\n <div\n class=\"eg-shell\"\n :class=\"['eg-size-' + internalSize, 'eg-theme-' + internalTheme, { 'eg-shell-fullscreen': isFullscreen, 'eg-locked': internalLocked }]\"\n :style=\"shellStyle\"\n >\n <!-- Toolbar -->\n <div v-if=\"_toolbarVisible\" class=\"eg-toolbar\">\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canUndo || toolbarDisabled('undoRedo')\"\n :title=\"t.undo\"\n @click=\"undo\"\n ><span class=\"eg-toolbar-icon eg-icon-undo\"></span></button>\n <button\n v-if=\"toolbarHas('undoRedo')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || !canRedo || toolbarDisabled('undoRedo')\"\n :title=\"t.redo\"\n @click=\"redo\"\n ><span class=\"eg-toolbar-icon eg-icon-redo\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('export')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('export')\"\n :title=\"t.export\"\n @click=\"toolbarExportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-export\"></span></button>\n <button\n v-if=\"toolbarHas('import')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"internalLocked || toolbarDisabled('import')\"\n :title=\"t.import\"\n @click=\"toolbarImportClick\"\n ><span class=\"eg-toolbar-icon eg-icon-import\"></span></button>\n <input\n v-if=\"toolbarHas('import')\"\n ref=\"toolbarImportInput\"\n type=\"file\"\n accept=\".xlsx,.xls,.csv\"\n style=\"display: none\"\n @change=\"onToolbarImportFile\"\n >\n <button\n v-if=\"toolbarHas('print')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :disabled=\"toolbarDisabled('print')\"\n :title=\"t.print\"\n @click=\"printGrid\"\n ><span class=\"eg-toolbar-icon eg-icon-print\"></span></button>\n <button\n v-if=\"localSyncKey && toolbarHas('localSync')\"\n type=\"button\"\n class=\"eg-toolbar-btn eg-toolbar-btn-badged\"\n :disabled=\"toolbarDisabled('localSync')\"\n :title=\"localSyncAvailable ? t.localSyncAvailable : t.localSync\"\n @click=\"openLocalSyncModal\"\n ><span class=\"eg-toolbar-icon eg-icon-local-sync\"></span\n ><span v-if=\"localSyncAvailable\" class=\"eg-toolbar-badge\" aria-hidden=\"true\"></span></button>\n </div>\n\n <slot name=\"toolbar-extra\" :locked=\"internalLocked\" :fullscreen=\"isFullscreen\"></slot>\n\n <div v-if=\"toolbarButtons.length\" class=\"eg-toolbar-group\">\n <button\n v-for=\"btn in toolbarButtons\"\n :key=\"btn.key\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :title=\"btn.title\"\n @click=\"onToolbarButtonClick(btn, $event)\"\n ><span class=\"eg-toolbar-icon\" :style=\"btn.icon ? { maskImage: 'url(' + btn.icon + ')', webkitMaskImage: 'url(' + btn.icon + ')' } : null\">{{ btn.icon ? '' : (btn.label || '') }}</span></button>\n </div>\n\n <div class=\"eg-toolbar-spacer\"></div>\n\n <div v-if=\"toolbarHas('theme')\" class=\"eg-toolbar-group\">\n <select\n class=\"eg-toolbar-select\"\n :value=\"internalTheme\"\n :disabled=\"toolbarDisabled('theme')\"\n :title=\"t.theme\"\n @change=\"setTheme($event.target.value)\"\n >\n <option value=\"excel\">Excel</option>\n <option value=\"material\">Material</option>\n <option value=\"antd\">Ant Design</option>\n <option value=\"fluent\">Fluent</option>\n <option value=\"carbon\">Carbon</option>\n </select>\n </div>\n\n <div v-if=\"toolbarHas('size')\" class=\"eg-toolbar-segmented\" role=\"radiogroup\" :aria-label=\"t.size\">\n <button\n v-for=\"key in sizeOptions\"\n :key=\"key\"\n type=\"button\"\n role=\"radio\"\n class=\"eg-toolbar-btn eg-toolbar-segment\"\n :class=\"['eg-toolbar-segment-' + key, { 'eg-toolbar-btn-active': internalSize === key }]\"\n :aria-checked=\"internalSize === key\"\n :disabled=\"toolbarDisabled('size')\"\n :title=\"sizeTitle(key)\"\n @click=\"setSize(key)\"\n ><span class=\"eg-toolbar-icon\" :class=\"'eg-icon-size-' + key\"></span></button>\n </div>\n\n <div class=\"eg-toolbar-group\">\n <button\n v-if=\"toolbarHas('lock')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': internalLocked }\"\n :disabled=\"toolbarDisabled('lock')\"\n :title=\"internalLocked ? t.unlock : t.lock\"\n @click=\"toggleLock\"\n ><span class=\"eg-toolbar-icon\" :class=\"internalLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"></span></button>\n <button\n v-if=\"toolbarHas('fullscreen')\"\n type=\"button\"\n class=\"eg-toolbar-btn\"\n :class=\"{ 'eg-toolbar-btn-active': isFullscreen }\"\n :disabled=\"toolbarDisabled('fullscreen')\"\n :title=\"isFullscreen ? t.exitFullscreen : t.fullscreen\"\n @click=\"toggleFullscreen\"\n ><span class=\"eg-toolbar-icon\" :class=\"isFullscreen ? 'eg-icon-compress' : 'eg-icon-expand'\"></span></button>\n </div>\n </div>\n\n <div\n class=\"eg-wrapper\"\n :class=\"'eg-size-' + internalSize\"\n :style=\"wrapperStyle\"\n tabindex=\"0\"\n @keydown=\"onKeyDown\"\n @keypress=\"onKeyPress\"\n @mousemove=\"onGridMouseMove\"\n @mouseleave=\"onGridMouseLeave\"\n ref=\"wrapper\"\n >\n <!-- Context menu -->\n <div\n v-if=\"contextMenu.visible\"\n key=\"eg-context-menu\"\n ref=\"contextMenuEl\"\n class=\"eg-context-menu\"\n :style=\"{ top: contextMenu.y + 'px', left: (contextMenuPositioned ? contextMenu.x : -9999) + 'px', visibility: contextMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" @click=\"ctxCopy\">{{ t.copy }}</div>\n <div class=\"eg-context-item\" @click=\"ctxCut\">{{ t.cut }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPaste\">{{ t.paste }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item eg-context-submenu-parent\">\n {{ t.pasteSpecial }}\n <div class=\"eg-context-submenu\">\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all')\">{{ t.pasteAll }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('values')\">{{ t.pasteValues }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('formats')\">{{ t.pasteFormats }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPasteSpecial('all', true)\">{{ t.transpose }}</div>\n </div>\n </div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxMerge\">{{ t.mergeCells }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnmerge\">{{ t.unmergeCells }}</div>\n <div class=\"eg-context-separator\"></div>\n <div v-if=\"contextMenu.row >= 0\" class=\"eg-context-item\" @click=\"ctxHideRow\">{{ t.hideRows }}</div>\n <div v-if=\"contextMenu.col >= 0\" class=\"eg-context-item\" @click=\"ctxHideColumn\">{{ t.hideColumns }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideRows\">{{ t.unhideRows }}</div>\n <div class=\"eg-context-item\" @click=\"ctxUnhideColumns\">{{ t.unhideColumns }}</div>\n <template v-if=\"contextMenu.row < 0 && contextMenu.col >= 0\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"ctxPinLeft\">{{ t.pinLeft }}</div>\n <div class=\"eg-context-item\" @click=\"ctxPinRight\">{{ t.pinRight }}</div>\n <div class=\"eg-context-item\" v-if=\"isColPinned(contextMenu.col)\" @click=\"ctxUnpin\">{{ t.unpin }}</div>\n </template>\n <template v-if=\"devMode && (contextMenu.col >= 0 || contextMenu.row >= 0)\">\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.col >= 0\" @click=\"ctxShowColumnInfo\">{{ t.columnInfo }}</div>\n <div class=\"eg-context-item\" v-if=\"contextMenu.row >= 0\" @click=\"ctxShowRowInfo\">{{ t.rowInfo }}</div>\n </template>\n </div>\n\n <!-- Sort popup menu (header sort icon) -->\n <div\n v-if=\"sortMenu.visible\"\n key=\"eg-sort-menu\"\n ref=\"sortMenuEl\"\n class=\"eg-context-menu eg-sort-menu\"\n :style=\"{ top: sortMenu.y + 'px', left: (sortMenuPositioned ? sortMenu.x : -9999) + 'px', visibility: sortMenuPositioned ? 'visible' : 'hidden' }\"\n @click.stop\n >\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('asc') }\" @click=\"sortMenuPickAsc\">{{ t.sortMenuAsc }}</div>\n <div class=\"eg-context-item\" :class=\"{ 'eg-context-item-active': isSortMenuOptionActive('desc') }\" @click=\"sortMenuPickDesc\">{{ t.sortMenuDesc }}</div>\n <div class=\"eg-context-separator\"></div>\n <div class=\"eg-context-item\" @click=\"sortMenuPickClear\">{{ t.sortMenuClear }}</div>\n </div>\n\n <!-- devMode: column/row info modal -->\n <div v-if=\"infoModal\" key=\"eg-info-modal-backdrop\" class=\"eg-info-modal-backdrop\" @click=\"closeInfoModal\">\n <div class=\"eg-info-modal\" @click.stop>\n <div class=\"eg-info-modal-header\">\n <span class=\"eg-info-modal-title\">{{ infoModal.title }}</span>\n <button type=\"button\" class=\"eg-info-modal-close\" @click=\"closeInfoModal\">×</button>\n </div>\n <pre class=\"eg-info-modal-body\">{{ infoModal.json }}</pre>\n </div>\n </div>\n\n <!-- Scroll container -->\n <div class=\"eg-container\" :class=\"{ 'eg-col-dragging': colDrag.active }\" ref=\"container\" @scroll=\"onScroll\" @click=\"hideFloatingMenus\">\n\n <!-- ===== HEADER AREA ===== -->\n <div class=\"eg-header-area\" :style=\"headerAreaStyle\">\n\n <!-- Corner cell -->\n <div\n class=\"eg-corner\"\n :style=\"cornerStyle\"\n @click=\"selectAll\"\n ></div>\n\n <!-- Row group outline gutter (left) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-gutter\" :style=\"rowOutlineGutterStyle\"></div>\n\n <!-- Frozen columns header -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, true)\">\n <div\n :key=\"'fh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, true)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'ffilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable columns header -->\n <div class=\"eg-scroll-col-header\" ref=\"scrollColHeader\" :style=\"scrollColHeaderStyle\">\n <!-- Inner width = scrollable columns only: this strip renders no pinned cells\n (they live in the sibling zones), and scrollableContentWidth keeps its max\n scrollLeft identical to the body's (see scrollColHeaderStyle). -->\n <div class=\"eg-scroll-col-header-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: (headerRowHeight * headerDepth + _filterRowH) + 'px', position: 'relative' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellVisible(cell, rowIdx, false)\">\n <div\n :key=\"'sh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyle(cell, rowIdx, false)\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'sfilter-' + ci\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(ci)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns header -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-header\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightHeaderStyle\">\n <div :style=\"{ position: 'relative', height: (headerRowHeight * headerDepth + _filterRowH) + 'px' }\">\n <template v-for=\"(row, rowIdx) in headerRows\">\n <template v-for=\"cell in row\">\n <template v-if=\"isCellInZone(cell, rowIdx, 'right')\">\n <div\n :key=\"'prh-' + rowIdx + '-' + (cell.column.key || cell.column.title)\"\n class=\"eg-header-cell\"\n :class=\"{ 'eg-header-group': cell.isGroup, 'eg-header-cell--drag-source': colDrag.active && cell._firstLeafFlatIndex >= colDrag.sourceFirstLeaf && cell._firstLeafFlatIndex < colDrag.sourceFirstLeaf + colDrag.sourceLeafCount }\"\n :data-fi=\"!cell.isGroup ? cell.column._flatIndex : undefined\"\n :data-start=\"cell.isGroup ? cell._firstLeafFlatIndex : undefined\"\n :data-end=\"cell.isGroup ? cell._firstLeafFlatIndex + cell.colspan - 1 : undefined\"\n :style=\"getHeaderCellStyleForZone(cell, rowIdx, 'right')\"\n @mousedown=\"onColHeaderMouseDown($event, cell)\"\n @dblclick=\"onColHeaderDblClick($event, cell)\"\n @contextmenu.prevent=\"onColHeaderContextMenu($event, cell)\"\n >\n <slot v-if=\"!cell.isGroup\" :name=\"'header-' + cell.column.key\" :column=\"cell.column\">\n {{ cell.column.title }}\n </slot>\n <span v-else>{{ cell.column.title }}</span>\n <span\n v-if=\"!cell.isGroup && isColSortable(cell.column)\"\n class=\"eg-sort-icon\"\n :class=\"sortIconClass(cell.column)\"\n :title=\"sortIconTitle()\"\n @mousedown.stop\n @click.stop=\"openSortMenu($event, cell.column)\"\n ></span>\n <div\n v-if=\"!cell.isGroup\"\n class=\"eg-col-resize-handle\"\n @mousedown.stop=\"startColResize($event, cell.column._flatIndex)\"\n ></div>\n </div>\n </template>\n </template>\n </template>\n <template v-if=\"filterRow\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prfilter-' + col._flatIndex\"\n class=\"eg-filter-cell\"\n :class=\"{ 'eg-filter-cell-empty': filterControlType(col) === 'none' }\"\n :style=\"getFilterCellStyle(col._flatIndex)\"\n @mousedown.stop\n >\n <input\n v-if=\"filterControlType(col) === 'text'\"\n type=\"text\"\n class=\"eg-filter-input\"\n :value=\"internalFilters[col.key] || ''\"\n :placeholder=\"t.filterPlaceholder\"\n @input=\"setFilter(col.key, $event.target.value)\"\n @keydown.stop\n @click.stop\n />\n <select\n v-else-if=\"filterControlType(col) === 'select'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option v-for=\"opt in filterOptionsFor(col)\" :key=\"opt.value\" :value=\"opt.value\">{{ opt.label }}</option>\n </select>\n <select\n v-else-if=\"filterControlType(col) === 'checkbox'\"\n class=\"eg-filter-select\"\n :value=\"internalFilters[col.key] || ''\"\n @change=\"setFilter(col.key, $event.target.value)\"\n @click.stop\n >\n <option value=\"\">{{ t.filterAll }}</option>\n <option value=\"true\">{{ t.filterChecked }}</option>\n <option value=\"false\">{{ t.filterUnchecked }}</option>\n </select>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- ===== BODY AREA ===== -->\n <div class=\"eg-body-area\" :style=\"bodyAreaStyle\">\n\n <!-- Row number header column -->\n <div class=\"eg-row-header-col\" :style=\"rowHeaderColStyle\">\n <!-- Scrollable row headers -->\n <div class=\"eg-scroll-row-headers\" :style=\"scrollRowHeadersStyle\" ref=\"scrollRowHeaders\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'srh-' + ri\"\n class=\"eg-row-header-cell\"\n :style=\"getRowHeaderStyle(ri)\"\n :data-row=\"ri\"\n @mousedown=\"onRowHeaderMouseDown($event, ri)\"\n @contextmenu.prevent=\"onRowContextMenu($event, ri)\"\n >\n {{ ri + 1 }}\n <div class=\"eg-row-resize-handle\" @mousedown.stop=\"startRowResize($event, ri)\"></div>\n </div>\n </template>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Row group outline gutter (left side of rows) -->\n <div v-if=\"rowGroups.length\" class=\"eg-row-outline-col\" :style=\"rowOutlineColStyle\">\n <template v-for=\"group in rowGroups\">\n <div\n :key=\"'rg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getRowGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n <!-- Main scroll body -->\n <div class=\"eg-scroll-body\" ref=\"scrollBody\" :style=\"scrollBodyStyle\" @scroll=\"onBodyScroll\">\n <!-- display:flex puts the two sticky pinned zones side by side at y=0 (as\n blocks they'd stack vertically, pushing pinned-right below the rows), and\n lets margin-left:auto give pinned-right its natural place at the inner's\n RIGHT edge — sticky right:0 only ever pulls an element back toward its\n natural position, so from x=0 (block flow) it would never stick at all.\n .eg-main-cells is position:absolute and doesn't participate. -->\n <div class=\"eg-scroll-body-inner\" :style=\"{ width: scrollContentWidth + 'px', height: totalVirtualHeight + 'px', position: 'relative', display: 'flex' }\">\n\n <!-- Pinned left column body -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'fc-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\" :data-row=\"ri\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div\n :key=\"'fc-cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Main data cells -->\n <div class=\"eg-main-cells\" :style=\"mainCellsStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative', width: scrollableContentWidth + 'px' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div\n v-if=\"!isRowHidden(ri)\"\n :key=\"'row-' + ri\"\n class=\"eg-row\"\n :class=\"{ 'eg-row--even': ri % 2 === 0 }\"\n :style=\"getRowStyle(ri)\"\n :data-row=\"ri\"\n >\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div\n :key=\"'cell-' + ri + '-' + ci\"\n class=\"eg-cell\"\n :data-fi=\"ci\"\n :class=\"getCellClasses(ri, ci)\"\n :style=\"[getCellStyle(ri, ci), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, ci)\"\n @mouseover=\"onCellMouseOver($event, ri, ci)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, ci)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, ci)\"\n >\n <div v-if=\"isMergedAway(ri, ci)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, ci)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input\n v-else\n ref=\"editInput\"\n class=\"eg-cell-input\"\n v-model=\"editValue\"\n @input=\"onEditInput\"\n @blur=\"commitEdit\"\n @keydown.stop=\"onEditKeyDown\"\n />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, ci)\"\n :disabled=\"!isCellEditable(ri, ci)\"\n @change=\"(v) => setCheckboxValue(ri, ci, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, ci, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(ci, getCellValue(ri, ci), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, ci)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Pinned Right column body -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-body\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightBodyStyle\">\n <div :style=\"{ height: totalVirtualHeight + 'px', position: 'relative' }\">\n <div :style=\"{ transform: 'translateY(' + virtualOffsetTop + 'px)', willChange: 'transform', contain: 'layout style' }\">\n <template v-for=\"ri in visibleRowRange\">\n <div v-if=\"!isRowHidden(ri)\" :key=\"'pr-row-' + ri\" class=\"eg-row\" :class=\"{ 'eg-row--even': ri % 2 === 0 }\" :style=\"getRowStyle(ri)\" :data-row=\"ri\">\n <template v-for=\"col in pinnedRightCols\">\n <div\n :key=\"'pr-cell-' + ri + '-' + col._flatIndex\"\n class=\"eg-cell\"\n :data-fi=\"col._flatIndex\"\n :class=\"getCellClasses(ri, col._flatIndex)\"\n :style=\"[getCellStyle(ri, col._flatIndex), getRowCustomStyle(ri)]\"\n @mousedown=\"onCellMouseDown($event, ri, col._flatIndex)\"\n @mouseover=\"onCellMouseOver($event, ri, col._flatIndex)\"\n @mouseup=\"onCellMouseUp\"\n @dblclick=\"onCellDblClick(ri, col._flatIndex)\"\n @contextmenu.prevent=\"onCellContextMenu($event, ri, col._flatIndex)\"\n >\n <div v-if=\"isMergedAway(ri, col._flatIndex)\" style=\"display:none\"></div>\n <template v-else-if=\"isEditing(ri, col._flatIndex)\">\n <slot v-if=\"hasCellSlot(col)\" :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"true\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <slot v-else-if=\"hasLegacyEditSlot(col)\" :name=\"'edit-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :done=\"(v) => commitEditSlot(v)\" :cancel=\"cancelEdit\" />\n <input v-else ref=\"editInput\" class=\"eg-cell-input\" v-model=\"editValue\" @input=\"onEditInput\" @blur=\"commitEdit\" @keydown.stop=\"onEditKeyDown\" />\n </template>\n <template v-else-if=\"col.type === 'checkbox'\">\n <eg-checkbox\n :value=\"isCheckboxChecked(ri, col._flatIndex)\"\n :disabled=\"!isCellEditable(ri, col._flatIndex)\"\n @change=\"(v) => setCheckboxValue(ri, col._flatIndex, v)\"\n />\n </template>\n <template v-else-if=\"col.type === 'action'\">\n <span class=\"eg-cell-action-wrap\" @[actionHandlerEvent(col)]=\"triggerCellAction(ri, col._flatIndex, $event)\">\n <slot :name=\"'action-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" />\n </span>\n </template>\n <template v-else>\n <slot :name=\"'cell-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" :isEditing=\"false\" :done=\"null\" :cancel=\"null\">\n <slot v-if=\"hasPrefixSlot(col)\" :name=\"'prefix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /><template v-if=\"hasSuffixSlot(col) && isSelectLikeCol(col)\"><span class=\"eg-cell-select-trigger-wrap\"><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span><span class=\"eg-cell-select-caret-inline\">▾</span></span></template><template v-else><span class=\"eg-cell-value\">{{ getCellDisplayValue(col._flatIndex, getCellValue(ri, col._flatIndex), ri) }}</span></template><span v-if=\"hasSuffixSlot(col)\" class=\"eg-cell-suffix-wrap\"><slot :name=\"'suffix-' + col.key\" :row=\"_dataRaw[ri]\" :rowIndex=\"ri\" :column=\"col\" :value=\"getCellValue(ri, col._flatIndex)\" /></span>\n </slot>\n </template>\n </div>\n </template>\n </div>\n </template>\n </div>\n </div>\n </div>\n\n <!-- Fill handle (shown on primary range) -->\n <div\n v-if=\"fillHandleVisible\"\n class=\"eg-fill-handle\"\n :style=\"fillHandleStyle\"\n @mousedown.stop=\"startFillDrag\"\n ></div>\n\n <!-- Drag move ghost border -->\n <div\n v-if=\"dragMove.active\"\n class=\"eg-drag-ghost\"\n :style=\"dragGhostStyle\"\n ></div>\n </div>\n </div>\n\n <!-- Column group outline row (top, above body) -->\n <div v-if=\"colGroups.length\" class=\"eg-col-outline-row\" :style=\"colOutlineRowStyle\">\n <template v-for=\"group in colGroups\">\n <div\n :key=\"'cg-' + group.startIndex + '-' + group.endIndex\"\n class=\"eg-group-btn\"\n :class=\"{ collapsed: isGroupCollapsed(group) }\"\n :style=\"getColGroupBtnStyle(group)\"\n @click=\"toggleGroup(group)\"\n >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>\n </template>\n </div>\n\n </div><!-- end body-area -->\n\n <!-- ===== FOOTER AREA (column totals) ===== -->\n <div v-if=\"hasFooter\" class=\"eg-footer-area\" :style=\"footerAreaStyle\">\n\n <!-- \"Jami\" label cell (mirrors corner cell) -->\n <div class=\"eg-footer-label\" :style=\"footerLabelStyle\">{{ t.total }}</div>\n\n <!-- Frozen columns footer -->\n <div v-if=\"getFrozenWidth() > 0\" class=\"eg-frozen-col-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollLeft() }\" :style=\"frozenColFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnPinnedLeft(ci) && !isColHidden(ci)\">\n <div :key=\"'ff-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Scrollable footer (horizontally synced with body via scrollFooter ref) -->\n <div class=\"eg-scroll-col-footer\" ref=\"scrollFooter\" :style=\"scrollColHeaderStyle\">\n <div class=\"eg-scroll-col-footer-inner\" :style=\"{ width: scrollableContentWidth + 'px', height: footerRowHeight + 'px', position: 'relative' }\">\n <template v-for=\"(col, ci) in leafCols\">\n <template v-if=\"isColumnScrollable(ci) && !isColHidden(ci)\">\n <div :key=\"'sf-' + ci\" class=\"eg-footer-cell\" :class=\"getFooterCellClass(ci)\" :style=\"getFooterCellStyle(ci)\">\n {{ getColumnTotalDisplay(ci) }}\n </div>\n </template>\n </template>\n </div>\n </div>\n\n <!-- Pinned Right columns footer -->\n <div v-if=\"hasPinnedRight\" class=\"eg-pinned-right-footer\" :class=\"{ 'eg-shadow-edge': hasHiddenScrollRight() }\" :style=\"pinnedRightFooterStyle\">\n <div :style=\"{ position: 'relative', height: footerRowHeight + 'px' }\">\n <div\n v-for=\"col in pinnedRightCols\"\n :key=\"'prf-' + col._flatIndex\"\n class=\"eg-footer-cell\"\n :class=\"getFooterCellClass(col._flatIndex)\"\n :style=\"getFooterCellStyle(col._flatIndex)\"\n >{{ getColumnTotalDisplay(col._flatIndex) }}</div>\n </div>\n </div>\n </div><!-- end footer-area -->\n\n <!-- Resize indicator line -->\n <div v-if=\"resizing.active\" class=\"eg-resize-line\" :style=\"resizeLineStyle\"></div>\n\n <!-- Loading overlay (host-driven, e.g. during an async data fetch) -->\n <div v-if=\"loading\" class=\"eg-loading-overlay\">\n <template v-if=\"columns && columns.length\">\n <div class=\"eg-skeleton\">\n <div class=\"eg-skeleton-row eg-skeleton-row-header\" :style=\"{ height: headerRowHeight + 'px' }\">\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-h-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n <div\n v-for=\"r in 20\"\n :key=\"'sk-r-' + r\"\n class=\"eg-skeleton-row\"\n :style=\"{ height: _rowH + 'px' }\"\n >\n <div\n v-for=\"col in leafCols\"\n :key=\"'sk-c-' + col._flatIndex\"\n class=\"eg-skeleton-block\"\n :style=\"{ width: getColW(col._flatIndex) + 'px' }\"\n ></div>\n </div>\n </div>\n </template>\n <template v-else>\n <div class=\"eg-loading-spinner\"></div>\n </template>\n </div>\n </div><!-- end container -->\n\n <!-- Import progress overlay -->\n <div v-if=\"importState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl import qilinmoqda</div>\n <div class=\"eg-import-bar-wrap\">\n <div class=\"eg-import-bar\" :style=\"{ width: importState.percent + '%' }\"></div>\n </div>\n <div class=\"eg-import-msg\">{{ importState.message }}</div>\n <div class=\"eg-import-pct\">{{ importState.percent }}%</div>\n </div>\n </div>\n\n <!-- Export progress overlay -->\n <div v-if=\"exportState.active\" class=\"eg-import-overlay\">\n <div class=\"eg-import-dialog\">\n <div class=\"eg-import-title\">Fayl export qilinmoqda...</div>\n <div class=\"eg-export-spinner\"></div>\n </div>\n </div>\n\n <!-- Local sync modal (localSyncKey feature) -->\n <eg-local-sync-modal\n v-if=\"localSyncKey\"\n :visible=\"localSyncModal.visible\"\n :grid-options=\"_selfComponentDef\"\n :columns=\"_localSyncPreviewColumns\"\n :versions=\"localSyncModal.versions\"\n :selected-id=\"localSyncModal.selectedId\"\n :selected-version=\"localSyncModal.selectedVersion\"\n :locale=\"locale\"\n :theme=\"internalTheme\"\n @close=\"closeLocalSyncModal\"\n @select=\"_selectLocalSyncModalVersion\"\n @save=\"_onLocalSyncModalSave\"\n @restore=\"restoreLocalVersion\"\n @clear=\"_onLocalSyncModalDelete\"\n />\n\n <!-- Lock/unlock state feedback — brief centered icon flash -->\n <div v-if=\"lockFlashVisible\" class=\"eg-lock-flash\" aria-hidden=\"true\">\n <span\n ref=\"lockFlashIconEl\"\n class=\"eg-lock-flash-icon\"\n :class=\"lockFlashLocked ? 'eg-icon-lock' : 'eg-icon-unlock'\"\n ></span>\n </div>\n\n <!-- Select dropdown overlay (flat select) -->\n <eg-select\n v-if=\"selectDropdown && !selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n <!-- Tree select dropdown overlay -->\n <eg-tree-select\n v-if=\"selectDropdown && selectDropdown.tree\"\n ref=\"selectDropdownComp\"\n :value=\"selectDropdown.currentValue\"\n :options=\"selectDropdown.rawOptions\"\n :value-key=\"selectDropdown.keys.valueKey\"\n :label-key=\"selectDropdown.keys.labelKey\"\n :children-key=\"selectDropdown.keys.childrenKey\"\n :enable-search=\"selectDropdown.enableSearch\"\n :trigger-rect=\"selectDropdown.rect\"\n :dropdown-only=\"true\"\n :size=\"internalSize\"\n :locale=\"locale\"\n :clearable=\"selectDropdown.clearable\"\n @select=\"onComponentSelect\"\n @close=\"closeSelectDropdown\"\n />\n\n <!-- Date picker overlay -->\n <eg-date-picker\n v-if=\"datePickerOverlay\"\n ref=\"datePickerComp\"\n :value=\"datePickerOverlay.value\"\n :format=\"datePickerOverlay.format\"\n :locale=\"locale\"\n :trigger-rect=\"datePickerOverlay.rect\"\n @pick=\"onDatePick\"\n @typing=\"onDateTyping\"\n @close=\"closeDatePicker\"\n />\n\n <!-- Validation error tooltip (cellValidator) -->\n <div\n v-if=\"invalidCell && invalidCell.rect\"\n class=\"eg-validation-tooltip\"\n :style=\"invalidTooltipStyle\"\n >{{ invalidCell.message }}</div>\n\n <!-- Column drag ghost — a header-shaped copy of the grabbed column that follows\n the cursor with the original grab point preserved (position updated via\n direct DOM in handleColDragMove) -->\n <div\n v-if=\"colDrag.active\"\n ref=\"colDragGhost\"\n class=\"eg-col-drag-ghost\"\n :style=\"{\n left: (colDrag.startX - colDrag.grabOffsetX) + 'px',\n top: (colDrag.startY - 14) + 'px',\n width: colDrag.ghostWidth + 'px',\n height: _headerRowH + 'px'\n }\"\n >{{ colDrag.ghostLabel }}</div>\n\n </div>\n </div>\n</template>\n\n<script>\nimport {\n normalizeRange, isCellInRange, isCellInAnyRange,\n buildRange, singleCellRange, getPrimaryRange\n} from '../utils/selection.js'\nimport { flattenColumns, buildHeaderRows, columnTreeDepth, moveColumnNodeInTree } from '../utils/headerTree.js'\nimport { calcVisibleRows, buildCumulativeHeights, findVisibleRange, getRowTop, getRowHeight } from '../utils/virtualScroll.js'\nimport { generateFillValues } from '../utils/fillHandle.js'\nimport {\n matrixToTSV, tsvToMatrix, extractMatrix, transposeMatrix, applyMatrix, tileMatrix\n} from '../utils/clipboard.js'\nimport {\n mergeHasConflict, findMergeAtCell, isMergeAnchor, addMerge,\n removeMergeAt, resolveMergeAnchor\n} from '../utils/merge.js'\nimport {\n addGroup, removeGroup, toggleGroupCollapse, isGroupCollapsed as _isGroupCollapsed,\n getCollapsedRowIndices, getCollapsedColIndices\n} from '../utils/outline.js'\nimport { exportToExcel as _exportToExcel } from '../utils/excelExport.js'\nimport { buildPrintTableHtml } from '../utils/printTable.js'\nimport { importFromFile as _importFromFile } from '../utils/excelImport.js'\nimport { createExcelImportWorker, createExcelExportWorker } from '../utils/workerFactory'\nimport { getMessages } from '../utils/i18n.js'\nimport EgCheckbox from './EgCheckbox.vue'\nimport anime from 'animejs'\nimport EgSelect from './EgSelect.vue'\nimport EgTreeSelect from './EgTreeSelect.vue'\nimport EgDatePicker from './EgDatePicker.vue'\nimport EgLocalSyncModal from './EgLocalSyncModal.vue'\nimport { parseDateValue, formatDate, DEFAULT_DATE_FORMAT } from '../utils/dateUtils.js'\nimport { formatNumber as _formatNumber } from '../utils/numberFormat.js'\nimport { Mask, MaskInput } from '../utils/mask.js'\nimport { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'\nimport { hashRows, hashRowsAsync } from '../utils/dataHash.js'\n\nconst SIZE_CONFIG = {\n small: { row: 22, header: 24, footer: 24, filter: 26 },\n middle: { row: 28, header: 28, footer: 28, filter: 32 },\n large: { row: 36, header: 36, footer: 36, filter: 40 }\n}\nconst ROW_HEADER_WIDTH = 50\nconst DEFAULT_COL_WIDTH = 100\nconst OUTLINE_GUTTER = 20\nconst FIT_COLUMNS_MIN_WIDTH = 40\nconst ROW_LIST_EDGE_PAD = 8 // birinchi/oxirgi qator bilan tepa/pastki border orasidagi bo'sh joy (px)\nconst FILL_DRAG_EDGE_ZONE = 24 // px from .eg-scroll-body's edge that triggers continuous auto-scroll while fill-dragging\nconst FILL_DRAG_MAX_SPEED = 18 // px/frame, at the very edge (scales down to 0 at the zone's outer boundary)\n\n// Classifies a col.width spec so resolveColWidth() knows how to turn it into a\n// concrete px number. Plain numbers/px strings resolve instantly with no DOM\n// work; '%' resolves via arithmetic against the grid's own measured viewport\n// width; any other CSS length (em, rem, vw, calc(), ...) needs a DOM probe.\nfunction parseColWidthSpec(spec) {\n if (typeof spec === 'number' && !isNaN(spec)) return { type: 'px', value: spec }\n if (typeof spec === 'string') {\n const trimmed = spec.trim()\n if (/^\\d+(\\.\\d+)?$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?px$/.test(trimmed)) return { type: 'px', value: parseFloat(trimmed) }\n if (/^\\d+(\\.\\d+)?%$/.test(trimmed)) return { type: 'percent', value: parseFloat(trimmed) }\n if (trimmed) return { type: 'css', value: trimmed }\n }\n return { type: 'px', value: DEFAULT_COL_WIDTH }\n}\n// Default toolbar tool list when `toolbar` is `true` or an object without\n// its own `list` — every tool group. Like `size`, the theme picker mirrors\n// its own `internalTheme` state and works standalone; `:theme.sync` is only\n// needed if the host wants to read the current theme back, not a prerequisite\n// for the picker to function.\nconst DEFAULT_TOOLBAR_LIST = ['undoRedo', 'export', 'import', 'print', 'localSync', 'size', 'lock', 'fullscreen', 'theme']\n// Column drag live-preview transition (AG Grid-like feel). Native CSS transitions\n// retarget smoothly from whatever intermediate position an element is currently\n// at when the drop slot changes mid-flight — no JS animation library races.\nconst COL_DRAG_TRANSITION = 'transform 200ms cubic-bezier(0.2, 0, 0, 1)'\nconst THEME_LIST = ['excel', 'material', 'antd', 'fluent', 'carbon']\n\n// Kept as a named const (rather than an inline `export default {}`) so the\n// local-sync preview modal can receive the plain component definition\n// itself as a prop — see `_selfComponentDef` below. Importing ExcelGrid.vue\n// from EgLocalSyncModal.vue to nest a grid inside it would be a circular\n// import (ExcelGrid.vue -> EgLocalSyncModal.vue -> ExcelGrid.vue); passing\n// this reference down avoids that entirely, and avoids passing `this.$options`\n// (an instance's merged/runtime-contaminated options, not a clean definition).\nconst ExcelGridComponent = {\n name: 'ExcelGrid',\n\n components: { EgCheckbox, EgSelect, EgTreeSelect, EgDatePicker, EgLocalSyncModal },\n\n // v-model → :data + @input\n model: { prop: 'data', event: 'input' },\n\n props: {\n /** Array of row objects keyed by columns[].key */\n data: { type: Array, default: () => [] },\n\n /**\n * Column definitions (may be nested via children[]).\n *\n * `width` accepts anything the CSS `width` property accepts, not just a\n * raw px number:\n * - `Number` (e.g. `150`) or `'150px'` — fixed pixel width (fast path, no\n * DOM measurement).\n * - `'20%'` — relative to the grid's own rendered viewport width. Stays\n * responsive: recalculated whenever the grid's container is resized,\n * exactly like a real CSS percentage — until the column is manually\n * drag-resized (or auto-fit), at which point it becomes a fixed px\n * override and stops rescaling.\n * - Any other CSS length (`'10em'`, `'5rem'`, `'20vw'`, `calc(...)`, ...)\n * — resolved once via a hidden probe element measured against the\n * grid's own font-size/layout. `'auto'`/`'fit-content'` have no\n * intrinsic width this way and fall back to the 100px default; use the\n * `autoWidth` prop or double-click auto-fit for real content-fit sizing.\n *\n * `mask` — input mask for the plain-text editable input (engine adapted\n * from the `maska` library, vendored in `src/utils/mask.js`), applied\n * while typing and to every other value-entry path (paste, fill handle,\n * custom edit slots). Ignored by `select`/`treeSelect`/`date`/`checkbox`/\n * `action` columns, which use their own pickers. The masked/formatted\n * text is what gets written to `data` — display, storage, and export\n * all see the same string.\n * - `String` — e.g. `'(##) ###-##-##'` (`#` digit, `@` letter, `*`\n * alphanumeric by default). Use non-digit separators (spaces, dashes,\n * parens, letters) between token runs — a literal made of digits\n * (e.g. `'998##'`) can misalign against a raw/pasted value whose own\n * digits happen to coincide with the literal's; type the value through\n * the live cell editor or keep the fixed prefix outside the mask\n * (as static UI, e.g. a `#prefix-{key}` slot) if that's a concern.\n * - `Array<String>` — dynamic mask, picked by input length, e.g.\n * `['#-#', '##-##']`.\n * - `Object` — full maska options passthrough (`{ mask, tokens, eager,\n * reversed, ... }`) for custom tokens or advanced behavior.\n *\n * @example\n * columns: [\n * { key: 'id', title: '#', width: 60 },\n * { key: 'name', title: 'Name', width: '30%' },\n * { key: 'note', title: 'Note', width: '12em' },\n * { key: 'phone', title: 'Phone', mask: '(##) ###-##-##' }\n * ]\n */\n columns: { type: Array, default: () => [] },\n\n /** Indices of hidden leaf columns */\n hiddenColumns: { type: Array, default: () => [] },\n\n /** Indices of hidden rows */\n hiddenRows: { type: Array, default: () => [] },\n\n /**\n * Dynamic row visibility: (row, rowIndex) => boolean. Return false to\n * hide that row — same visual effect as `hiddenRows`/collapsed groups\n * (row is not rendered, but stays in `data`; nothing is deleted). Feeds\n * the same unification point as every other row-hiding mechanism, so it\n * automatically applies to virtual scroll, keyboard navigation, and\n * Excel export with no extra wiring.\n *\n * @example\n * // Hide inactive rows\n * <excel-grid :row-visible=\"(row) => row.status !== 'inactive'\" />\n * @example\n * // Hide every other row (by position, not by content)\n * <excel-grid :row-visible=\"(row, rowIndex) => rowIndex % 2 === 0\" />\n */\n rowVisible: { type: Function, default: null },\n\n /** Initial merge descriptors [{ startRow, startCol, endRow, endCol }] */\n merges: { type: Array, default: () => [] },\n\n /** Collapsed group descriptors [{ type, startIndex, endIndex }] */\n collapsedGroups: { type: Array, default: () => [] },\n\n /**\n * Height of the grid container in px.\n *\n * Omit it (or pass `null`) for auto-height: the grid sizes itself to fit\n * the header + filter row + all rows + footer for the current `data`\n * length, capped at 70% of the viewport height (`window.innerHeight`) —\n * beyond that it stops growing and scrolls internally, same as a fixed\n * `height` would. Recomputes reactively as `data` grows/shrinks and on\n * window resize. Does not apply in fullscreen mode (already sized to the\n * viewport there) and does not include the toolbar's own height.\n *\n * Pass an explicit `Number` to opt back into the old fixed-height behavior.\n */\n height: { type: Number, default: null },\n\n /**\n * Width of the grid container (px or a CSS length string).\n * Only applies while `widthMode` is `'full'` — in `'auto'` mode the grid\n * sizes itself to its columns and this prop is ignored.\n */\n width: { type: [Number, String], default: '100%' },\n\n /**\n * How the grid decides its overall width.\n *\n * - `'full'` (default) — the grid takes the `width` prop's value\n * (default `100%` of the host container), regardless of how little\n * space its columns actually need. Current/legacy behavior.\n * - `'auto'` — the grid hugs its content (max-content): its width is\n * the row-number gutter + the sum of all visible column widths\n * (live: reacts to column resize, hide/show, and `col.visible`),\n * plus the vertical scrollbar's width when rows overflow the height.\n * Capped at `max-width: 100%` — when the columns are wider than the\n * host, the grid stops at the host's edge and scrolls horizontally\n * inside, exactly like `'full'`. The `width` prop is ignored.\n *\n * Fullscreen mode always fills the viewport, whatever this is set to.\n *\n * @example\n * <excel-grid v-model=\"rows\" :columns=\"columns\" width-mode=\"auto\" />\n */\n widthMode: {\n type: String,\n default: 'full',\n validator: (v) => ['full', 'auto'].includes(v)\n },\n\n /**\n * Called before drag-drop: (sourceRange, targetRange) => bool\n * Return false to cancel the drop.\n */\n beforeDrop: { type: Function, default: null },\n\n /**\n * UI locale for the context menu.\n * Supported: 'en' | 'ru' | 'uz' | 'uz-Cyrl'\n */\n locale: { type: String, default: 'en' },\n\n /**\n * Pinned columns map: { [colKey]: 'left' | 'right' }\n * Pinned-left columns render as a fixed zone on the left.\n * Pinned-right columns render as a fixed zone on the right.\n * Emit 'update:pinnedColumns' to support .sync modifier.\n */\n pinnedColumns: { type: Object, default: () => ({}) },\n\n /**\n * Dynamic cell editability: (row, rowIndex, col, colIndex) => boolean\n * Overrides column-level editable when provided.\n * row = plain data object, col = column definition.\n */\n cellEditable: { type: Function, default: null },\n\n /**\n * Cell value validation: (value, row, rowIndex, col, colIndex) => boolean\n * Return false to reject the write — the cell keeps its old value and\n * an error tooltip is shown anchored to that cell.\n */\n cellValidator: { type: Function, default: null },\n\n /**\n * Error tooltip text for a rejected value.\n * Function: (value, row, rowIndex, col, colIndex) => string\n * String: same message for every rejected cell.\n * Default: locale's \"invalidValue\" message.\n */\n cellValidationMessage: { type: [Function, String], default: null },\n\n /**\n * Bulk-write strictness (paste / fill handle / Delete).\n * false (default): invalid cells in the affected range are skipped,\n * the rest of the operation still applies.\n * true: if ANY cell in the affected range fails validation, the whole\n * bulk operation is rejected — nothing is written.\n */\n strictBulkValidation: { type: Boolean, default: false },\n\n /**\n * Paste auto-grows rows: false (default) — pasting a clipboard matrix\n * taller than the remaining rows below the paste target clips to the\n * existing rows, same as before. true — missing rows are appended to\n * `data` to fit the full pasted range (Excel/Sheets-style). New rows'\n * columns not covered by the pasted matrix get a type-aware blank value\n * (`type: 'checkbox'` → false, everything else → ''). Columns are never\n * auto-created — pasting more columns than exist still clips.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows />\n */\n pasteGrowsRows: { type: Boolean, default: false },\n\n /**\n * Extra fields merged into rows auto-created by pasteGrowsRows, beyond\n * the type-aware column blanks. Rows only ever get values for `columns`\n * keys — anything else your app tracks per row (an id, a visibility\n * flag `rowVisible` depends on, a nested list, etc.) is NOT set unless\n * provided here, since the grid has no way to know about it.\n * Object: same fields merged into every new row.\n * Function(newRowIndex) => Object: computed per new row (e.g. a fresh\n * uuid per row). `newRowIndex` is the row's index in the resulting\n * `data` array.\n * Applied before the pasted values are written, so pasted cells always\n * win over any column-key present here.\n * For a single aggregate action after ALL rows from one paste have been\n * created and written to v-model, listen for the `rows-added` event\n * instead of doing it here (this runs once per row, before the commit).\n * @type {Object|Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" paste-grows-rows\n * :paste-row-defaults=\"() => ({ uuid: uuidv4(), state: 1 })\" />\n */\n pasteRowDefaults: { type: [Object, Function], default: null },\n\n /** Visual size of the grid. Affects row/header height and font sizes. */\n size: { type: String, default: 'middle' },\n\n /** Auto-fit all column widths to content (same as double-clicking each resize handle). Re-applies on data/columns change. */\n autoWidth: { type: Boolean, default: false },\n\n /**\n * Forces every visible leaf column to fit within a single viewport —\n * no horizontal scroll. Column widths are still resolved (`col.width`,\n * `%`/`px`/other CSS lengths, `DEFAULT_COL_WIDTH` when omitted) but only\n * as RELATIVE WEIGHTS: all resolved widths are scaled by one shared\n * factor so their sum exactly fills the available width, keeping each\n * column's proportion to the others. Each column still gets at least\n * `40px` — if there are enough columns that even that minimum doesn't\n * fit, horizontal scroll returns for that (rare) case rather than\n * shrinking columns illegibly thin.\n *\n * Highest-priority width control: overrides `col.width` as an absolute\n * value and `autoWidth`. Manual column resize (drag handle) and\n * double-click auto-fit ARE both allowed — either one turns that column\n * into a fixed px width, excluded from the fit; the rest keep scaling\n * proportionally to fill what's left, which can reintroduce horizontal\n * scroll if the fixed column(s) alone exceed the viewport. Pinned\n * columns count toward the fit too — the goal is the whole row fitting,\n * regardless of pin state.\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" fit-columns />\n */\n fitColumns: { type: Boolean, default: false },\n\n /**\n * Allow columns to be reordered by dragging their headers.\n * On drop, emits 'update:columns' (compatible with .sync) and 'column-reorder'.\n */\n columnDraggable: { type: Boolean, default: true },\n\n /**\n * Controls the built-in toolbar above the grid — visibility as a whole,\n * and (optionally) which individual tool groups appear. Controlled\n * entirely by the parent — no in-component toggle for the bar itself.\n *\n * @type {Boolean|{visible?: Boolean, list?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>, disabled?: Array<('undoRedo'|'export'|'import'|'print'|'size'|'lock'|'fullscreen'|'theme')>}}\n * @default false\n *\n * Two forms:\n * - `Boolean` — `false` (default) hides the toolbar entirely; `true`\n * shows it with the default tool list — every group, including\n * `'theme'` (it mirrors its own internal state like `size` does;\n * `:theme.sync` is optional, only needed if the parent wants to read\n * the current theme back).\n * - `Object { visible, list, disabled }` — fine-grained control:\n * - `visible` {Boolean} — defaults to `true` when an object is given\n * (passing an object is itself an opt-in to show the toolbar);\n * set explicitly to `false` to hide it while still describing a\n * `list` you intend to reuse later.\n * - `list` {Array<String>} — which tool groups render, using the\n * identifiers above. Unknown/misspelled strings are silently\n * ignored. Omitting `list` falls back to the same default set as\n * the `true` shorthand. **Order in `list` does not change the\n * toolbar's layout** — groups always render in a fixed position\n * (undo/redo → export/import/print → #toolbar-extra slot → toolbarButtons\n * → spacer → theme → size → lock/fullscreen); `list` is purely an\n * inclusion filter.\n * - `disabled` {Array<String>} — which tool groups render grayed out\n * and unclickable while staying **visible** (same identifiers as\n * `list`) — distinct from `list`, which removes a tool entirely.\n * Scoped to the toolbar button itself: it doesn't block the\n * underlying keyboard shortcut (only `'undoRedo'` has one —\n * Ctrl+Z/Ctrl+Y) or a ref method call (`undo()`, `exportToExcel()`,\n * etc.), matching how `list` also only hides the button, not the\n * shortcut/method. Omitting `disabled` disables nothing (default).\n *\n * `toolbarButtons` and the `#toolbar-extra` slot are independent of\n * this prop — they render whenever content is provided, regardless of\n * `list`/`disabled`.\n *\n * @example\n * // Whole default set\n * <excel-grid toolbar />\n * @example\n * // Only undo/redo and fullscreen\n * <excel-grid :toolbar=\"{ list: ['undoRedo', 'fullscreen'] }\" />\n * @example\n * // Toggle visibility from a data property while keeping a fixed list\n * <excel-grid :toolbar=\"{ visible: showToolbar, list: ['export', 'import'] }\" />\n * @example\n * // Visible but grayed out — e.g. import disabled while a prior import is processing\n * <excel-grid :toolbar=\"{ disabled: ['import', 'size'] }\" />\n * @example\n * // Read the current theme back (optional — the picker works without it too)\n * <excel-grid toolbar :theme.sync=\"myTheme\" />\n */\n toolbar: { type: [Boolean, Object], default: false },\n\n /** Extra custom toolbar buttons: [{ key, icon, title, onClick(event, gridApi) }] */\n toolbarButtons: { type: Array, default: () => [] },\n\n /**\n * Print (`printGrid()`/toolbar \"Print\" tugmasi) chiqishida jadval tepasida,\n * markazda ko'rinadigan sarlavha. `null` (standart) — sarlavha umuman\n * render qilinmaydi.\n * @property {String} title - sarlavha matni (bo'sh/undefined bo'lsa render qilinmaydi)\n * @property {Object} [style] - Vue :style uslubidagi obyekt (camelCase),\n * standart markazlashtirilgan/qalin uslub USTIGA qo'shiladi\n * @property {String} [class] - qo'shimcha CSS klass nomi(lari)\n * @example\n * <excel-grid :print-title=\"{ title: 'Talabalar ro\\'yxati', style: { color: '#1a73e8' } }\" />\n */\n printTitle: { type: Object, default: null },\n\n /**\n * Locks the entire grid: blocks all edits (typing, paste, fill, delete,\n * checkbox, select/treeSelect) and structural changes (resize, column\n * reorder, merge/unmerge, undo/redo). Independent from cellEditable/\n * col.editable, which remain per-cell rules. Emit 'update:locked' to\n * support the .sync modifier.\n */\n locked: { type: Boolean, default: false },\n\n /**\n * Host-driven loading state (e.g. while an async data fetch is in\n * flight). When `true`, only the grid body (`.eg-container`) is\n * covered — the toolbar stays visible and interactive. If `columns`\n * is non-empty, a shimmering skeleton matching each column's width is\n * shown; otherwise a plain centered spinner is shown. Purely visual —\n * doesn't block edits/interaction the way `locked` does (combine both\n * if that's also needed).\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :loading=\"isFetching\" />\n */\n loading: { type: Boolean, default: false },\n\n /**\n * Data-driven per-row styling: `(row, rowIndex) => styleObject`. The\n * returned style object is applied to every cell in that row (across\n * the frozen/scrollable/pinned-right zones alike), giving the row a\n * continuous horizontal background/style without any host-side CSS.\n * Selection/active/fill-preview/readonly cell highlighting keeps\n * winning visually — those already use `!important`, which always\n * beats a plain (non-`!important`) inline style. Doesn't affect the\n * row-number header column (mirrors Excel: conditional formatting\n * colors data cells, not the row gutter). Return `null`/`undefined`\n * for rows that shouldn't get a custom style.\n * @type {Function}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" :row-style=\"(row) => row.status === 'error' ? { background: '#fde2e2' } : null\" />\n */\n rowStyle: { type: Function, default: null },\n\n /** Show \"Column info\" / \"Row info\" items in the context menu (dev/debug aid). */\n devMode: { type: Boolean, default: false },\n\n /**\n * Visual theme of the grid.\n * Built-in: 'excel' | 'material' | 'antd' | 'fluent' | 'carbon'\n * Default is the classic Excel 365 look. Use 'material' for Material Design 3.\n * Mirrors the `size` prop's convention: an internal `internalTheme` state\n * mirrors this prop and is what the toolbar's theme picker actually reads\n * from/writes to (via `setTheme()`), so the picker works standalone even\n * without `:theme.sync` — `.sync` is only needed to reflect the current\n * theme back into the host's own state.\n */\n theme: { type: String, default: 'excel' },\n\n /**\n * Shows a filter row directly beneath the column headers (inside the\n * sticky header area). Each leaf column gets a filter control matched to\n * its `type`:\n * - default (no type / 'date' / 'progress'): text search input,\n * matched case-insensitively against the column's *displayed* value\n * (so 'date' matches the formatted string, not the raw stored value).\n * - 'select' / 'treeSelect' *with a static `col.options` array*: a\n * native <select> populated from those options (tree options are\n * flattened, indented by depth), matched by exact raw value.\n * - 'select' / 'treeSelect' with function-based `col.options` (options\n * computed per-row): falls back to the text control, since a single\n * fixed option list can't be resolved outside a specific row.\n * - 'checkbox': a tri-state <select> — All / Checked / Unchecked.\n * - 'action': no filter control (not a data-bound column).\n *\n * Rows that don't match every active filter are hidden using the same\n * mechanism as `hiddenRows` — virtual scroll, keyboard navigation, and\n * `exportToExcel()` all skip them automatically, with no separate\n * filtering code path to keep in sync.\n *\n * Set `col.filterable: false` to omit the filter control for one column\n * (its filter cell renders empty). Set\n * `col.filterFn(cellValue, filterValue, row, rowIndex, column)` to\n * override the default match logic for that column entirely.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row />\n */\n filterRow: { type: Boolean, default: false },\n\n /**\n * Current filter values, keyed by column `key`. Optional — the filter\n * row works standalone via internal state (`internalFilters`) even\n * without `.sync`, mirroring the `size`/`theme` prop convention. Bind\n * `:filters.sync` only if the host needs to read or restore the current\n * filter state.\n * @type {Object}\n * @default {}\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" filter-row :filters.sync=\"myFilters\" />\n */\n filters: { type: Object, default: () => ({}) },\n\n /**\n * Shows a sort icon (kebab/\"more actions\" glyph) in every leaf column\n * header. Clicking it opens a popup menu — A → Z / Z → A / Clear sort —\n * matched to Excel's own column-header dropdown. Picking a different\n * column's option replaces the active sort — single-column sort only.\n *\n * Sorting **physically reorders** the `data` array (like a real\n * spreadsheet sort) and emits it through the normal v-model/`input`\n * event — it is recorded in the undo/redo history exactly like any\n * other data edit, so `undo()` restores the pre-sort row order.\n * \"Clear sort\" (or `clearSort()`) both clears the indicator **and**\n * restores the row order from immediately before the first sort of\n * this session — switching columns or direction in between doesn't\n * move that reference point. If rows were added/removed since sorting\n * (row count no longer matches), the restore is skipped and only the\n * indicator clears, to avoid silently dropping/duplicating rows.\n *\n * Comparison is matched to the column's `type`: plain/`'progress'`\n * columns compare the raw value (numeric-aware); `'date'` compares the\n * parsed date chronologically, not the formatted string; `'select'` /\n * `'treeSelect'` compare the displayed option label; `'checkbox'`\n * compares the boolean state. Empty/`null` values always sort last,\n * regardless of direction. `'action'` columns are never sortable.\n *\n * Set `col.sortable: false` to omit the icon for one column. Set\n * `col.sortFn(rowA, rowB, column) => number` to fully override the\n * comparison for that column.\n *\n * Blocked (no-op) while the grid is `locked`, or while any merged\n * cells or row outline groups exist — both are anchored to row\n * *position*, which a reorder would silently invalidate.\n *\n * @type {Boolean}\n * @default false\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable />\n */\n sortable: { type: Boolean, default: false },\n\n /**\n * Current sort state — `{ key, direction }` (`direction` is `'asc'` or\n * `'desc'`) or `null` when unsorted. Optional — sorting works standalone\n * via internal state (`internalSort`) even without `.sync`, mirroring\n * the `size`/`theme`/`filters` prop convention. Bind `:sort.sync` only\n * if the host needs to read or restore the current sort state.\n * @type {Object}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" sortable :sort.sync=\"mySort\" />\n */\n sort: { type: Object, default: null },\n\n /**\n * Unique key that opts the grid into local version history: when the page\n * is being left (tab hidden/closed, or this component unmounted — NOT on\n * every edit), the current `v-model` is content-hashed (via a Web Worker\n * when available, so the main thread stays responsive right as the page\n * may be unloading) and saved to the browser's IndexedDB as a **version**,\n * keyed by this string. If the hash matches an already-saved version for\n * this key, that version's timestamp is refreshed instead of creating a\n * duplicate — only genuinely different content produces a new version.\n * Also adds a toolbar button ('localSync' — see the `toolbar` prop's\n * `list`/`disabled`) that opens a modal listing saved versions, each with\n * a read-only preview and **Restore** / **Delete** actions, plus a\n * **Save** button for an explicit checkpoint.\n *\n * Two different grid instances must use different keys — the same key\n * means \"the same version history\" (concurrent tabs interleave). Omit\n * this prop entirely to opt out completely: no IndexedDB access happens\n * at all, and the toolbar button never renders.\n *\n * See also `localSyncMaxVersions`, the ref-callable methods\n * `listLocalVersions()`, `getLocalVersion(id)`, `restoreLocalVersion(id)`,\n * `deleteLocalVersion(id)`, `saveLocalVersionNow()`,\n * `openLocalSyncModal()`/`closeLocalSyncModal()`, and the\n * `local-sync-save`/`local-sync-restore`/`local-sync-clear`/`local-sync-error`/`local-sync-available`\n * events — [details](/local-sync).\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" toolbar />\n */\n localSyncKey: { type: String, default: null },\n\n /**\n * Optional tenant/organization identifier for multi-tenant hosts (SaaS\n * apps where the same browser/device is used across different\n * organizations). When set, it's combined with `localSyncKey`\n * internally (`${tenant}::${localSyncKey}`) so each tenant gets a fully\n * isolated IndexedDB version history — switching tenants on the same\n * device never shows another tenant's saved drafts. Omit it (default)\n * for single-tenant hosts; behavior is then identical to before this\n * prop existed.\n *\n * Has no effect without `localSyncKey` — it only changes how that key\n * is namespaced internally, it doesn't opt into the feature by itself.\n * Reactive: changing `tenant` (e.g. after an async \"current\n * organization\" load) reloads that tenant's own version list/buffer,\n * the same way a `localSyncKey` that starts empty and becomes truthy\n * later does. Avoid `::` inside `tenant`/`localSyncKey` values\n * themselves — it's the internal separator between the two.\n * @type {String}\n * @default null\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :tenant=\"currentOrgId\" toolbar />\n */\n tenant: { type: String, default: null },\n\n /**\n * Maximum number of local versions kept per `localSyncKey` (per\n * `tenant`, when set). Once a new version pushes the count past this,\n * the oldest version(s) (by `createdAt`) are deleted automatically\n * (FIFO). Has no effect without `localSyncKey`.\n * @type {Number}\n * @default 10\n * @example\n * <excel-grid :columns=\"columns\" v-model=\"data\" local-sync-key=\"invoice-42\" :local-sync-max-versions=\"20\" />\n */\n localSyncMaxVersions: { type: Number, default: 10 }\n },\n\n data() {\n return {\n // Selection\n activeCell: { row: 0, col: 0 },\n selectionRanges: [], // [{ startRow, startCol, endRow, endCol }]\n isMouseDown: false,\n mouseDownRange: null,\n isCtrlDrag: false,\n\n // Edit\n editingCell: null, // { row, col }\n editValue: '',\n editOriginalValue: '',\n\n // Scroll\n scrollTop: 0,\n scrollLeft: 0,\n headerViewportWidth: null,\n // The grid's DOM parent's width — the reference for relative (%, css)\n // column widths, in BOTH widthMode values (see _relativeWidthContainer).\n // In 'auto' mode the shell's width is DERIVED from column widths, so\n // resolving a relative column width against the shell's own measured\n // viewport (headerViewportWidth) is circular (each resolve nudges the\n // shell size, which nudges the next resolve...). 'full' mode isn't\n // circular that way, but headerViewportWidth (scrollBody.clientWidth)\n // turned out to have its OWN instability source: a vertical scrollbar\n // toggling on/off flips it by the scrollbar's width even though the\n // shell's own outer size never changes — confirmed via a real host\n // report (repeated brief re-render bursts hurting CLS) that 'auto'\n // mode didn't reproduce. The parent's width doesn't depend on the\n // grid's own rendered size OR its internal scrollbar state, so using\n // it everywhere sidesteps both instability sources uniformly.\n hostContainerWidth: null,\n // Real vertical-scrollbar width measured off the scroll body (0 for\n // macOS overlay scrollbars) — widthMode:'auto' adds it to the natural\n // width so the scrollbar never eats into the last column.\n vScrollbarW: 0,\n // .eg-shell's horizontal border total (border-box). Measured at mount —\n // themes can restyle it — so widthMode:'auto' accounts for it instead\n // of shaving those pixels off the last column.\n shellBorderW: 2,\n\n // Auto-height (height prop omitted) — 70vh cap, kept in sync on resize\n windowInnerH: typeof window !== 'undefined' ? window.innerHeight : 800,\n\n // Virtual scroll\n virtualStartIndex: 0,\n virtualEndIndex: 0,\n virtualOffsetTop: 0,\n totalVirtualHeight: 0,\n\n // Column widths (flat index → px)\n colWidths: {},\n\n // Flat index → raw CSS width spec, for columns whose width is a relative\n // unit ('20%', '10em', ...) and therefore needs re-resolving whenever the\n // grid's viewport width changes. Cleared for a column once it's manually\n // resized/auto-fit — see startColResize/autoFitColumn/_applyAutoWidth.\n relativeColSpecs: {},\n\n // col.key → true, for columns manually resized while fitColumns is on.\n // Such a column becomes a fixed px width (excluded from the proportional\n // fit); the rest keep scaling among themselves to fill what's left — see\n // _applyFitColumns(). Keyed by col.key (not flat index) so it survives\n // column reorders, same convention as relativeColSpecs preservation.\n fitColumnsOverrides: {},\n\n // Row heights (row index → px)\n rowHeights: {},\n\n // Import/export progress\n importState: { active: false, percent: 0, message: '' },\n exportState: { active: false },\n\n // localSyncKey feature — modal state: the version list (metadata only)\n // plus which one is selected/previewed. `selectedVersion` holds that\n // version's full { id, data, createdAt, updatedAt } for the nested-grid\n // preview (see EgLocalSyncModal.vue).\n localSyncModal: { visible: false, versions: [], selectedId: null, selectedVersion: null },\n // True when one or more versions already existed at mount and the user\n // hasn't opened the modal yet — drives the toolbar button's \"unseen\n // local versions\" dot. See _loadLocalVersionsOnMount() for the full lifecycle.\n localSyncAvailable: false,\n\n resizing: {\n active: false,\n type: null, // 'col' | 'row'\n index: -1,\n startX: 0,\n startY: 0,\n startSize: 0,\n currentPos: 0\n },\n\n // Fill handle\n fillDrag: {\n active: false,\n sourceRange: null,\n currentRow: -1,\n currentCol: -1\n },\n\n // Drag-move\n dragMove: {\n active: false,\n sourceRange: null,\n startX: 0,\n startY: 0,\n targetRow: -1,\n targetCol: -1,\n copyMode: false\n },\n\n // Clipboard (internal)\n clipboardMatrix: null,\n clipboardRange: null,\n clipboardMode: null, // 'copy' | 'cut'\n\n // Context menu\n contextMenu: { visible: false, x: 0, y: 0, row: -1, col: -1 },\n // false while the menu's real size hasn't been measured yet — kept hidden\n // (not painted) so the flip-to-fit repositioning in _positionContextMenu()\n // doesn't cause a visible jump.\n contextMenuPositioned: false,\n\n // Sort popup menu (header sort icon click) — { visible, x, y, key }\n sortMenu: { visible: false, x: 0, y: 0, key: null },\n sortMenuPositioned: false,\n\n // devMode — column/row config info modal ({ title, json } | null)\n infoModal: null,\n\n // Select dropdown\n selectDropdown: null, // { row, col, options, currentValue, rect }\n\n // Date picker overlay\n datePickerOverlay: null, // { row, col, format, value, rect }\n\n // Cell value validation (cellValidator)\n invalidCell: null, // { row, col, message, rect }\n\n // Merges (controlled via prop + update:merges)\n internalMerges: [],\n\n // Toolbar (controlled via prop + update:locked / update:size / update:theme)\n internalLocked: false,\n internalSize: 'middle',\n internalTheme: 'excel',\n // Pinned columns — merges col.pin (columns config default) with pinnedColumns prop.\n // context-menu pin/unpin updates this directly so .sync is not required.\n internalPinnedColumns: {},\n // Brief centered icon feedback whenever the lock state actually changes\n // (toolbar click, toggleLock(), or a host-driven :locked.sync change).\n lockFlashVisible: false,\n lockFlashLocked: false,\n isFullscreen: false,\n // Bumped whenever _history/_historyIndex change so canUndo/canRedo stay reactive.\n historyVersion: 0,\n\n // Groups\n internalGroups: [], // [{ type, startIndex, endIndex }]\n internalCollapsedGroups: [],\n\n // Header\n headerDepth: 1,\n headerRows: [],\n\n // Leaf columns cache\n leafCols: [],\n\n // Footer column totals (flat col index → number | null)\n columnTotals: {},\n\n // Filter row (controlled via prop + update:filters)\n internalFilters: {},\n // Row indices currently hidden by an active filter — merged into\n // hiddenRowSet, so virtual scroll/export/navigation need no separate path.\n filteredRowIndices: [],\n\n // Sort (controlled via prop + update:sort) — { key, direction } | null\n internalSort: null,\n\n // Column drag-and-drop state\n colDrag: {\n pending: false, // mousedown received, waiting for movement threshold\n active: false, // drag in progress\n sourceFirstLeaf: -1, // first leaf flat-index of the dragged node\n sourceLeafCount: 1, // number of leaves in the dragged node (1=leaf, N=group)\n dropIndex: -1, // insert-before flat index (0..N)\n startX: 0,\n startY: 0,\n ghostLabel: '',\n ghostWidth: 0, // ghost mirrors the grabbed header's real width (capped)\n grabOffsetX: 0 // cursor offset inside the grabbed header — keeps the grab point under the cursor\n },\n }\n },\n\n computed: {\n t() { return getMessages(this.locale) },\n\n // localSyncKey feature — the actual IndexedDB storage key. idbStore.js\n // itself knows nothing about \"tenants\"; this is the one place that\n // namespaces localSyncKey by tenant when one is given, so every\n // storage-layer call site below just uses this instead of\n // this.localSyncKey directly. null whenever localSyncKey itself is\n // unset — tenant alone doesn't opt into the feature.\n effectiveSyncKey() {\n if (!this.localSyncKey) return null\n return this.tenant ? `${this.tenant}::${this.localSyncKey}` : this.localSyncKey\n },\n\n pinnedLeftSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'left') s.add(col._flatIndex) })\n return s\n },\n\n pinnedRightSet() {\n const s = new Set()\n this.leafCols.forEach(col => { if (this.internalPinnedColumns[col.key] === 'right') s.add(col._flatIndex) })\n return s\n },\n\n pinnedLeftCols() { return this.leafCols.filter(col => this.pinnedLeftSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n pinnedRightCols() { return this.leafCols.filter(col => this.pinnedRightSet.has(col._flatIndex) && !this.isColHidden(col._flatIndex)) },\n\n hasPinnedLeft() { return this.pinnedLeftSet.size > 0 },\n hasPinnedRight() { return this.pinnedRightSet.size > 0 },\n\n hasFooter() { return this.leafCols.some(col => this.isColumnTotalVisible(col)) },\n\n pinnedLeftWidth() {\n let w = 0\n for (const col of this.pinnedLeftCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n pinnedRightWidth() {\n let w = 0\n for (const col of this.pinnedRightCols) {\n if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)\n }\n return w\n },\n\n totalRows() { return this.data.length },\n\n // See the `toolbar` prop's JSDoc for the full Boolean|Object contract.\n _toolbarVisible() {\n if (typeof this.toolbar === 'boolean') return this.toolbar\n return !!this.toolbar && this.toolbar.visible !== false\n },\n _toolbarList() {\n if (this.toolbar && Array.isArray(this.toolbar.list)) return this.toolbar.list\n return DEFAULT_TOOLBAR_LIST\n },\n // Same key set as _toolbarList, but for the disabled (grayed out, still visible) state\n // instead of visibility — see toolbar prop JSDoc.\n _toolbarDisabledList() {\n if (this.toolbar && Array.isArray(this.toolbar.disabled)) return this.toolbar.disabled\n return []\n },\n\n // Columns shown in the local-sync modal's preview table — action columns\n // are pure interactive UI (buttons), not printable/previewable data,\n // same exclusion printGrid()/exportToExcel() already apply.\n _localSyncPreviewColumns() {\n return this.leafCols.filter(col => col.type !== 'action')\n },\n\n // Passed to EgLocalSyncModal so it can render the snapshot preview as a\n // real, nested ExcelGrid instance — see the comment on ExcelGridComponent\n // above for why this is the module-level const and not `this.$options`.\n _selfComponentDef() {\n return ExcelGridComponent\n },\n\n _sizeConf() { return SIZE_CONFIG[this.internalSize] || SIZE_CONFIG.middle },\n\n sizeOptions() { return Object.keys(SIZE_CONFIG) },\n _rowH() { return this._sizeConf.row },\n _headerRowH() { return this._sizeConf.header },\n _footerRowH() { return this._sizeConf.footer },\n _filterRowH() { return this.filterRow ? this._sizeConf.filter : 0 },\n\n headerRowHeight() { return this._headerRowH },\n footerRowHeight() { return this._footerRowH },\n\n // Pre-built cumulative height array — O(n) once, then cached by Vue.\n // findVisibleRange() uses binary search against this cached array → O(log n) per scroll.\n // Invalidated only when row count, custom heights, or hidden rows change.\n cumulativeHeights() {\n return buildCumulativeHeights(this.totalRows, this._rowH, this.rowHeights, this.hiddenRowSet, ROW_LIST_EDGE_PAD, ROW_LIST_EDGE_PAD)\n },\n\n // height prop omitted → fit header + filter row + all (visible) rows + footer exactly.\n _autoContentHeight() {\n const headerH = this._headerRowH * this.headerDepth + this._filterRowH\n const bodyH = this.cumulativeHeights[this.totalRows] || 0\n const footerH = this.hasFooter ? this._footerRowH : 0\n return headerH + bodyH + footerH\n },\n\n // Resolved grid-area height: explicit `height` prop wins as-is (old\n // behavior); omitted → auto-fit content, capped at 70% of the viewport.\n effectiveHeight() {\n if (this.height != null) return this.height\n return Math.min(this._autoContentHeight, this.windowInnerH * 0.7)\n },\n\n hiddenColSet() { return new Set(this.hiddenColumns) },\n\n hiddenRowSet() {\n const s = new Set(this.hiddenRows)\n // Also add rows hidden by collapsed row groups\n const collapsed = getCollapsedRowIndices(this.internalGroups, this.internalCollapsedGroups)\n collapsed.forEach(i => s.add(i))\n // Also add rows hidden by the filter row (see filteredRowIndices/_recomputeRowFilters)\n this.filteredRowIndices.forEach(i => s.add(i))\n // Also add rows the host's rowVisible(row, rowIndex) rejects — a static/\n // dynamic rule (status, parity, ...) independent of the toggleable\n // hiddenRows list and the interactive filter row.\n if (typeof this.rowVisible === 'function') {\n const rows = this.data\n for (let i = 0; i < rows.length; i++) {\n if (this.rowVisible(rows[i], i) === false) s.add(i)\n }\n }\n return s\n },\n\n hiddenColFromGroupSet() {\n return getCollapsedColIndices(this.internalGroups, this.internalCollapsedGroups)\n },\n\n effectiveHiddenColSet() {\n const s = new Set(this.hiddenColSet)\n this.hiddenColFromGroupSet.forEach(i => s.add(i))\n // col.visible === false — a schema-level hide, independent of the\n // toggleable hiddenColumns list. \"Show All Columns\" never reveals it.\n // Boolean or Function(column, colIndex) => boolean — like rowVisible (see\n // hiddenRowSet above), the function form is called fresh on every\n // evaluation of this computed, so it stays reactive to whatever host\n // state its closure reads even though leafCols itself (built by\n // flattenColumns' `{...col}` shallow clone) is only rebuilt when the\n // columns prop reference changes. A function value survives that clone\n // unchanged (functions copy by reference), so the closure keeps running\n // fresh — no need to restructure columns into a computed just to react.\n this.leafCols.forEach(col => {\n const v = typeof col.visible === 'function' ? col.visible(col, col._flatIndex) : col.visible\n if (v === false) s.add(col._flatIndex)\n })\n return s\n },\n\n rowGroups() { return this.internalGroups.filter(g => g.type === 'row') },\n colGroups() { return this.internalGroups.filter(g => g.type === 'column') },\n\n visibleRowRange() {\n const rows = []\n for (let i = this.virtualStartIndex; i <= this.virtualEndIndex; i++) rows.push(i)\n return rows\n },\n\n primaryRange() {\n return getPrimaryRange(this.selectionRanges, this.activeCell)\n },\n\n fillHandleVisible() {\n return this.primaryRange && !this.editingCell\n },\n\n fillHandleStyle() {\n if (!this.primaryRange) return {}\n const r = normalizeRange(this.primaryRange)\n // O(1) lookup via cumulativeHeights instead of O(endRow) getRowTop loop\n const bottom = this.cumulativeHeights[r.endRow + 1] || 0\n const right = this.getColLeft(r.endCol + 1)\n return {\n position: 'absolute',\n width: '6px',\n height: '6px',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n border: '1px solid white',\n bottom: (this.totalVirtualHeight - bottom + 1) + 'px',\n right: (this.scrollContentWidth - right + 1) + 'px',\n cursor: 'crosshair',\n zIndex: 10\n }\n },\n\n\n invalidTooltipStyle() {\n if (!this.invalidCell || !this.invalidCell.rect) return {}\n const { rect } = this.invalidCell\n return {\n position: 'fixed',\n top: (rect.bottom + 4) + 'px',\n left: rect.left + 'px',\n zIndex: 9999\n }\n },\n\n dragGhostStyle() {\n if (!this.dragMove.active || this.dragMove.targetRow < 0) return {}\n const r = normalizeRange(this.dragMove.sourceRange)\n const rows = r.endRow - r.startRow\n const cols = r.endCol - r.startCol\n // O(1) lookup via cumulativeHeights instead of O(targetRow) getRowTop loop\n const top = this.cumulativeHeights[this.dragMove.targetRow] || 0\n const left = this.getColLeft(this.dragMove.targetCol)\n const width = this.getRangeWidth(this.dragMove.targetCol, this.dragMove.targetCol + cols)\n const height = this.getRangeHeight(this.dragMove.targetRow, this.dragMove.targetRow + rows)\n return {\n position: 'absolute',\n top: top + 'px',\n left: left + 'px',\n width: width + 'px',\n height: height + 'px',\n border: '2px dashed #1a73e8',\n pointerEvents: 'none',\n zIndex: 20\n }\n },\n\n // widthMode:'auto' — the grid's natural (max-content) width: row-number\n // gutter + outline gutter (when row groups exist) + every visible leaf\n // column's current width + the measured vertical scrollbar width (0 when\n // rows fit / overlay scrollbars). Fully reactive: column resize,\n // hide/show, col.visible, and group changes all re-derive it.\n naturalGridWidth() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return ROW_HEADER_WIDTH + outlineW + this.getColLeft(this.leafCols.length) + this.vScrollbarW + this.shellBorderW\n },\n\n // Reference width for resolving relative (%, css) column widths — always\n // hostContainerWidth (the grid's DOM parent), in both widthMode values.\n // See hostContainerWidth's declaration comment for why headerViewportWidth\n // (the grid's own scrollBody.clientWidth) isn't used for this in either\n // mode: circular in 'auto', and independently unstable in 'full' (a\n // vertical scrollbar toggling flips it without the shell's outer size\n // ever changing).\n _relativeWidthContainer() {\n return this.hostContainerWidth\n },\n\n // The CSS width both the shell and the wrapper use in non-fullscreen mode.\n _hostWidth() {\n if (this.widthMode === 'auto') return this.naturalGridWidth + 'px'\n return typeof this.width === 'number' ? this.width + 'px' : this.width\n },\n\n wrapperStyle() {\n if (this.isFullscreen) {\n return {\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n }\n const style = {\n width: this._hostWidth,\n height: this.effectiveHeight + 'px',\n position: 'relative',\n outline: 'none',\n userSelect: 'none'\n }\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n shellStyle() {\n if (this.isFullscreen) return {}\n const style = {\n width: this._hostWidth,\n display: 'flex',\n flexDirection: 'column'\n }\n // The cap that keeps a too-wide column set at the host's edge\n // (internal horizontal scroll takes over from there).\n if (this.widthMode === 'auto') style.maxWidth = '100%'\n return style\n },\n\n canUndo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex >= 0\n },\n\n canRedo() {\n // eslint-disable-next-line no-unused-expressions\n this.historyVersion\n return this._historyIndex < this._history.length - 1\n },\n\n headerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n position: 'sticky',\n top: 0,\n zIndex: 30,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n cornerStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: (this._headerRowH * this.headerDepth + this._filterRowH) + 'px',\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderBottom: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0\n }\n },\n\n rowOutlineGutterStyle() {\n return { width: OUTLINE_GUTTER + 'px', flexShrink: 0 }\n },\n\n frozenColHeaderStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n scrollColHeaderStyle() {\n // Once measured, lock the header to the body's exact clientWidth (flex: 0 0 <px>)\n // instead of growing independently via flex:1 — this guarantees identical\n // scrollWidth-clientWidth (max scrollLeft) on both, so syncing scrollLeft never clamps\n // one before the other. (padding-right can't do this: clientWidth always includes\n // padding, so padding never actually shrinks it.)\n //\n // The pinned zones must be subtracted from the measured clientWidth: in the\n // body they live INSIDE .eg-scroll-body as sticky overlays (so clientWidth\n // already spans them), but in the header/footer rows they are separate flex\n // siblings rendered BEFORE/AFTER this strip. Without the subtraction the\n // header/footer rows end up frozenWidth+pinnedRightWidth wider than the\n // container, giving .eg-container (overflow:auto) its own second horizontal\n // scrollbar. With it, this strip's viewport exactly matches the body's\n // scrollable window between the sticky zones, and — together with the strip\n // inner width being scrollableContentWidth — both max scrollLefts stay equal.\n // A falsy 0 (not just null/undefined) means \"not really measured yet\" — e.g. the grid\n // mounted while its container was display:none (a hidden v-show tab). Locking to '0 0 0px'\n // in that case would collapse the header/footer strip to invisible; fall back to the same\n // natural flex:1 used pre-measurement instead. The ResizeObserver in mounted() re-measures\n // once the container becomes visible, replacing this with a real px lock.\n if (this.headerViewportWidth) {\n const w = Math.max(\n 0,\n this.headerViewportWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n )\n return { flex: '0 0 ' + w + 'px', overflow: 'hidden' }\n }\n return { flex: 1, overflow: 'hidden' }\n },\n\n bodyAreaStyle() {\n // Fullscreen: let flexbox fill the viewport dynamically (mirrors\n // _containerH()'s fullscreen branch, which reads the resulting\n // clientHeight back for virtual-scroll math) — an explicit height\n // here would fight that.\n if (this.isFullscreen) {\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: 1,\n position: 'relative'\n }\n }\n // Fixed/auto height: an explicit px height (matching _containerH()'s\n // non-fullscreen formula) instead of flex:1 growth. flex:1 here only\n // ever has ONE sibling competing for space mathematically, so it\n // should be exact — but nested flex cross-axis stretch (this box's\n // height flows down to .eg-scroll-body/.eg-row-header-col via\n // align-items:stretch) is vulnerable to sub-pixel rounding between\n // browser-rendered header/footer row heights and the integer px\n // this component assumes, occasionally landing 1px short. That 1px\n // shortfall is enough to make .eg-scroll-body's overflow:auto show a\n // vertical scrollbar even when content should fit exactly (reported\n // with a single data row and no `height` prop — content height ==\n // container height, so any shortfall trips overflow instantly).\n // flex: '0 0 auto' overrides the .eg-body-area class rule's own\n // `flex: 1` (flex-basis: 0%) — with a non-auto flex-basis, flexbox\n // ignores this element's own `height` entirely for main-axis sizing,\n // so basis must explicitly go back to `auto` for `height` to apply.\n const h = this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - (this.hasFooter ? this._footerRowH : 0)\n return {\n display: 'flex',\n flexDirection: 'row',\n flex: '0 0 auto',\n height: Math.max(h, 0) + 'px',\n position: 'relative'\n }\n },\n\n rowHeaderColStyle() {\n return {\n width: ROW_HEADER_WIDTH + 'px',\n minWidth: ROW_HEADER_WIDTH + 'px',\n flexShrink: 0,\n display: 'flex',\n flexDirection: 'column',\n position: 'sticky',\n left: 0,\n zIndex: 20,\n background: 'var(--eg-color-header-bg, var(--eg-color-header-bg-default))'\n }\n },\n\n rowOutlineColStyle() {\n return {\n width: OUTLINE_GUTTER + 'px',\n minWidth: OUTLINE_GUTTER + 'px',\n flexShrink: 0,\n position: 'relative',\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))'\n }\n },\n\n scrollBodyStyle() {\n return {\n flex: 1,\n overflow: 'auto',\n position: 'relative',\n overflowAnchor: 'none' /* prevent Chrome scroll anchoring from fighting virtual scroll translateY */\n }\n },\n\n frozenColBodyStyle() {\n const w = this.getFrozenWidth()\n return {\n position: 'sticky',\n left: 0,\n width: w + 'px',\n flexShrink: 0,\n zIndex: 10,\n background: 'var(--eg-color-cell-bg, var(--eg-color-cell-bg-default))',\n overflow: 'hidden'\n }\n },\n\n mainCellsStyle() {\n const left = this.getFrozenWidth()\n return {\n position: 'absolute',\n left: left + 'px',\n top: 0,\n bottom: 0\n }\n },\n\n scrollContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && !this.pinnedRightSet.has(i)) w += this.getColW(i)\n }\n // Extra right padding so pinned-right zone doesn't cover the last scrollable column\n if (this.hasPinnedRight) w += this.pinnedRightWidth\n return w\n },\n\n scrollableContentWidth() {\n let w = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColHidden(i) && this.isColumnScrollable(i)) w += this.getColW(i)\n }\n return w\n },\n\n pinnedRightBodyStyle() {\n return {\n position: 'sticky',\n right: 0,\n // Natural position must be the inner's right edge for sticky right:0 to\n // engage (see the .eg-scroll-body-inner flex comment in the template).\n marginLeft: 'auto',\n width: this.pinnedRightWidth + 'px',\n zIndex: 10,\n background: 'white',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightHeaderStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n scrollRowHeadersStyle() {\n return { flex: 1, overflow: 'hidden', position: 'relative', overflowAnchor: 'none' }\n },\n\n footerAreaStyle() {\n return {\n display: 'flex',\n flexDirection: 'row',\n flexShrink: 0,\n position: 'sticky',\n bottom: 0,\n zIndex: 30,\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n // box-shadow (not a real border, like .eg-header-area's own\n // separator) — a real border would add an unaccounted extra 1px to\n // the footer's layout height (nothing in the height math knows\n // about it), throwing off effectiveHeight/_autoContentHeight and\n // tripping a phantom vertical scrollbar on .eg-scroll-body even\n // when content should fit exactly.\n boxShadow: '0 -2px 0 0 var(--eg-color-border-strong, var(--eg-color-border-strong-default))'\n }\n },\n\n footerLabelStyle() {\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n return {\n width: (ROW_HEADER_WIDTH + outlineW) + 'px',\n minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',\n height: this._footerRowH + 'px',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n background: 'var(--eg-color-footer-bg, var(--eg-color-footer-bg-default))',\n borderRight: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n borderTop: '1px solid var(--eg-color-border, var(--eg-color-border-default))',\n boxSizing: 'border-box',\n flexShrink: 0,\n fontSize: 'var(--eg-font-size-header, var(--eg-font-size-header-default))',\n fontWeight: 600\n }\n },\n\n frozenColFooterStyle() {\n const w = this.getFrozenWidth()\n return {\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0\n }\n },\n\n pinnedRightFooterStyle() {\n return {\n width: this.pinnedRightWidth + 'px',\n minWidth: this.pinnedRightWidth + 'px',\n height: this._footerRowH + 'px',\n overflow: 'hidden',\n flexShrink: 0,\n boxSizing: 'border-box',\n borderLeft: '1px solid var(--eg-color-border, var(--eg-color-border-default))'\n }\n },\n\n colOutlineRowStyle() {\n return {\n position: 'absolute',\n top: -OUTLINE_GUTTER + 'px',\n left: 0,\n right: 0,\n height: OUTLINE_GUTTER + 'px'\n }\n },\n\n resizeLineStyle() {\n if (!this.resizing.active) return {}\n if (this.resizing.type === 'col') {\n return {\n position: 'absolute',\n top: 0,\n left: (this.resizing.currentPos - (this.resizing.containerLeft || 0)) + 'px',\n width: '2px',\n height: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n } else {\n return {\n position: 'absolute',\n left: 0,\n top: (this.resizing.currentPos - (this.resizing.containerTop || 0)) + 'px',\n height: '2px',\n width: '100%',\n background: 'var(--eg-color-accent, var(--eg-color-accent-default))',\n pointerEvents: 'none',\n zIndex: 9999\n }\n }\n }\n },\n\n watch: {\n size: {\n immediate: true,\n handler(v) { this.internalSize = v || 'middle' }\n },\n\n theme: {\n immediate: true,\n handler(v) { this.internalTheme = v || 'excel' }\n },\n\n // Runs before the `columns`/`data` watchers below (both call\n // _recomputeRowFilters()) so internalFilters already reflects the prop\n // by the time either of them fires during the initial immediate pass.\n filters: {\n immediate: true,\n handler(v) { this.internalFilters = v ? { ...v } : {} }\n },\n\n internalFilters() { this._scheduleFilterRecompute() },\n\n filterRow() {\n this._recomputeRowFilters()\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n sort: {\n immediate: true,\n handler(v) { this.internalSort = v ? { key: v.key, direction: v.direction } : null }\n },\n\n internalSize() {\n this.rowHeights = {}\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n autoWidth(v) {\n if (v) this._applyAutoWidth()\n },\n\n fitColumns(v) {\n if (v) {\n this.fitColumnsOverrides = {}\n this._applyFitColumns()\n } else {\n // Back to normal per-column resolution (same as the columns\n // watcher's non-fit branch, without keyToWidth — fitColumns already\n // discarded any remembered manual-resize widths when it turned on).\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n this.fitColumnsOverrides = {}\n this._applyAutoWidth()\n }\n },\n\n columns: {\n immediate: true,\n handler(cols) {\n // Build key→width map from current state before rebuilding leafCols.\n // This preserves user-resized widths across column reorders: when columns\n // are moved, _flatIndex values shift but col.key stays the same.\n const keyToWidth = {}\n const keyToSpec = {}\n ;(this.leafCols || []).forEach(col => {\n if (this.colWidths[col._flatIndex] !== undefined) {\n keyToWidth[col.key] = this.colWidths[col._flatIndex]\n }\n if (this.relativeColSpecs[col._flatIndex] !== undefined) {\n keyToSpec[col.key] = this.relativeColSpecs[col._flatIndex]\n }\n })\n\n this.leafCols = flattenColumns(cols)\n this.headerDepth = columnTreeDepth(cols)\n this.headerRows = buildHeaderRows(cols, this.headerDepth)\n this._maskCache = {}\n\n if (this.fitColumns) {\n // fitColumns is the highest-priority width control — it overrides\n // even remembered manual-resize widths (keyToWidth above), so it\n // deliberately does NOT participate in that preservation logic.\n // fitColumnsOverrides (per-column fixed-width overrides set by\n // manually resizing while fitColumns is on) IS preserved, by key,\n // dropping any keys that no longer exist in the new column set.\n this.colWidths = {}\n this.relativeColSpecs = {}\n const validKeys = new Set(this.leafCols.map(c => c.key))\n const newOverrides = {}\n Object.keys(this.fitColumnsOverrides).forEach(k => {\n if (validKeys.has(k)) newOverrides[k] = true\n })\n this.fitColumnsOverrides = newOverrides\n this._applyFitColumns()\n } else {\n // Rebuild colWidths by new flat index, using col.key to look up saved widths\n const newColWidths = {}\n const newRelativeSpecs = {}\n this.leafCols.forEach(col => {\n if (keyToWidth[col.key] !== undefined) {\n newColWidths[col._flatIndex] = keyToWidth[col.key]\n if (keyToSpec[col.key] !== undefined) newRelativeSpecs[col._flatIndex] = keyToSpec[col.key]\n } else {\n newColWidths[col._flatIndex] = this.resolveColWidth(col.width, this._relativeWidthContainer)\n const parsed = parseColWidthSpec(col.width)\n if (parsed.type !== 'px') newRelativeSpecs[col._flatIndex] = col.width\n }\n })\n this.colWidths = newColWidths\n this.relativeColSpecs = newRelativeSpecs\n }\n\n this.computeColumnTotals()\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this._applyAutoWidth()\n\n // Merge col.pin entries for columns not yet tracked in internalPinnedColumns.\n // Existing entries (from prop or prior runtime pin/unpin) are not overwritten.\n const pinFromCols = this._colPinMap(this.leafCols)\n Object.keys(pinFromCols).forEach(key => {\n if (!(key in this.internalPinnedColumns)) this.$set(this.internalPinnedColumns, key, pinFromCols[key])\n })\n }\n },\n pinnedColumns: {\n immediate: true,\n handler(v) {\n // Rebuild: col.pin is the base default, prop value wins on conflict.\n this.internalPinnedColumns = { ...this._colPinMap(this.leafCols), ...(v || {}) }\n }\n },\n merges: {\n immediate: true,\n handler(v) { this.internalMerges = v ? [...v] : [] }\n },\n collapsedGroups: {\n immediate: true,\n handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }\n },\n // `immediate: true` guarantees this fires once, synchronously, right at\n // setup regardless of `v` — that's the one reliable signal that THIS\n // particular call is the initial sync, not a real host-driven change\n // (e.g. via `:locked.sync`). Every call after the first is a genuine\n // change, so only those flash. (Watching internalLocked itself for this\n // instead — the previous approach — can't tell \"initial value happened\n // to be true\" from \"the user's first-ever real toggle\": both look like\n // \"the first time internalLocked actually changed value\".)\n locked: {\n immediate: true,\n handler(v) {\n this.internalLocked = !!v\n if (this._lockPropSynced) this._flashLock(this.internalLocked)\n this._lockPropSynced = true\n }\n },\n data: {\n immediate: true,\n handler(newData) {\n // Keep _dataRaw in sync so template uses fresh data without triggering dependArray\n this._dataRaw = newData || []\n this._recomputeRowFilters()\n this.updateVirtualScroll()\n this.computeColumnTotals()\n // Force re-render so cell values refresh even when virtualScroll indices don't change\n // (e.g. editing a cell: same row count, same scroll position, only content changed)\n this.$forceUpdate()\n this._applyAutoWidth()\n }\n },\n totalRows() { this.updateVirtualScroll() },\n hiddenRowSet() { this.updateVirtualScroll() },\n totalVirtualHeight() { this.$nextTick(this.syncHeaderViewportWidth) },\n // widthMode:'auto' — the shell resizes with its columns (resize, hide/\n // show), so the header/body viewport lock must be re-measured after the\n // DOM picks up the new width. (The runaway-loop safety net lives in\n // _widthSyncChainOk(), gating the actual colWidths mutation inside\n // syncHeaderViewportWidth() — not here — since that single choke point\n // catches every trigger path, not just this one.)\n naturalGridWidth() {\n if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)\n },\n widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },\n\n // localSyncKey/tenant feature — mounted() only calls\n // _loadLocalVersionsOnMount() once, at mount time. Without these\n // watchers, a key/tenant that starts empty (a host conditionally\n // passing `:local-sync-key=\"ready ? id : null\"`, or a `tenant` only\n // known after an async \"current organization\" load) and later becomes\n // truthy would never load its version list, never promote a leftover\n // buffer, and never show the toolbar badge — the feature would look\n // \"broken\" for the rest of that page's lifetime even though everything\n // else works. Both share _reloadLocalSyncForKeyChange() since either one\n // changing means the effective storage key changed.\n localSyncKey(newKey, oldKey) {\n if (newKey === oldKey) return\n this._reloadLocalSyncForKeyChange()\n },\n tenant(newTenant, oldTenant) {\n if (newTenant === oldTenant) return\n this._reloadLocalSyncForKeyChange()\n }\n },\n\n created() {\n // Non-reactive row data reference — avoids Vue 2 dependArray(data) O(n) loop.\n // Vue 2 calls dependArray() for EVERY `data[ri]` access in the template,\n // iterating all 50,000 rows each time. With 1200 cells per render that's\n // 60 million iterations per render (~600ms). _dataRaw bypasses this.\n this._dataRaw = this.data || []\n this._hoveredRi = -1\n\n // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes\n this._cellStyleCache = Object.create(null)\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n // rAF handles for scroll/resize throttle\n this._scrollRAF = null\n this._resizeRAF = null\n\n // Undo/redo history — non-reactive to avoid Vue overhead.\n // Each entry: { type: 'data'|'merges', before: {...}, after: {...} }\n // 'data' stores only diff rows: { indices: number[], rows: object[] }\n this._history = []\n this._historyIndex = -1\n this._historySkip = false // true while undo/redo is in progress\n\n // Column drag preview offsets — non-reactive so direct DOM updates bypass Vue re-renders.\n // Keys are column keys, values are pixel offsets.\n this._dragOffsets = {}\n\n // Column drag internals (all non-reactive):\n // _dragBounds — snapshot of every visible leaf header's ORIGINAL viewport\n // rect taken once at drag start; drop-index math reads only this snapshot\n // (adjusted by scrollLeft delta), never live DOM rects — live rects carry\n // in-flight CSS transforms and give unstable intermediate positions.\n this._dragBounds = null\n this._dragStartScrollLeft = 0\n this._pendingDragHeaderRect = null\n this._lastDragClientX = 0\n this._dragScrollV = 0 // px/frame edge auto-scroll velocity (0 = idle)\n this._dragScrollRAF = null\n this._dragCleanupTimer = null\n\n // Fill-handle drag edge auto-scroll (see _updateFillDragAutoScroll) — same\n // idle-when-0 convention as _dragScrollV/_dragScrollRAF above, just for\n // both axes since a fill can extend down OR right.\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragScrollRAF = null\n this._fillDragLastEvent = null\n\n // localSyncKey — the last `data` reference a version was actually saved\n // for. Initialized to the current (mount-time) value so an untouched grid\n // that's merely tab-switched away from doesn't spam a \"nothing happened\"\n // version on every hide — only a REAL edit (which replaces this array\n // reference, see _emitDataChange) makes the next hide event flush.\n this._lastFlushedDataRef = this.data\n },\n\n mounted() {\n this.updateVirtualScroll()\n this.$nextTick(this.syncHeaderViewportWidth)\n document.addEventListener('mousemove', this.onGlobalMouseMove)\n document.addEventListener('mouseup', this.onGlobalMouseUp)\n document.addEventListener('keydown', this.onGlobalKeyDown)\n document.addEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.addEventListener('resize', this.syncHeaderViewportWidth)\n window.addEventListener('scroll', this._onWindowScrollForDropdown, true)\n // Self-heals the \"mounted while display:none\" case (e.g. a v-show tab that isn't\n // active yet) — scrollBody.clientWidth reads 0 at mount, and nothing but a real\n // window resize would otherwise ever re-measure it. ResizeObserver also fires when\n // the container regains real dimensions (tab becomes visible), so headers/footer\n // (scrollColHeaderStyle) get correctly re-locked with no host code changes needed.\n // rAF-batched like onBodyScroll's _scrollRAF, to avoid thrashing during a live drag-resize.\n if (typeof ResizeObserver !== 'undefined' && this.$refs.scrollBody) {\n this._scrollBodyResizeObserver = new ResizeObserver(() => {\n if (this._roRAF) return\n this._roRAF = requestAnimationFrame(() => {\n this._roRAF = null\n this.syncHeaderViewportWidth()\n })\n })\n this._scrollBodyResizeObserver.observe(this.$refs.scrollBody)\n // Also observe the grid's own DOM parent — needed in BOTH widthMode\n // values to keep hostContainerWidth (relative column widths' reference\n // in both modes) fresh when the host's layout changes.\n if (this.$el.parentElement) this._scrollBodyResizeObserver.observe(this.$el.parentElement)\n }\n this._loadLocalVersionsOnMount()\n // localSyncKey — save on page-hide, not per-edit (see _flushLocalVersionOnHide).\n // 'visibilitychange'->hidden is the primary trigger: it fires for tab\n // switches, minimizing, AND reliably before actual tab close/navigation on\n // both desktop and mobile, while the page is still very likely alive —\n // giving the async worker-hash + IndexedDB write a real chance to finish.\n // 'pagehide' is a backstop for the cases visibilitychange misses; it's\n // idempotent with visibilitychange (same content -> same hash -> no-op\n // beyond a timestamp touch) so firing both is harmless.\n document.addEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.addEventListener('pagehide', this._onPageHideForLocalSync)\n },\n\n updated() {\n // After Vue re-renders (e.g., virtual scroll adds new rows during drag),\n // apply missing transforms to newly rendered cells that don't have one yet.\n // The transform lands with transitions disabled (a fresh cell must appear\n // in place, not fly in from its untransformed position); the drag\n // transition is re-armed on the next frame.\n if (!this.colDrag.active || !this.$el) return\n const offsets = this._dragOffsets\n if (!offsets || !Object.keys(offsets).length) return\n const fresh = []\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n if (el.style.transform) return\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transition = 'none'\n el.style.transform = `translateX(${dx}px)`\n fresh.push(el)\n })\n if (fresh.length) {\n requestAnimationFrame(() => {\n if (!this.colDrag.active) return\n fresh.forEach(el => { el.style.transition = COL_DRAG_TRANSITION })\n })\n }\n },\n\n beforeDestroy() {\n clearTimeout(this._filterRecomputeTimer)\n clearTimeout(this._onInputTimer)\n clearTimeout(this._dragCleanupTimer)\n clearTimeout(this._widthSyncChainResetTimer)\n clearTimeout(this._bufferWriteTimer)\n if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)\n if (this._fillDragScrollRAF) cancelAnimationFrame(this._fillDragScrollRAF)\n document.removeEventListener('mousemove', this.onGlobalMouseMove)\n document.removeEventListener('mouseup', this.onGlobalMouseUp)\n document.removeEventListener('keydown', this.onGlobalKeyDown)\n document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)\n window.removeEventListener('resize', this.syncHeaderViewportWidth)\n window.removeEventListener('scroll', this._onWindowScrollForDropdown, true)\n document.removeEventListener('visibilitychange', this._onVisibilityChangeForLocalSync)\n window.removeEventListener('pagehide', this._onPageHideForLocalSync)\n // Best-effort — the component may be torn down (SPA route change) without\n // the page itself unloading, which visibilitychange/pagehide won't catch.\n // sync: true — see _flushLocalVersionOnHide()'s comment; a component\n // being destroyed is exactly the same \"might not survive an async hop\"\n // situation as the other two triggers.\n this._flushLocalVersionOnHide(false, true)\n if (this.isFullscreen) {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (this._scrollBodyResizeObserver) { this._scrollBodyResizeObserver.disconnect(); this._scrollBodyResizeObserver = null }\n if (this._roRAF) { cancelAnimationFrame(this._roRAF); this._roRAF = null }\n },\n\n methods: {\n // =========================================================\n // VIRTUAL SCROLL\n // =========================================================\n // In fullscreen, .eg-scroll-body's real height comes from flexbox filling\n // the viewport (which can be far taller than the `height` prop the host\n // originally passed for the fixed-size layout) — measure it directly\n // instead of trusting `height`, otherwise virtual scroll only ever\n // renders enough rows for the old fixed size, leaving the rest of the\n // (visually stretched) container blank.\n _containerH() {\n if (this.isFullscreen && this.$refs.scrollBody) return this.$refs.scrollBody.clientHeight\n const footerH = this.hasFooter ? this._footerRowH : 0\n return this.effectiveHeight - (this._headerRowH * this.headerDepth) - this._filterRowH - footerH\n },\n\n // Same fullscreen-aware measurement as _containerH(), but mirrors PageUp/\n // PageDown's pre-existing (footer-agnostic) row-span formula exactly for\n // the non-fullscreen case, so normal-mode behavior is unchanged.\n _pageRowSpan() {\n const h = this.isFullscreen && this.$refs.scrollBody\n ? this.$refs.scrollBody.clientHeight\n : (this.effectiveHeight - this._headerRowH * this.headerDepth - this._filterRowH)\n return Math.floor(h / this._rowH)\n },\n\n updateVirtualScroll() {\n // Use binary search against the Vue-cached cumulativeHeights → O(log n) per scroll.\n // overscan=8 (≈224px at default row height) gives enough buffer for fast scroll\n // without blank-band artifacts between rAF updates.\n const result = findVisibleRange(\n this.scrollTop, Math.max(this._containerH(), 100), this.totalRows, this.cumulativeHeights, 8\n )\n this.virtualStartIndex = result.startIndex\n this.virtualEndIndex = result.endIndex\n this.virtualOffsetTop = result.offsetTop\n this.totalVirtualHeight = result.totalHeight\n },\n\n onBodyScroll(e) {\n const target = e.target\n // Sync sibling scroll containers immediately (visual alignment, no layout trigger)\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = target.scrollLeft\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = target.scrollLeft\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = target.scrollTop\n\n // Close dropdown on scroll — fixed-position dropdown drifts away from the moving cell\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n\n // Throttle reactive state updates + virtual scroll recalc to one rAF per frame.\n // Read scrollTop from the DOM ref at rAF time (not from the stale event closure)\n // so that multiple scroll events arriving between frames still resolve to the\n // final position — eliminates the flickering caused by render/scroll mismatch.\n if (!this._scrollRAF) {\n this._scrollRAF = requestAnimationFrame(() => {\n this._scrollRAF = null\n const sb = this.$refs.scrollBody\n if (!sb) return\n this.scrollTop = sb.scrollTop\n this.scrollLeft = sb.scrollLeft\n this.updateVirtualScroll()\n })\n }\n },\n\n onScroll(e) {},\n\n // .eg-scroll-col-header (overflow:hidden, synced via JS) has no native scrollbar, but\n // .eg-scroll-body (overflow:auto) does — its real vertical scrollbar eats into its\n // clientWidth. If both containers just grow via flex:1, the header ends up wider than\n // the body, so its max scrollLeft is smaller and syncing clamps it before the body\n // reaches its rightmost scroll position — column borders desync right at the scroll\n // end. Locking the header to the body's exact measured clientWidth (scrollColHeaderStyle)\n // makes both max scrollLeft values identical.\n syncHeaderViewportWidth() {\n // Auto-height's 70vh cap tracks the real viewport — keep it current on resize.\n if (typeof window !== 'undefined' && this.windowInnerH !== window.innerHeight) {\n this.windowInnerH = window.innerHeight\n }\n const el = this.$refs.scrollBody\n if (!el) return\n // headerViewportWidth is still tracked in both modes (scrollColHeaderStyle/\n // virtual scroll need the grid's OWN viewport width) — but no longer\n // feeds relative column-width resolution at all (see\n // _relativeWidthContainer/hostContainerWidth's comments for why: it's\n // circular in 'auto' and independently scrollbar-toggle-unstable in\n // 'full'). _widthSyncChainOk() still guards the assignment itself,\n // since it's reactive (scrollColHeaderStyle reads it) — at fractional\n // page-zoom levels el.clientWidth can genuinely oscillate by ±1px\n // indefinitely (a vertical scrollbar toggling right at that subpixel\n // rounding boundary), which would otherwise re-render on every flip\n // forever even with nothing left downstream to actually resolve.\n if (this.headerViewportWidth !== el.clientWidth && this._widthSyncChainOk()) {\n this.headerViewportWidth = el.clientWidth\n }\n // Reference for relative (%, css) column widths, in BOTH widthMode\n // values — see hostContainerWidth's declaration comment. Measured from\n // the grid's DOM PARENT (not the grid itself, and not its scrollBody),\n // which is stable regardless of the grid's own rendered size or\n // internal scrollbar state — UNLESS the host's own layout also\n // auto-sizes around the grid (e.g. the parent shrink-wraps to its\n // content), which recreates a similar cycle outside our control.\n // _widthSyncChainOk() guards the assignment itself against that\n // residual case — the single choke point both a resized parent (this\n // block) and a resized shell (the naturalGridWidth watcher, via a\n // fresh call to this method, 'auto' mode only) funnel through.\n if (this.$el && this.$el.parentElement) {\n const pw = this.$el.parentElement.clientWidth\n if (pw !== this.hostContainerWidth && this._widthSyncChainOk()) {\n this.hostContainerWidth = pw\n if (this.fitColumns) this._applyFitColumns()\n else this._resolveRelativeColWidths()\n }\n }\n const sbw = el.offsetWidth - el.clientWidth\n if (sbw >= 0 && sbw !== this.vScrollbarW) this.vScrollbarW = sbw\n // Shell border total (see naturalGridWidth) — offsetWidth-clientWidth\n // is exactly the horizontal borders here (the shell itself never scrolls).\n const shell = this.$el\n if (shell && shell.offsetWidth) {\n const bw = shell.offsetWidth - shell.clientWidth\n if (bw >= 0 && bw !== this.shellBorderW) this.shellBorderW = bw\n }\n // Window resize while fullscreen changes the real available row-viewport\n // height too (see _containerH()) — recompute which rows are rendered.\n // Same for auto-height (height prop omitted): the 70vh cap just moved.\n if (this.isFullscreen || this.height == null) this.updateVirtualScroll()\n },\n\n // Runaway width-measurement guard — used in BOTH widthMode values.\n // In 'auto' mode, hostContainerWidth re-resolving colWidths is DESIGNED\n // to settle in 1-2 calls, but a host layout that also auto-sizes AROUND\n // the grid can recreate a circular dependency outside our control. In\n // 'full' mode there's no such circularity by construction, but\n // el.clientWidth is still a real browser measurement, and at fractional\n // page-zoom levels it can genuinely oscillate by ±1px indefinitely\n // (scrollbar toggling right at the rounding boundary, or ResizeObserver's\n // own well-known \"callback reads a size that changes again\" footgun).\n // Either way the symptom is the same: syncHeaderViewportWidth keeps\n // getting re-triggered forever. Caps consecutive calls instead of\n // chasing forever; 400ms of quiet resets the count, so a legitimately\n // slow (but finite) settle is never penalized — only a genuinely\n // runaway chain trips it.\n _widthSyncChainOk() {\n this._widthSyncChainCount = (this._widthSyncChainCount || 0) + 1\n clearTimeout(this._widthSyncChainResetTimer)\n this._widthSyncChainResetTimer = setTimeout(() => { this._widthSyncChainCount = 0 }, 400)\n if (this._widthSyncChainCount > 30) {\n if (!this._widthSyncChainWarned) {\n this._widthSyncChainWarned = true\n // eslint-disable-next-line no-console\n console.warn('[vue2-excel-grid] widthMode=\"' + this.widthMode + '\": column width measurement did not stabilize after 30 consecutive re-measures (possibly fractional browser zoom, or a host layout that also auto-sizes around the grid) — freezing at the last measured size instead of re-rendering forever.')\n }\n return false // stop chasing; keep the last measured/resolved widths instead of looping forever\n }\n return true\n },\n\n // =========================================================\n // HELPERS\n // =========================================================\n isRowHidden(ri) { return this.hiddenRowSet.has(ri) },\n isColHidden(ci) { return this.effectiveHiddenColSet.has(ci) },\n\n getColW(ci) {\n return this.colWidths[ci] !== undefined ? this.colWidths[ci] : (this.leafCols[ci] ? this.resolveColWidth(this.leafCols[ci].width, this._relativeWidthContainer) : DEFAULT_COL_WIDTH)\n },\n\n // Turns a col.width spec (number, 'Npx', 'N%', or any other CSS length like\n // 'Nem'/'calc(...)') into a concrete px number. Percent is resolved against\n // containerWidth (see _relativeWidthContainer — always hostContainerWidth,\n // in both widthMode values); other CSS units are resolved via a hidden\n // DOM probe (_measureCssWidth).\n resolveColWidth(spec, containerWidth) {\n const parsed = parseColWidthSpec(spec)\n if (parsed.type === 'px') return Math.max(20, parsed.value)\n if (parsed.type === 'percent') {\n if (!containerWidth) return DEFAULT_COL_WIDTH\n return Math.max(20, (parsed.value / 100) * containerWidth)\n }\n const measured = this._measureCssWidth(parsed.value)\n return Math.max(20, measured || DEFAULT_COL_WIDTH)\n },\n\n // Measures an arbitrary CSS width string (em/rem/vw/calc/...) by applying it\n // to a hidden probe element, so it inherits ambient font-size for em/rem.\n // Parented in the grid's DOM PARENT (not the grid root or its scrollBody)\n // in both widthMode values, matching _relativeWidthContainer — a\n // %/calc(%...) value would otherwise resolve against the grid's own\n // rendered size, which is either circular ('auto') or independently\n // scrollbar-toggle-unstable ('full'). Falls back to the grid root if\n // there's no parent (e.g. a detached/test mount). Returns null before\n // mount (no DOM yet).\n _measureCssWidth(cssValue) {\n if (typeof document === 'undefined' || !this.$el || !this.$el.appendChild) return null\n const parent = this.$el.parentElement || this.$el\n if (!this._widthProbeEl || this._widthProbeEl.parentNode !== parent) {\n if (this._widthProbeEl && this._widthProbeEl.parentNode) {\n this._widthProbeEl.parentNode.removeChild(this._widthProbeEl)\n }\n const el = document.createElement('div')\n el.style.position = 'absolute'\n el.style.visibility = 'hidden'\n el.style.height = '0'\n el.style.overflow = 'hidden'\n el.style.pointerEvents = 'none'\n parent.appendChild(el)\n this._widthProbeEl = el\n }\n this._widthProbeEl.style.width = cssValue\n return this._widthProbeEl.offsetWidth\n },\n\n // Re-resolves every column whose width spec is relative (%, em, vw, ...)\n // against the current _relativeWidthContainer — called whenever that\n // reference width changes (initial mount + window resize), so\n // percent/relative columns stay responsive like real CSS, matching a\n // plain numeric/px width's stability.\n _resolveRelativeColWidths() {\n const specs = this.relativeColSpecs\n const keys = Object.keys(specs)\n if (!keys.length) return\n keys.forEach(ci => {\n this.$set(this.colWidths, ci, this.resolveColWidth(specs[ci], this._relativeWidthContainer))\n })\n this._cellStyleCache = Object.create(null)\n },\n\n getRowH(ri) {\n if (this.isRowHidden(ri)) return 0\n return this.rowHeights[ri] || this._rowH\n },\n\n getColLeft(ci) {\n let left = 0\n for (let i = 0; i < ci && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) left += this.getColW(i)\n }\n return left\n },\n\n getFrozenWidth() {\n return this.hasPinnedLeft ? this.pinnedLeftWidth : 0\n },\n\n // Scroll-position-aware pinned-column edge shadow (mirrors Ant Design/AG Grid's own\n // convention): the shadow only appears while there's actually scrolled-away content\n // hiding under the pinned zone on that side, not as a permanent decoration — a static\n // shadow reads as background noise and the user stops noticing it exactly when it\n // matters (mid-scroll). Read scrollLeft (already tracked reactively, updated every\n // rAF tick in onBodyScroll) so template :class bindings re-evaluate on every scroll frame.\n hasHiddenScrollLeft() {\n return this.getFrozenWidth() > 0 && this.scrollLeft > 0\n },\n hasHiddenScrollRight() {\n if (!this.hasPinnedRight) return false\n const sb = this.$refs.scrollBody\n if (!sb) return false\n return this.scrollLeft < sb.scrollWidth - sb.clientWidth - 1\n },\n\n isColumnPinnedLeft(ci) {\n return this.pinnedLeftSet.has(ci)\n },\n\n isColumnPinnedRight(ci) {\n return this.pinnedRightSet.has(ci)\n },\n\n isColumnScrollable(ci) {\n return !this.isColumnPinnedLeft(ci) && !this.isColumnPinnedRight(ci)\n },\n\n getZoneColLeft(ci, zone) {\n let left = 0\n if (zone === 'left') {\n for (const col of this.pinnedLeftCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else if (zone === 'right') {\n for (const col of this.pinnedRightCols) {\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)\n }\n } else {\n for (let i = 0; i < this.leafCols.length; i++) {\n const col = this.leafCols[i]\n if (col._flatIndex === ci) break\n if (!this.isColHidden(col._flatIndex) && this.isColumnScrollable(col._flatIndex)) {\n left += this.getColW(col._flatIndex)\n }\n }\n }\n return left\n },\n\n isCellInZone(cell, rowIdx, zone) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n const z = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n return z === zone\n },\n\n getHeaderCellStyleForZone(cell, rowIndex, zone) {\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z === zone) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')\n if (z !== zone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getRangeWidth(startCol, endCol) {\n let w = 0\n for (let i = startCol; i <= endCol && i < this.leafCols.length; i++) {\n if (!this.isColHidden(i)) w += this.getColW(i)\n }\n return w\n },\n\n getRangeHeight(startRow, endRow) {\n let h = 0\n for (let i = startRow; i <= endRow && i < this.totalRows; i++) {\n if (!this.isRowHidden(i)) h += this.getRowH(i)\n }\n return h\n },\n\n getCellValue(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return ''\n const row = this._dataRaw[ri]\n if (!row) return ''\n const v = row[col.key]\n return v !== undefined ? v : ''\n },\n\n isEditing(ri, ci) {\n return this.editingCell && this.editingCell.row === ri && this.editingCell.col === ci\n },\n\n isMergedAway(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return false\n const nm = normalizeRange(merge)\n return !(nm.startRow === ri && nm.startCol === ci)\n },\n\n getMergeSpan(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n if (!merge) return { rowspan: 1, colspan: 1 }\n const nm = normalizeRange(merge)\n return {\n rowspan: nm.endRow - nm.startRow + 1,\n colspan: nm.endCol - nm.startCol + 1\n }\n },\n\n isSelected(ri, ci) {\n return isCellInAnyRange(ri, ci, this.selectionRanges)\n },\n\n isActiveCell(ri, ci) {\n return this.activeCell.row === ri && this.activeCell.col === ci\n },\n\n isCopyHighlighted(ri, ci) {\n if (!this.clipboardRange || this.clipboardMode !== 'copy') return false\n return isCellInRange(ri, ci, this.clipboardRange)\n },\n\n // =========================================================\n // STYLES\n // =========================================================\n getRowStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowStyleCache[key]) return this._rowStyleCache[key]\n const style = { height: h + 'px', minHeight: h + 'px' }\n this._rowStyleCache[key] = style\n return style\n },\n\n getRowHeaderStyle(ri) {\n const h = this.getRowH(ri)\n const key = h\n if (this._rowHeaderStyleCache[key]) return this._rowHeaderStyleCache[key]\n const style = { height: h + 'px' }\n this._rowHeaderStyleCache[key] = style\n return style\n },\n\n getCellStyle(ri, ci) {\n const merge = findMergeAtCell(ri, ci, this.internalMerges)\n const nm = merge ? normalizeRange(merge) : null\n let w = this.getColW(ci)\n let h = this.getRowH(ri)\n\n if (nm && nm.startRow === ri && nm.startCol === ci) {\n w = this.getRangeWidth(nm.startCol, nm.endCol)\n h = this.getRangeHeight(nm.startRow, nm.endRow)\n }\n\n // Position cells with the same absolute-left arithmetic the header uses\n // (getZoneColLeft), instead of relying on flex layout to accumulate widths —\n // flex and a JS integer sum can drift apart by a few px over many columns,\n // desyncing the body's column borders from the header's.\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n\n // Cache by dimensions+position — same (w,h,left) → same object ref → Vue 2 skips DOM style writes\n const key = w + '_' + h + '_' + left\n if (this._cellStyleCache[key]) return this._cellStyleCache[key]\n const style = { position: 'absolute', left: left + 'px', top: '0px', width: w + 'px', minWidth: w + 'px', height: h + 'px' }\n this._cellStyleCache[key] = style\n return style\n },\n\n // rowStyle prop — deliberately NOT merged into getCellStyle()'s return value:\n // that object is cached/shared by (w,h,left) across unrelated rows/cells (see\n // comment above), so mutating it here would leak one row's style onto every\n // other cell that happens to share the same cache key. Kept as a second,\n // uncached :style array entry instead (see template) — cheap, and correctly\n // reactive to the row's actual data instead of just its geometry.\n getRowCustomStyle(ri) {\n if (!this.rowStyle) return null\n return this.rowStyle(this._dataRaw[ri], ri) || null\n },\n\n getCellClasses(ri, ci) {\n const col = this.leafCols[ci]\n const active = this.isActiveCell(ri, ci)\n // Non-active selected cell: border only on edges NOT shared with another\n // selected neighbor, so a multi-cell range gets one continuous outline\n // around its outer perimeter (Excel-style) instead of each cell only\n // getting a flat background tint. Works for disjoint multi-range\n // selections too (e.g. ctrl+click rows) since it's a pure neighbor check.\n const rangeSelected = this.isSelected(ri, ci) && !active\n return {\n 'eg-cell-active': active,\n 'eg-cell-selected': rangeSelected,\n 'eg-cell-range-top': rangeSelected && !this.isSelected(ri - 1, ci),\n 'eg-cell-range-bottom': rangeSelected && !this.isSelected(ri + 1, ci),\n 'eg-cell-range-left': rangeSelected && !this.isSelected(ri, ci - 1),\n 'eg-cell-range-right': rangeSelected && !this.isSelected(ri, ci + 1),\n 'eg-cell-fill-preview': this.isInFillPreview(ri, ci),\n 'eg-cell-copy': this.isCopyHighlighted(ri, ci),\n 'eg-cell-merged-hidden': this.isMergedAway(ri, ci),\n 'eg-cell-select': !!(col && this.isSelectLikeCol(col)),\n 'eg-cell-date': !!(col && col.type === 'date'),\n 'eg-cell-checkbox': !!(col && col.type === 'checkbox'),\n 'eg-cell-action': !!(col && col.type === 'action'),\n 'eg-cell-readonly': !this.isCellEditable(ri, ci),\n 'eg-cell-invalid': !!(this.invalidCell && this.invalidCell.row === ri && this.invalidCell.col === ci),\n 'eg-cell--has-suffix': !!(col && this.hasSuffixSlot(col)),\n 'eg-cell--drag-source': this.colDrag.active && ci >= this.colDrag.sourceFirstLeaf && ci < this.colDrag.sourceFirstLeaf + this.colDrag.sourceLeafCount,\n 'eg-cell-align-right': !!(col && col.align === 'right'),\n 'eg-cell-align-center': !!(col && col.align === 'center')\n }\n },\n\n isInFillPreview(ri, ci) {\n if (!this.fillDrag.active || !this.fillDrag.sourceRange) return false\n const src = this.fillDrag.sourceRange\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n if (targetRow > src.endRow) {\n return ri > src.endRow && ri <= targetRow && ci >= src.startCol && ci <= src.endCol\n }\n if (targetCol > src.endCol) {\n return ci > src.endCol && ci <= targetCol && ri >= src.startRow && ri <= src.endRow\n }\n return false\n },\n\n getHeaderCellStyle(cell, rowIndex, isFrozen) {\n const zone = isFrozen ? 'left' : 'scroll'\n let leftLeafIdx = cell._firstLeafFlatIndex\n if (cell.isGroup) {\n const end = leftLeafIdx + cell.colspan - 1\n for (let i = leftLeafIdx; i <= end; i++) {\n if (this.isColHidden(i)) continue\n if (isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)) { leftLeafIdx = i; break }\n }\n }\n const left = this.getZoneColLeft(leftLeafIdx, zone)\n\n let width = 0\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (!inZone) continue\n width += this.getColW(i)\n }\n } else {\n width = this.getColW(cell.column._flatIndex)\n }\n\n return {\n top: (this._headerRowH * rowIndex) + 'px',\n left: left + 'px',\n width: width + 'px',\n height: (this._headerRowH * cell.rowspan) + 'px'\n }\n },\n\n getHeaderDragTransform(cell) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking, no re-render on offset change\n let key\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n } else {\n key = cell.column.key\n }\n if (!key) return null\n const dx = this._dragOffsets[key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n getCellDragTransform(ci) {\n if (!this.colDrag.active) return null\n // Read from non-reactive _dragOffsets — no Vue dependency tracking\n const col = this.leafCols[ci]\n if (!col) return null\n const dx = this._dragOffsets[col.key]\n if (!dx) return null\n return { transform: `translateX(${dx}px)` }\n },\n\n // =========================================================\n // FOOTER (column totals)\n // =========================================================\n\n // col.totalConfig.visible (boolean) takes priority over col.showTotal when present.\n isColumnTotalVisible(col) {\n if (col.totalConfig && typeof col.totalConfig.visible === 'boolean') return col.totalConfig.visible\n return !!col.showTotal\n },\n\n // Columns with a visible total are implicitly numeric — computeColumnTotals()\n // only sums typeof === 'number' cells, so a value written as a string (every\n // edit path hands over a string: the built-in text <input>, pasted TSV text,\n // fill-handle/drag-move source values) would silently drop out of the sum\n // with no error. Coerce user-entered values that fully parse as a number into\n // an actual Number for these columns; anything else (blank, non-numeric text)\n // passes through unchanged so it still fails validation/renders as typed.\n _coerceCellValue(ci, value) {\n if (typeof value !== 'string') return value\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return value\n const trimmed = value.trim()\n if (trimmed === '' || isNaN(Number(trimmed))) return value\n return Number(trimmed)\n },\n\n // Default value for a column on a row auto-created by pasteGrowsRows.\n _blankValueForCol(col) {\n return col.type === 'checkbox' ? false : ''\n },\n\n // One `Mask` instance per masked flat column index, reused across calls\n // (maska memoizes internally too, but this avoids re-parsing col.mask on\n // every keystroke/paste/fill). Invalidated whenever columns changes (see\n // the `columns` watcher, which resets `_maskCache = {}`).\n _getColMask(ci) {\n if (!this._maskCache) this._maskCache = {}\n if (this._maskCache[ci] !== undefined) return this._maskCache[ci]\n const col = this.leafCols[ci]\n const spec = col && col.mask\n let mask = null\n if (spec) {\n const options = (typeof spec === 'string' || Array.isArray(spec)) ? { mask: spec } : spec\n mask = new Mask(options)\n }\n this._maskCache[ci] = mask\n return mask\n },\n\n // Reformats a value through col.mask, if the column has one. Used at every\n // path that writes a value without going through the live MaskInput-driven\n // editor input (commit of a custom edit slot, paste, fill handle/double-fill)\n // — the live-typing case is handled by MaskInput itself (see startEditing).\n // Idempotent: masking an already-masked value returns it unchanged.\n _applyColMask(ci, value) {\n const mask = this._getColMask(ci)\n if (!mask || value === '' || value === null || value === undefined) return value\n if (typeof value !== 'string' && typeof value !== 'number') return value\n return mask.masked(value)\n },\n\n computeColumnTotals() {\n const totals = {}\n const rows = this._dataRaw || []\n this.leafCols.forEach(col => {\n if (!this.isColumnTotalVisible(col)) return\n let sum = 0\n let hasNumber = false\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n const v = row ? row[col.key] : undefined\n if (typeof v === 'number' && isFinite(v)) {\n sum += v\n hasNumber = true\n }\n }\n totals[col._flatIndex] = hasNumber ? Math.round(sum * 1e6) / 1e6 : null\n })\n this.columnTotals = totals\n },\n\n getColumnTotalDisplay(ci) {\n const col = this.leafCols[ci]\n if (!col || !this.isColumnTotalVisible(col)) return ''\n const raw = this.columnTotals[ci]\n const cfg = col.totalConfig\n if (cfg && typeof cfg.formatter === 'function') {\n return cfg.formatter(raw, this._dataRaw, col, ci)\n }\n return raw === null || raw === undefined ? '-' : raw\n },\n\n getFooterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n const style = {\n position: 'absolute',\n left: left + 'px',\n top: '0px',\n width: w + 'px',\n minWidth: w + 'px',\n height: this._footerRowH + 'px'\n }\n const col = this.leafCols[ci]\n const extra = col && col.totalConfig && col.totalConfig.style\n return extra ? { ...style, ...extra } : style\n },\n\n getFooterCellClass(ci) {\n const col = this.leafCols[ci]\n if (!col) return null\n const classList = col.totalConfig && col.totalConfig.classList\n const base = this.isColumnTotalVisible(col) ? 'eg-footer-cell-total' : null\n if (!base) return classList || null\n return classList ? [base, classList] : base\n },\n\n isCellVisible(cell, rowIndex, isFrozen) {\n if (cell.isGroup) {\n const start = cell._firstLeafFlatIndex\n const end = start + cell.colspan - 1\n for (let i = start; i <= end; i++) {\n if (this.isColHidden(i)) continue\n const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)\n if (inZone) return true\n }\n return false\n }\n const ci = cell.column._flatIndex\n if (this.isColHidden(ci)) return false\n return isFrozen ? this.isColumnPinnedLeft(ci) : this.isColumnScrollable(ci)\n },\n\n getRowGroupBtnStyle(group) {\n const top = getRowTop(group.startIndex, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD) - this.scrollTop\n return {\n position: 'absolute',\n top: top + 'px',\n left: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px',\n zIndex: 5\n }\n },\n\n getColGroupBtnStyle(group) {\n const left = this.getColLeft(group.startIndex) - this.scrollLeft\n return {\n position: 'absolute',\n left: left + 'px',\n top: '2px',\n width: '16px',\n height: '16px',\n lineHeight: '16px',\n textAlign: 'center',\n fontSize: '10px',\n cursor: 'pointer',\n background: 'var(--eg-color-accent-soft, var(--eg-color-accent-soft-default))',\n border: '1px solid var(--eg-color-group-border, var(--eg-color-group-border-default))',\n borderRadius: '2px'\n }\n },\n\n // =========================================================\n // CELL SELECTION (3.1)\n // =========================================================\n selectCell(row, col, addToSelection = false) {\n if (row < 0 || row >= this.totalRows || col < 0 || col >= this.leafCols.length) return\n this.activeCell = { row, col }\n if (addToSelection) {\n this.selectionRanges.push(singleCellRange(row, col))\n } else {\n this.selectionRanges = [singleCellRange(row, col)]\n }\n this.emitSelectionChange()\n },\n\n selectRange(startRow, startCol, endRow, endCol, replace = true) {\n const range = { startRow, startCol, endRow, endCol }\n if (replace) {\n this.selectionRanges = [range]\n } else {\n this.selectionRanges.push(range)\n }\n this.emitSelectionChange()\n },\n\n selectRow(ri) {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [{ startRow: ri, endRow: ri, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n selectColumn(ci) {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n this.emitSelectionChange()\n },\n\n selectAll() {\n this.activeCell = { row: 0, col: 0 }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: 0, endCol: this.leafCols.length - 1 }]\n this.emitSelectionChange()\n },\n\n emitSelectionChange() {\n this.$emit('selection-change', {\n activeCell: { ...this.activeCell },\n ranges: this.selectionRanges.map(r => normalizeRange(r))\n })\n },\n\n onCellMouseDown(e, ri, ci) {\n this.invalidCell = null\n if (this.editingCell) {\n const editCol = this.leafCols[this.editingCell.col]\n if (editCol && this._hasCustomEdit(editCol)) {\n // Custom cell/edit slots commit via their own @blur/@done handler\n // (see commitEditSlot()), and the browser fires that native blur\n // *after* this mousedown listener returns (blur is the default\n // action of the focus shift, which runs post-dispatch). Cancelling\n // here immediately would null out editingCell before the slot's\n // blur handler gets to call done(), silently discarding the typed\n // value. Defer the cleanup instead, and only run it if the slot's\n // own commit hasn't already resolved editingCell by then.\n const staleEditingCell = this.editingCell\n this.$nextTick(() => {\n if (this.editingCell === staleEditingCell) this.cancelEdit()\n })\n } else {\n this.commitEdit()\n }\n }\n this.hideContextMenu()\n this.$refs.wrapper && this.$refs.wrapper.focus()\n\n if (e.ctrlKey || e.metaKey) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Ctrl+click: start new independent range\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges.push(this.mouseDownRange)\n this.isCtrlDrag = true\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n // Shift+click: extend primary range to this cell\n this.selectionRanges = [buildRange(this.activeCell, { row: ri, col: ci })]\n } else {\n const wasActive = this.activeCell.row === ri && this.activeCell.col === ci\n this.activeCell = { row: ri, col: ci }\n this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }\n this.selectionRanges = [this.mouseDownRange]\n this.isCtrlDrag = false\n\n // Open select/date dropdown on click if cell was already active (2nd click)\n const col = this.leafCols[ci]\n if (wasActive && col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openSelectDropdown(ri, ci, e)\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (wasActive && col && col.type === 'date' && !this._hasCustomEdit(col) && this.isCellEditable(ri, ci)) {\n this.openDatePickerForActive()\n e.preventDefault()\n this.isMouseDown = true\n this.emitSelectionChange()\n return\n }\n if (this.selectDropdown) this.closeSelectDropdown()\n if (this.datePickerOverlay) this.closeDatePicker()\n }\n\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onCellMouseOver(e, ri, ci) {\n if (!this.isMouseDown) return\n if (this.fillDrag.active) return\n if (this.dragMove.active) {\n this.dragMove.targetRow = ri\n this.dragMove.targetCol = ci\n return\n }\n\n // Extend current drag range\n if (this.mouseDownRange) {\n this.mouseDownRange.endRow = ri\n this.mouseDownRange.endCol = ci\n this.emitSelectionChange()\n }\n },\n\n onCellMouseUp() {\n this.isMouseDown = false\n },\n\n onCellDblClick(ri, ci) {\n const col = this.leafCols[ci]\n if (!this.isCellEditable(ri, ci)) return\n if (col && this.isSelectLikeCol(col) && !this._hasCustomEdit(col)) {\n this.openSelectDropdownForActive()\n return\n }\n if (col && col.type === 'date' && !this._hasCustomEdit(col)) {\n this.openDatePickerForActive()\n return\n }\n if (col && col.type === 'checkbox') return\n if (col && col.type === 'action') return\n this.startEditing(ri, ci, null, true)\n },\n\n onRowHeaderMouseDown(e, ri) {\n const endCol = this.leafCols.length - 1\n const fullRow = { startRow: ri, endRow: ri, startCol: 0, endCol }\n\n if (e.ctrlKey || e.metaKey) {\n if (e.shiftKey && this.selectionRanges.length > 0) {\n // Ctrl+Shift: active qatordan ri gacha yangi range qo'sh\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [...this.selectionRanges, { startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n // Ctrl+click: qatorni toggle qil\n const idx = this.selectionRanges.findIndex(r =>\n r.startRow === ri && r.endRow === ri && r.startCol === 0 && r.endCol === endCol\n )\n if (idx >= 0) {\n const next = this.selectionRanges.filter((_, i) => i !== idx)\n this.selectionRanges = next.length ? next : [fullRow]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [...this.selectionRanges, fullRow]\n }\n }\n } else if (e.shiftKey && this.selectionRanges.length > 0) {\n const sr = Math.min(this.activeCell.row, ri)\n const er = Math.max(this.activeCell.row, ri)\n this.selectionRanges = [{ startRow: sr, endRow: er, startCol: 0, endCol }]\n } else {\n this.activeCell = { row: ri, col: 0 }\n this.selectionRanges = [fullRow]\n }\n\n this.mouseDownRange = this.selectionRanges[this.selectionRanges.length - 1]\n this.isMouseDown = true\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderMouseDown(e, cell) {\n // Start pending drag (activates after 5 px horizontal movement)\n // Works for both leaf and group headers\n if (this.columnDraggable && !this.internalLocked && !e.shiftKey && !e.ctrlKey && !e.metaKey) {\n this.colDrag.pending = true\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = cell._firstLeafFlatIndex\n this.colDrag.sourceLeafCount = cell.isGroup ? cell.colspan : 1\n this.colDrag.startX = e.clientX\n this.colDrag.startY = e.clientY\n this.colDrag.ghostLabel = cell.column.title || ''\n // Grabbed header's rect sizes the ghost and anchors the grab point if\n // the 5px threshold later activates the drag (see _startColDrag).\n this._pendingDragHeaderRect = e.currentTarget ? e.currentTarget.getBoundingClientRect() : null\n }\n\n if (cell.isGroup) {\n // Group header click: select all leaf columns within the group\n const startCol = cell._firstLeafFlatIndex\n const endCol = startCol + cell.colspan - 1\n this.activeCell = { row: 0, col: startCol }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol, endCol }]\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n return\n }\n\n const ci = cell.column._flatIndex\n if (e.shiftKey && this.selectionRanges.length > 0) {\n const sc = Math.min(this.activeCell.col, ci)\n const ec = Math.max(this.activeCell.col, ci)\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: sc, endCol: ec }]\n } else {\n this.activeCell = { row: 0, col: ci }\n this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]\n }\n this.isMouseDown = true\n this.mouseDownRange = this.selectionRanges[0]\n this.emitSelectionChange()\n e.preventDefault()\n },\n\n onColHeaderDblClick(e, cell) {\n if (cell.isGroup) return\n this.autoFitColumn(cell.column._flatIndex)\n },\n\n onColHeaderContextMenu(e, cell) {\n if (!cell.isGroup) {\n this._openContextMenu(e, -1, cell.column._flatIndex)\n }\n },\n\n onRowContextMenu(e, ri) {\n this._openContextMenu(e, ri, -1)\n },\n\n onCellContextMenu(e, ri, ci) {\n if (!this.isSelected(ri, ci)) {\n this.selectCell(ri, ci)\n }\n this._openContextMenu(e, ri, ci)\n },\n\n _openContextMenu(e, row, col) {\n this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row, col }\n this.contextMenuPositioned = false\n this.$nextTick(this._positionContextMenu)\n },\n\n // Flips the menu up/left if it would overflow the viewport at the raw\n // click coordinates — measured against the actual rendered size (item\n // count varies with devMode/pin/row-vs-col context, so an estimate isn't\n // reliable). Rendered off-screen (left:-9999px, see template) while\n // unpositioned so this first measurement reflects the menu's true\n // natural (single-line) width — measuring it in place near a viewport\n // edge would let the browser's own shrink-to-fit sizing wrap the text\n // to fit the cramped space, which then \"sticks\" even after flipping to\n // a roomier position since we only move it, we don't force a width.\n // Stays hidden (visibility, not v-if) until repositioned so there's no\n // visible jump.\n _positionContextMenu() {\n const el = this.$refs.contextMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.contextMenu.x\n let y = this.contextMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.contextMenu.x = x\n this.contextMenu.y = y\n this.contextMenuPositioned = true\n },\n\n hideContextMenu() {\n this.contextMenu.visible = false\n },\n\n // General click-anywhere-in-the-grid dismissal for both floating menus —\n // each menu item stops propagation (@click.stop) so a click *inside*\n // either menu never reaches this handler.\n hideFloatingMenus() {\n this.hideContextMenu()\n this.hideSortMenu()\n },\n\n\n // =========================================================\n // HOVER HIGHLIGHT\n // =========================================================\n onGridMouseMove(e) {\n const rowNode = e.target.closest('[data-row]')\n const newRi = rowNode ? parseInt(rowNode.getAttribute('data-row'), 10) : -1\n\n if (this._hoveredRi !== newRi) {\n if (this._hoveredRi >= 0) {\n const oldEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < oldEls.length; i++) {\n oldEls[i].classList.remove('eg-row--hover')\n }\n }\n \n this._hoveredRi = newRi\n\n if (this._hoveredRi >= 0) {\n const newEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < newEls.length; i++) {\n newEls[i].classList.add('eg-row--hover')\n }\n }\n }\n },\n \n onGridMouseLeave() {\n if (this._hoveredRi >= 0) {\n if (this.$refs.wrapper) {\n const oldEls = this.$refs.wrapper.querySelectorAll(`[data-row=\"${this._hoveredRi}\"]`)\n for (let i = 0; i < oldEls.length; i++) {\n oldEls[i].classList.remove('eg-row--hover')\n }\n }\n this._hoveredRi = -1\n }\n },\n\n // =========================================================\n // KEYBOARD NAVIGATION (3.2)\n // =========================================================\n onKeyDown(e) {\n // Delegate keyboard navigation to the active dropdown component\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n if (comp && comp.handleKey) comp.handleKey(e)\n return\n }\n\n if (this.infoModal) {\n if (e.key === 'Escape') this.closeInfoModal()\n return\n }\n\n if (this.editingCell) {\n // Normally handled by onEditKeyDown on the built-in <input> (which stops\n // propagation, so this branch never runs for it). Custom #cell-{key} edit\n // slots own their own input and focus it themselves — if that focus didn't\n // land (e.g. the host relies on the unreliable HTML `autofocus` attribute\n // on a freshly-inserted element), Escape falls through to here instead.\n // Without this, editingCell stays stuck and even arrow-key navigation is\n // swallowed by this same guard on every subsequent keydown.\n if (e.key === 'Escape') this.cancelEdit()\n return\n }\n const key = e.key\n // Layout-independent letter for the Ctrl/Cmd shortcuts below (z/y/a/c/x/v/d/r).\n // e.key reports the character a layout PRODUCES — on a non-Latin layout\n // (Cyrillic, etc.) the physical C key can report e.key: 'с' (Cyrillic es)\n // even with Ctrl held and the physical key/position unchanged, silently\n // breaking `key === 'c'`. e.code is the physical key position and is\n // immune to layout, so it's used here instead wherever available;\n // falls back to e.key when e.code is missing (e.g. synthetic events in\n // tests that don't set it).\n const shortcutKey = (e.code && e.code.length === 4 && e.code.slice(0, 3) === 'Key')\n ? e.code.charAt(3).toLowerCase()\n : key\n\n if ((e.ctrlKey || e.metaKey) && shortcutKey === 'z' && !e.shiftKey) {\n e.preventDefault()\n this.undo()\n return\n }\n if ((e.ctrlKey || e.metaKey) && (shortcutKey === 'y' || (shortcutKey === 'z' && e.shiftKey))) {\n e.preventDefault()\n this.redo()\n return\n }\n\n if (shortcutKey === 'a' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.selectAll()\n return\n }\n\n if (shortcutKey === 'c' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.copySelection()\n return\n }\n if (shortcutKey === 'x' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.cutSelection()\n return\n }\n if (shortcutKey === 'v' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.pasteAt(this.activeCell.row, this.activeCell.col)\n return\n }\n\n if (shortcutKey === 'd' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillDown()\n return\n }\n if (shortcutKey === 'r' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault()\n this.fillRight()\n return\n }\n\n if (key === 'Delete' || key === 'Backspace') {\n e.preventDefault()\n this.clearSelection()\n return\n }\n\n if (key === 'F2') {\n e.preventDefault()\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openSelectDropdownForActive()\n } else if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol) && activeEditable) {\n this.openDatePickerForActive()\n } else if (activeCol && activeCol.type === 'checkbox' && activeEditable) {\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n } else if (activeCol && activeCol.type === 'action' && activeEditable) {\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n } else if (activeEditable) {\n this.startEditing(this.activeCell.row, this.activeCell.col, null, true)\n }\n return\n }\n\n if (key === ' ') {\n const activeCol = this.leafCols[this.activeCell.col]\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n this.toggleCheckbox(this.activeCell.row, this.activeCell.col)\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n this.triggerCellAction(this.activeCell.row, this.activeCell.col, e)\n return\n }\n }\n\n if (key === 'Escape') {\n this.clipboardRange = null\n this.clipboardMatrix = null\n this.clipboardMode = null\n return\n }\n\n if (key === 'Tab') {\n e.preventDefault()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n return\n }\n if (key === 'Enter') {\n e.preventDefault()\n this.moveActive(e.shiftKey ? -1 : 1, 0, e)\n return\n }\n\n const arrows = { ArrowUp: [-1,0], ArrowDown: [1,0], ArrowLeft: [0,-1], ArrowRight: [0,1] }\n if (arrows[key]) {\n e.preventDefault()\n const [dr, dc] = arrows[key]\n if (e.shiftKey) {\n this.extendSelection(dr, dc)\n } else {\n this.moveActive(dr, dc, e)\n }\n return\n }\n\n if (key === 'Home') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(0, 0)\n } else {\n this.selectCell(this.activeCell.row, 0)\n }\n return\n }\n if (key === 'End') {\n e.preventDefault()\n if (e.ctrlKey) {\n this.selectCell(this.totalRows - 1, this.leafCols.length - 1)\n } else {\n this.selectCell(this.activeCell.row, this.leafCols.length - 1)\n }\n return\n }\n if (key === 'PageUp') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(-this._pageRowSpan(), 0)\n } else {\n this.moveActive(-this._pageRowSpan(), 0, e)\n }\n return\n }\n if (key === 'PageDown') {\n e.preventDefault()\n if (e.shiftKey) {\n this.extendSelection(this._pageRowSpan(), 0)\n } else {\n this.moveActive(this._pageRowSpan(), 0, e)\n }\n return\n }\n },\n\n onKeyPress(e) {\n if (this.editingCell) return\n if (this.selectDropdown) return\n if (this.datePickerOverlay) return\n if (e.ctrlKey || e.metaKey || e.altKey) return\n if (e.key && e.key.length === 1) {\n const activeCol = this.leafCols[this.activeCell.col]\n const activeEditable = this.isCellEditable(this.activeCell.row, this.activeCell.col)\n if (activeCol && this.isSelectLikeCol(activeCol) && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openSelectDropdownForActive()\n return\n }\n if (activeCol && activeCol.type === 'date' && !this._hasCustomEdit(activeCol)) {\n e.preventDefault()\n if (activeEditable) this.openDatePickerForActive()\n return\n }\n if (activeCol && activeCol.type === 'checkbox') {\n e.preventDefault()\n return\n }\n if (activeCol && activeCol.type === 'action') {\n e.preventDefault()\n return\n }\n // Without this, the browser's own native \"insert this character\" action\n // (queued for whatever element ends up focused) still fires after\n // startEditing()'s $nextTick focuses the freshly-created edit <input> —\n // which is already seeded with this same character via editValue — so\n // the char gets inserted a second time (e.g. \"1\" -> \"11\").\n e.preventDefault()\n this.startEditing(this.activeCell.row, this.activeCell.col, e.key, false)\n }\n },\n\n onGlobalKeyDown(e) {\n if (e.key === 'Escape' && (this.colDrag.active || this.colDrag.pending)) {\n this._resetColDrag(true)\n e.preventDefault()\n }\n },\n\n // Shift+Arrow / Shift+PageUp/PageDown: extend or shrink the selection\n // relative to the FIXED anchor (activeCell) — not just always growing in\n // the pressed direction. The \"far\" corner is whichever end of the current\n // (normalized) range isn't the anchor; per axis, _extendAxis grows it\n // further out while it's already on the pressed side, or shrinks it back\n // toward the anchor (never past it in one step) while it's on the\n // opposite side — matching Excel/Sheets. Only the axis(es) with a\n // non-zero delta are touched, so a pure vertical extend preserves the\n // current column range untouched, and vice versa.\n extendSelection(dr, dc) {\n const anchor = this.activeCell\n const pr = this.primaryRange || singleCellRange(anchor.row, anchor.col)\n const nm = normalizeRange(pr)\n let farRow = nm.startRow === anchor.row ? nm.endRow : nm.startRow\n let farCol = nm.startCol === anchor.col ? nm.endCol : nm.startCol\n if (dr !== 0) farRow = this._extendAxis(anchor.row, farRow, dr, this.totalRows - 1)\n if (dc !== 0) farCol = this._extendAxis(anchor.col, farCol, dc, this.leafCols.length - 1)\n this.selectionRanges = [buildRange(anchor, { row: farRow, col: farCol })]\n this.emitSelectionChange()\n // Unlike moveActive(), this doesn't move the active cell — but the newly\n // extended (far) corner of the range must still be scrolled into view,\n // exactly like moveActive() does for the active cell itself.\n this.scrollToCell(farRow, farCol)\n },\n\n _extendAxis(anchorIdx, farIdx, delta, maxIdx) {\n if (delta > 0) {\n return farIdx >= anchorIdx ? Math.min(maxIdx, farIdx + delta) : Math.min(anchorIdx, farIdx + delta)\n }\n return farIdx <= anchorIdx ? Math.max(0, farIdx + delta) : Math.max(anchorIdx, farIdx + delta)\n },\n\n moveActive(dr, dc, e) {\n let row = this.activeCell.row + dr\n let col = this.activeCell.col + dc\n\n // Tab: wrap around rows\n if (e && e.key === 'Tab') {\n if (col >= this.leafCols.length) { col = 0; row++ }\n if (col < 0) { col = this.leafCols.length - 1; row-- }\n }\n\n row = Math.max(0, Math.min(this.totalRows - 1, row))\n col = Math.max(0, Math.min(this.leafCols.length - 1, col))\n\n // Skip hidden cols\n while (this.isColHidden(col) && col > 0 && col < this.leafCols.length - 1) {\n col += dc >= 0 ? 1 : -1\n }\n // Skip hidden rows\n while (this.isRowHidden(row) && row > 0 && row < this.totalRows - 1) {\n row += dr >= 0 ? 1 : -1\n }\n\n // Handle merge navigation\n const resolved = resolveMergeAnchor(row, col, this.internalMerges)\n row = resolved.row\n col = resolved.col\n\n this.activeCell = { row, col }\n this.selectionRanges = [singleCellRange(row, col)]\n this.emitSelectionChange()\n this.scrollToCell(row, col)\n },\n\n scrollToCell(ri, ci) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return\n const rowTop = getRowTop(ri, this._rowH, this.rowHeights, ROW_LIST_EDGE_PAD)\n const rowBottom = rowTop + this.getRowH(ri)\n\n if (rowTop < scrollBody.scrollTop) scrollBody.scrollTop = rowTop\n if (rowBottom > scrollBody.scrollTop + scrollBody.clientHeight) {\n scrollBody.scrollTop = rowBottom - scrollBody.clientHeight\n }\n\n // Pinned columns are sticky — always horizontally in view, nothing to do.\n if (!this.isColumnScrollable(ci)) return\n const zoneLeft = this.getZoneColLeft(ci, 'scroll')\n const zoneRight = zoneLeft + this.getColW(ci)\n // A scrollable cell only counts as visible inside the window the sticky\n // pinned zones don't cover — without subtracting them, a cell could sit\n // fully underneath a pinned zone yet be treated as already in view.\n const visibleW = scrollBody.clientWidth - this.getFrozenWidth() - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n if (zoneLeft < scrollBody.scrollLeft) scrollBody.scrollLeft = zoneLeft\n if (zoneRight > scrollBody.scrollLeft + visibleW) {\n scrollBody.scrollLeft = zoneRight - visibleW\n }\n },\n\n // =========================================================\n // EDITING (used by 3.1 + 3.2)\n // =========================================================\n startEditing(ri, ci, initialChar = null, keepValue = false) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (ri < 0 || ri >= this.totalRows) return\n if (!this._hasCustomEdit(col)) {\n if (this.isSelectLikeCol(col)) { this.openSelectDropdownForActive(); return }\n if (col.type === 'date') { this.openDatePickerForActive(); return }\n if (col.type === 'checkbox') return\n if (col.type === 'action') return\n }\n\n this.invalidCell = null\n this.editingCell = { row: ri, col: ci }\n if (initialChar !== null) {\n this.editValue = initialChar\n } else if (keepValue) {\n this.editValue = String(this.getCellValue(ri, ci) || '')\n } else {\n this.editValue = ''\n }\n this.editOriginalValue = String(this.getCellValue(ri, ci) || '')\n\n this.$nextTick(() => {\n const inputs = this.$refs.editInput\n if (inputs) {\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) {\n // col.mask: MaskInput owns the element's live formatting/caret while\n // typing and re-dispatches a corrected native 'input' event after\n // every keystroke, which the existing v-model=\"editValue\" binding\n // picks up normally — no separate sync code needed. It also masks\n // whatever value is already in the input (seeded above) on attach.\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (col.mask) {\n const options = (typeof col.mask === 'string' || Array.isArray(col.mask)) ? { mask: col.mask } : col.mask\n this._maskInput = new MaskInput(input, options)\n }\n input.focus()\n if (keepValue) {\n input.setSelectionRange(input.value.length, input.value.length)\n }\n }\n }\n })\n },\n\n commitEdit() {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n\n // editValue is already masked live by MaskInput (see startEditing) — this\n // is a defensive final pass, cheap/idempotent, and covers any commit that\n // skipped the live-typing path (e.g. programmatic editValue changes).\n const value = this._coerceCellValue(col, this._applyColMask(col, this.editValue))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n cancelEdit() {\n clearTimeout(this._onInputTimer)\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Fires while the user is actively typing in the plain-text edit input —\n // debounced (col.debounceMs, default 400ms) so col.onInput isn't called\n // on every keystroke. `row` is the pre-edit row (not yet committed);\n // `value` is the live in-progress text.\n onEditInput(e) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n this._scheduleOnInput(row, col, e.target.value)\n },\n\n _scheduleOnInput(ri, ci, value) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n clearTimeout(this._onInputTimer)\n this._onInputPending = true\n const delay = typeof col.debounceMs === 'number' ? col.debounceMs : 400\n this._onInputTimer = setTimeout(() => {\n this._onInputPending = false\n const row = this._dataRaw[ri]\n if (row) {\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row, ri, col)\n // col.onInput is a common place for a host to derive/write another\n // cell's value on the same (already-reactive) row object — but cell\n // rendering reads through the non-reactive _dataRaw (see created()),\n // so an in-place mutation like that is otherwise invisible until the\n // next time `data` itself gets a new reference (e.g. on commit).\n this.$forceUpdate()\n }\n }, delay)\n },\n\n // Cancels any pending debounced call and fires col.onInput immediately —\n // used at commit points (Enter/blur, date pick, checkbox toggle) so a\n // still-in-flight debounce timer never drops the final value. If nothing\n // is pending (the debounce already fired naturally with this exact\n // value), skip re-firing — commitEdit() calls this unconditionally, so\n // without this guard a slow typist would get onInput called twice for\n // the same settled value.\n _flushOnInput(ri, ci, value, row) {\n const col = this.leafCols[ci]\n if (!col || typeof col.onInput !== 'function') return\n const wasPending = this._onInputPending\n clearTimeout(this._onInputTimer)\n this._onInputPending = false\n const last = this._lastFiredOnInput\n if (!wasPending && last && last.ri === ri && last.ci === ci && last.value === value) return\n this._lastFiredOnInput = { ri, ci, value }\n col.onInput(value, row || this._dataRaw[ri], ri, col)\n // See _scheduleOnInput — a same-tick mutation to another cell on `row`\n // needs its own forceUpdate, since it happens after the data-watcher's\n // own forceUpdate (triggered by the commit that led here) already ran.\n this.$forceUpdate()\n },\n\n hasCellSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['cell-' + col.key])\n },\n hasLegacyEditSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['edit-' + col.key])\n },\n hasPrefixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['prefix-' + col.key])\n },\n hasSuffixSlot(col) {\n return !!(col && col.key && this.$scopedSlots && this.$scopedSlots['suffix-' + col.key])\n },\n _hasCustomEdit(col) {\n // For select/treeSelect/date: cell-{key} slot only customizes display; picker still opens normally\n if (col.type === 'select' || col.type === 'treeSelect' || col.type === 'date') {\n return this.hasLegacyEditSlot(col)\n }\n return this.hasCellSlot(col) || this.hasLegacyEditSlot(col)\n },\n\n commitEditSlot(value) {\n if (!this.editingCell) return\n const { row, col } = this.editingCell\n const colDef = this.leafCols[col]\n if (this._maskInput) { this._maskInput.destroy(); this._maskInput = null }\n if (!colDef) { this.editingCell = null; return }\n value = this._coerceCellValue(col, this._applyColMask(col, value))\n if (!this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, value, newData[row])\n this.editingCell = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // CHECKBOX CELLS\n // =========================================================\n\n isCheckboxChecked(ri, ci) {\n const v = this.getCellValue(ri, ci)\n if (v === true || v === 1) return true\n if (typeof v === 'string') return ['true', '1'].includes(v.trim().toLowerCase())\n return false\n },\n\n setCheckboxValue(ri, ci, value) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) return\n if (!this.isCellValueValid(ri, ci, value)) {\n this._showInvalidCell(ri, ci, value)\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[ri] = { ...oldData[ri], [col.key]: value }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(ri, ci, value, newData[ri])\n },\n\n toggleCheckbox(ri, ci) {\n this.setCheckboxValue(ri, ci, !this.isCheckboxChecked(ri, ci))\n },\n\n // =========================================================\n // ACTION CELLS (col.type === 'action')\n // =========================================================\n\n // Dynamic v-on argument expressions can't contain quotes/spaces in Vue 2.6\n // templates, so the 'click' fallback is resolved here instead of inline.\n actionHandlerEvent(col) {\n return (col && col.handlerType) || 'click'\n },\n\n triggerCellAction(ri, ci, e) {\n if (this.internalLocked) return\n const col = this.leafCols[ci]\n if (!col || col.type !== 'action' || typeof col.handler !== 'function') return\n if (!this.isCellEditable(ri, ci)) return\n col.handler(this._dataRaw[ri], ri, col, ci, e)\n },\n\n // =========================================================\n // CELL EDITABILITY (3.17)\n // =========================================================\n\n isCellEditable(ri, ci) {\n const col = this.leafCols[ci]\n if (!col) return false\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellEditable === 'function') return !!this.cellEditable(row, ri, col, ci)\n if (typeof col.editable === 'function') return !!col.editable(row, ri)\n if (col.editable === false) return false\n return true\n },\n\n // =========================================================\n // CELL VALUE VALIDATION (cellValidator)\n // =========================================================\n\n isCellValueValid(ri, ci, value) {\n if (typeof this.cellValidator !== 'function') return true\n const col = this.leafCols[ci]\n if (!col) return true\n const row = this._dataRaw[ri] || {}\n return !!this.cellValidator(value, row, ri, col, ci)\n },\n\n _resolveValidationMessage(ri, ci, value) {\n const col = this.leafCols[ci]\n const row = this._dataRaw[ri] || {}\n if (typeof this.cellValidationMessage === 'function') return this.cellValidationMessage(value, row, ri, col, ci)\n if (typeof this.cellValidationMessage === 'string' && this.cellValidationMessage) return this.cellValidationMessage\n return this.t.invalidValue\n },\n\n // Find the on-screen rect of the cell currently being committed — the edit\n // input if still mounted (commitEdit, before editingCell is cleared), else\n // the active cell element (checkbox/select commits, which never enter edit mode).\n _invalidCellRect() {\n const inputs = this.$refs.editInput\n const input = Array.isArray(inputs) ? inputs[0] : inputs\n if (input) return input.getBoundingClientRect()\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n return el ? el.getBoundingClientRect() : null\n },\n\n _showInvalidCell(ri, ci, value) {\n const message = this._resolveValidationMessage(ri, ci, value)\n this.invalidCell = { row: ri, col: ci, message, rect: this._invalidCellRect() }\n this.$emit('cell-validation-error', { row: ri, col: ci, value, message })\n },\n\n // Resolve+emit messages for a batch of rejected { row, col, value } entries\n // from a bulk write (paste / fill / clear). blocked=true means the whole\n // operation was aborted (strictBulkValidation); false means these cells\n // were skipped but the rest of the write still applied.\n _emitBulkValidationError(skipped, blocked) {\n const cells = skipped.map(c => ({\n row: c.row, col: c.col, value: c.value,\n message: this._resolveValidationMessage(c.row, c.col, c.value)\n }))\n this.$emit('bulk-validation-error', { blocked, cells })\n },\n\n // =========================================================\n // SELECT DROPDOWN (3.16) + TREE SELECT (3.22)\n // =========================================================\n\n isSelectLikeCol(col) {\n return !!(col && (col.type === 'select' || col.type === 'treeSelect'))\n },\n\n treeOptionKeys(col) {\n return {\n valueKey: col.optionValueKey || 'value',\n labelKey: col.optionLabelKey || 'label',\n childrenKey: col.optionChildrenKey || 'children'\n }\n },\n\n // Returns { [colKey]: 'left'|'right' } for columns that declare col.pin.\n _colPinMap(cols) {\n const map = {}\n ;(cols || []).forEach(col => {\n if (col.pin === 'left' || col.pin === 'right') map[col.key] = col.pin\n })\n return map\n },\n\n _treeFindLabel(nodes, keys, value) {\n for (const node of (nodes || [])) {\n // A malformed col.options (e.g. a host array with a hole/undefined entry,\n // often from an unguarded Array.prototype.find() result spread back in)\n // shouldn't crash the whole grid's render — skip it like a missing option.\n if (!node) continue\n if (String(node[keys.valueKey]) === value) {\n return node[keys.labelKey] != null ? node[keys.labelKey] : String(node[keys.valueKey])\n }\n const found = this._treeFindLabel(node[keys.childrenKey], keys, value)\n if (found != null) return found\n }\n return null\n },\n\n // col.options may be a static array OR a function (row, rowIndex, column, colIndex) => array,\n // letting hosts return a different option list per cell.\n hasOptionsSource(col) {\n return !!(col && (Array.isArray(col.options) || typeof col.options === 'function'))\n },\n\n resolveOptionsArray(col, ri, ci) {\n if (!col) return []\n if (typeof col.options === 'function') {\n const row = ri != null ? (this._dataRaw[ri] || {}) : {}\n const result = col.options(row, ri, col, ci != null ? ci : col._flatIndex)\n return Array.isArray(result) ? result : []\n }\n return Array.isArray(col.options) ? col.options : []\n },\n\n colOptions(ci, ri) {\n const col = this.leafCols[ci]\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return []\n // optionValueKey/optionLabelKey apply to type:'select' the same way they\n // already do for type:'treeSelect' — same config, same defaults.\n const { valueKey, labelKey } = this.treeOptionKeys(col)\n return raw.map(o =>\n typeof o === 'string' ? { value: o, label: o } : { value: o[valueKey], label: o[labelKey] != null ? o[labelKey] : String(o[valueKey]), color: o.color }\n )\n },\n\n getSelectLabel(ci, value, ri) {\n const col = this.leafCols[ci]\n const v = value == null ? '' : String(value)\n if (col && col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, ri, ci)\n if (!raw.length) return v\n const found = this._treeFindLabel(raw, this.treeOptionKeys(col), v)\n return found != null ? found : v\n }\n const opts = this.colOptions(ci, ri)\n if (!opts.length) return v\n const found = opts.find(o => String(o.value) === v)\n return found ? found.label : v\n },\n\n _buildSelectDropdownState(ri, ci, col, currentValue, rect) {\n const rectObj = { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n return {\n row: ri, col: ci,\n tree: col.type === 'treeSelect',\n // valueKey/labelKey apply to both select and treeSelect (childrenKey\n // only matters for the tree dropdown, harmlessly unused otherwise).\n keys: this.treeOptionKeys(col),\n rawOptions: this.resolveOptionsArray(col, ri, ci),\n currentValue,\n rect: rectObj,\n // Defaults to on (opt out with enableSearch: false) — same convention\n // as clearable below.\n enableSearch: col.enableSearch !== false,\n clearable: col.clearable !== false\n }\n },\n\n openSelectDropdown(ri, ci, e) {\n const col = this.leafCols[ci]\n if (!this.hasOptionsSource(col)) return\n const el = e.currentTarget\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(ri, ci) != null ? this.getCellValue(ri, ci) : '')\n this.selectDropdown = this._buildSelectDropdownState(ri, ci, col, currentValue, rect)\n },\n\n openSelectDropdownForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!this.hasOptionsSource(colDef)) return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const currentValue = String(this.getCellValue(row, col) != null ? this.getCellValue(row, col) : '')\n this.selectDropdown = this._buildSelectDropdownState(row, col, colDef, currentValue, rect)\n })\n },\n\n onComponentSelect({ value, label }) {\n if (this.internalLocked) { this.closeSelectDropdown(); return }\n if (!this.selectDropdown) return\n const { row, col } = this.selectDropdown\n const colDef = this.leafCols[col]\n if (colDef) {\n // Clearing (value === '') always goes through, same as onDatePick()'s\n // empty-string bypass — \"no selection\" is a valid state regardless of\n // what cellValidator requires of a real picked value.\n if (value !== '' && !this.isCellValueValid(row, col, value)) {\n this._showInvalidCell(row, col, value)\n this.closeSelectDropdown()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: value }\n this._emitDataChange(oldData, newData)\n if (typeof colDef.onSelect === 'function') {\n // Deferred the same way as _fireCellCallbacksForRange (paste/fill/drag-move) —\n // a host watching the emitted data (e.g. mirroring it into its own\n // store) needs that watcher to run first, or onSelect reads stale state.\n this.$nextTick(() => {\n colDef.onSelect({ value, label }, newData[row], row, colDef)\n // See _scheduleOnInput — picks up any same-tick mutation the callback\n // made to another cell on this (already-reactive) row object.\n this.$forceUpdate()\n })\n }\n }\n this.closeSelectDropdown()\n },\n\n // Always returns focus to the grid wrapper — the dropdown (especially with\n // enableSearch) can hold real DOM focus on its own search input, and every\n // close path (Escape, outside click, picking a value) must hand focus back\n // or keyboard navigation on the grid goes dead until the user clicks a cell.\n closeSelectDropdown() {\n this.selectDropdown = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // =========================================================\n // DATE PICKER\n // =========================================================\n\n getCellDisplayValue(ci, val, ri) {\n const col = this.leafCols[ci]\n if (!col) return val == null ? '' : val\n if (typeof col.formatter === 'function') {\n const out = col.formatter(val, this._dataRaw[ri], ri, col, ci)\n return out == null ? '' : out\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, val, ri)\n if (col.type === 'date') {\n const d = parseDateValue(val)\n return d ? formatDate(d, col.format || DEFAULT_DATE_FORMAT) : (val == null ? '' : val)\n }\n return val == null ? '' : val\n },\n\n // =========================================================\n // FILTER ROW\n // =========================================================\n\n // Which control the filter cell renders for this column.\n // 'none' cells still render (empty) so column alignment stays intact.\n filterControlType(col) {\n if (!col || col.filterable === false || col.type === 'action') return 'none'\n if (col.type === 'checkbox') return 'checkbox'\n // Function-based options can't be resolved into a fixed list outside a\n // specific row, so those columns fall back to the text control.\n if ((col.type === 'select' || col.type === 'treeSelect') && Array.isArray(col.options)) return 'select'\n return 'text'\n },\n\n // Flat { value, label } list for a select-control filter cell. treeSelect\n // options are recursively flattened, each depth indented for readability.\n filterOptionsFor(col) {\n if (!col) return []\n if (col.type === 'treeSelect') {\n const raw = this.resolveOptionsArray(col, null, col._flatIndex)\n const out = []\n this._flattenTreeOptionsForFilter(raw, this.treeOptionKeys(col), 0, out)\n return out\n }\n return this.colOptions(col._flatIndex, null)\n },\n\n _flattenTreeOptionsForFilter(nodes, keys, depth, out) {\n for (const node of (nodes || [])) {\n const value = node[keys.valueKey]\n const label = node[keys.labelKey] != null ? node[keys.labelKey] : String(value)\n out.push({ value: String(value), label: '  '.repeat(depth) + label })\n this._flattenTreeOptionsForFilter(node[keys.childrenKey], keys, depth + 1, out)\n }\n },\n\n getFilterCellStyle(ci) {\n const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')\n const left = this.getZoneColLeft(ci, zone)\n const w = this.getColW(ci)\n return {\n position: 'absolute',\n left: left + 'px',\n top: (this._headerRowH * this.headerDepth) + 'px',\n width: w + 'px',\n height: this._filterRowH + 'px',\n boxSizing: 'border-box'\n }\n },\n\n setFilter(key, value) {\n if (!this.leafCols.some(c => c.key === key)) return\n const next = { ...this.internalFilters }\n if (value === '' || value === null || value === undefined) {\n delete next[key]\n } else {\n next[key] = value\n }\n this.internalFilters = next\n this.$emit('update:filters', { ...next })\n this.$emit('filter-change', { key, value, filters: { ...next } })\n },\n\n clearFilters() {\n if (!Object.keys(this.internalFilters).length) return\n this.internalFilters = {}\n // A deliberate, infrequent action (unlike typing) — recompute immediately\n // rather than waiting out the debounce, and drop any timer already pending.\n clearTimeout(this._filterRecomputeTimer)\n this._recomputeRowFilters()\n this.$emit('update:filters', {})\n this.$emit('filter-change', { key: null, value: null, filters: {} })\n },\n\n // Debounced — recomputing filteredRowIndices scans every row, so typing\n // in the text filter shouldn't trigger it on every keystroke.\n _scheduleFilterRecompute() {\n clearTimeout(this._filterRecomputeTimer)\n this._filterRecomputeTimer = setTimeout(() => { this._recomputeRowFilters() }, 150)\n },\n\n _activeFilterCols() {\n const cols = []\n for (const col of this.leafCols) {\n if (this.filterControlType(col) === 'none') continue\n const v = this.internalFilters[col.key]\n if (v === undefined || v === null || v === '') continue\n cols.push(col)\n }\n return cols\n },\n\n _cellMatchesFilter(row, ri, col) {\n const ci = col._flatIndex\n const filterValue = this.internalFilters[col.key]\n if (typeof col.filterFn === 'function') {\n return !!col.filterFn(this.getCellValue(ri, ci), filterValue, row, ri, col)\n }\n const kind = this.filterControlType(col)\n if (kind === 'checkbox') {\n const checked = this.isCheckboxChecked(ri, ci)\n return filterValue === 'true' ? checked : !checked\n }\n if (kind === 'select') {\n const raw = this.getCellValue(ri, ci)\n return String(raw == null ? '' : raw) === String(filterValue)\n }\n const display = this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri)\n return String(display == null ? '' : display).toLowerCase().indexOf(String(filterValue).toLowerCase()) !== -1\n },\n\n _recomputeRowFilters() {\n // Can be reached (via the `columns`/`filterRow` watchers) during initWatch(),\n // before created() has set up _dataRaw — getCellValue() depends on it. The\n // `data` watcher's own immediate handler sets _dataRaw first, then always\n // calls this again, so the initial mount still ends up correct.\n if (!this._dataRaw) return\n if (!this.filterRow) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const activeCols = this._activeFilterCols()\n if (!activeCols.length) {\n if (this.filteredRowIndices.length) this.filteredRowIndices = []\n return\n }\n const rows = this.data\n const out = []\n for (let ri = 0; ri < rows.length; ri++) {\n const row = rows[ri]\n let matches = true\n for (const col of activeCols) {\n if (!this._cellMatchesFilter(row, ri, col)) { matches = false; break }\n }\n if (!matches) out.push(ri)\n }\n this.filteredRowIndices = out\n },\n\n // =========================================================\n // COLUMN SORTING\n // =========================================================\n\n isColSortable(col) {\n return !!(this.sortable && col && col.sortable !== false && col.type !== 'action')\n },\n\n sortIconClass(col) {\n const active = !!(this.internalSort && this.internalSort.key === col.key)\n return active ? 'eg-icon-sort-menu eg-sort-icon-active' : 'eg-icon-sort-menu'\n },\n\n sortIconTitle() {\n return this.t.sortMenuTooltip\n },\n\n // Opens the sort popup menu (A → Z / Z → A / Cancel) anchored to the\n // clicked header icon. Replaces the old direct cycle-on-click behavior.\n openSortMenu(e, col) {\n if (this.internalLocked) return\n this.sortMenu = { visible: true, x: e.clientX, y: e.clientY, key: col.key }\n this.sortMenuPositioned = false\n this.$nextTick(this._positionSortMenu)\n },\n\n // Same flip-to-fit reasoning as _positionContextMenu().\n _positionSortMenu() {\n const el = this.$refs.sortMenuEl\n if (!el) return\n const rect = el.getBoundingClientRect()\n const margin = 4\n let x = this.sortMenu.x\n let y = this.sortMenu.y\n if (x + rect.width > window.innerWidth - margin) {\n x = Math.max(margin, window.innerWidth - rect.width - margin)\n }\n if (y + rect.height > window.innerHeight - margin) {\n y = Math.max(margin, window.innerHeight - rect.height - margin)\n }\n this.sortMenu.x = x\n this.sortMenu.y = y\n this.sortMenuPositioned = true\n },\n\n hideSortMenu() {\n this.sortMenu.visible = false\n },\n\n // Marks whichever direction is currently applied to the menu's own\n // column so the popup shows which option is active (checkmark, see CSS).\n isSortMenuOptionActive(direction) {\n return !!(\n this.internalSort &&\n this.sortMenu.key &&\n this.internalSort.key === this.sortMenu.key &&\n this.internalSort.direction === direction\n )\n },\n\n sortMenuPickAsc() {\n this.setSort(this.sortMenu.key, 'asc')\n this.hideSortMenu()\n },\n\n sortMenuPickDesc() {\n this.setSort(this.sortMenu.key, 'desc')\n this.hideSortMenu()\n },\n\n sortMenuPickClear() {\n this.clearSort()\n this.hideSortMenu()\n },\n\n // Cycles a column through none → asc → desc → none. Kept as a public\n // method for programmatic/custom #header-{key} slot use — the built-in\n // header icon now opens the popup menu (openSortMenu) instead.\n toggleSort(key) {\n if (this.internalLocked) return\n const current = this.internalSort\n if (!current || current.key !== key) { this.setSort(key, 'asc'); return }\n if (current.direction === 'asc') { this.setSort(key, 'desc'); return }\n this.clearSort()\n },\n\n setSort(key, direction) {\n if (direction !== 'asc' && direction !== 'desc') { this.clearSort(); return }\n this.sortByColumn(key, direction)\n },\n\n // Clicking a third time (or calling this directly) clears the sort\n // indicator AND restores the row order from immediately before the\n // first sort of this session (see _preSortData in sortByColumn()) —\n // switching columns or direction mid-session doesn't move that\n // reference point, so this always lands back on the true original\n // order. Emitted as a normal data change (_emitDataChange), so it's\n // itself undoable/redoable like any other edit.\n clearSort() {\n if (!this.internalSort) return\n this.internalSort = null\n const oldData = this.data\n const restored = this._preSortData\n this._preSortData = null\n // Row count mismatch means rows were added/removed since the sort —\n // the snapshot no longer lines up with the current data, so skip the\n // restore rather than silently drop/duplicate rows (indicator still\n // clears either way).\n if (restored && restored.length === oldData.length) {\n this._emitDataChange(oldData, restored)\n }\n this.$emit('update:sort', null)\n this.$emit('sort-change', { key: null, direction: null })\n },\n\n sortByColumn(key, direction) {\n if (this.internalLocked) return\n // Merges and row groups are anchored to row *position* — a reorder\n // would silently invalidate them, so sorting is blocked while either\n // exists (same \"blocked while locked\" no-op convention as elsewhere).\n if (this.internalMerges.length || this.rowGroups.length) return\n const col = this.leafCols.find(c => c.key === key)\n if (!this.isColSortable(col)) return\n\n const oldData = this.data\n // Remember the order from before the *first* sort of this session —\n // re-sorting the same or a different column (asc<->desc, or switching\n // columns) without clearing first must not move this reference point,\n // so clearSort() always lands back on the true original order.\n if (!this.internalSort) this._preSortData = oldData\n const pairs = oldData.map((row, ri) => ({ row, ri }))\n pairs.sort((a, b) => this._compareForSort(a.row, a.ri, b.row, b.ri, col, direction))\n const newData = pairs.map(p => p.row)\n\n this.internalSort = { key, direction }\n this._emitDataChange(oldData, newData)\n this.$emit('update:sort', { key, direction })\n this.$emit('sort-change', { key, direction })\n\n this.scrollTop = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) this.$refs.scrollBody.scrollTop = 0\n })\n },\n\n _sortValueFor(row, ri, col) {\n const ci = col._flatIndex\n if (col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? 1 : 0\n if (col.type === 'date') {\n const d = parseDateValue(this.getCellValue(ri, ci))\n return d ? d.getTime() : null\n }\n if (this.isSelectLikeCol(col)) return this.getSelectLabel(ci, this.getCellValue(ri, ci), ri)\n return this.getCellValue(ri, ci)\n },\n\n _compareForSort(rowA, riA, rowB, riB, col, direction) {\n if (typeof col.sortFn === 'function') {\n const result = col.sortFn(rowA, rowB, col)\n return direction === 'desc' ? -result : result\n }\n const a = this._sortValueFor(rowA, riA, col)\n const b = this._sortValueFor(rowB, riB, col)\n const aEmpty = a === null || a === undefined || a === ''\n const bEmpty = b === null || b === undefined || b === ''\n if (aEmpty && bEmpty) return 0\n if (aEmpty) return 1\n if (bEmpty) return -1\n const cmp = (typeof a === 'number' && typeof b === 'number')\n ? a - b\n : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })\n return direction === 'desc' ? -cmp : cmp\n },\n\n openDatePickerForActive() {\n const { row, col } = this.activeCell\n const colDef = this.leafCols[col]\n if (!colDef || colDef.type !== 'date') return\n this.$nextTick(() => {\n const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null\n if (!el) return\n const rect = el.getBoundingClientRect()\n const val = this.getCellValue(row, col)\n this.datePickerOverlay = {\n row, col,\n format: colDef.format || DEFAULT_DATE_FORMAT,\n value: val == null ? '' : String(val),\n rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }\n }\n })\n },\n\n onDatePick(formattedVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n const colDef = this.leafCols[col]\n if (colDef) {\n if (formattedVal !== '' && !this.isCellValueValid(row, col, formattedVal)) {\n this._showInvalidCell(row, col, formattedVal)\n this.closeDatePicker()\n return\n }\n this.invalidCell = null\n const oldData = this.data\n const newData = oldData.slice()\n newData[row] = { ...oldData[row], [colDef.key]: formattedVal }\n this._emitDataChange(oldData, newData)\n this._flushOnInput(row, col, formattedVal, newData[row])\n }\n this.closeDatePicker()\n },\n\n // Raw keystrokes while typing in the date picker's manual text field —\n // debounced, same as onEditInput(). onDatePick() above flushes the final,\n // validated value immediately when the date actually commits.\n onDateTyping(rawVal) {\n if (!this.datePickerOverlay) return\n const { row, col } = this.datePickerOverlay\n this._scheduleOnInput(row, col, rawVal)\n },\n\n // See closeSelectDropdown() — same reasoning: every close path must return\n // focus to the grid wrapper or keyboard navigation goes dead afterward.\n closeDatePicker() {\n clearTimeout(this._onInputTimer)\n this.datePickerOverlay = null\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n _onWindowScrollForDropdown(e) {\n if (this.contextMenu.visible) this.hideContextMenu()\n if (this.sortMenu.visible) this.hideSortMenu()\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n _onDocMouseDownForDropdown(e) {\n if (this.selectDropdown) {\n const comp = this.$refs.selectDropdownComp\n const dd = comp && comp.$el ? comp.$el : null\n if (!(dd && dd.contains(e.target))) this.closeSelectDropdown()\n }\n if (this.datePickerOverlay) {\n const dp = this.$refs.datePickerComp\n const el = dp && dp.$el ? dp.$el : null\n if (!(el && el.contains(e.target))) this.closeDatePicker()\n }\n },\n\n onEditKeyDown(e) {\n if (e.key === 'Escape') {\n e.preventDefault()\n this.cancelEdit()\n } else if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(1, 0, e)\n } else if (e.key === 'Tab') {\n e.preventDefault()\n this.commitEdit()\n this.moveActive(0, e.shiftKey ? -1 : 1, e)\n }\n },\n\n clearSelection() {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n let hasChanges = false\n const skipped = []\n for (const range of this.selectionRanges) {\n const r = {\n startRow: Math.min(range.startRow, range.endRow),\n endRow: Math.max(range.startRow, range.endRow),\n startCol: Math.min(range.startCol, range.endCol),\n endCol: Math.max(range.startCol, range.endCol)\n }\n for (let ri = r.startRow; ri <= r.endRow && ri < newData.length; ri++) {\n let rowUpdated = null\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n if (!this.isCellValueValid(ri, ci, '')) { skipped.push({ row: ri, col: ci, value: '' }); continue }\n if (!rowUpdated) rowUpdated = { ...newData[ri] }\n rowUpdated[col.key] = ''\n hasChanges = true\n }\n }\n if (rowUpdated) newData[ri] = rowUpdated\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n if (hasChanges) this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // FILL HANDLE (3.3)\n // =========================================================\n startFillDrag(e) {\n if (this.internalLocked) return\n const pr = this.primaryRange\n if (!pr) return\n this.fillDrag.active = true\n this.fillDrag.sourceRange = normalizeRange(pr)\n this.fillDrag.currentRow = pr.endRow\n this.fillDrag.currentCol = pr.endCol\n e.preventDefault()\n },\n\n onGlobalMouseMove(e) {\n // Detect column drag threshold (5 px)\n if (this.colDrag.pending) {\n if (Math.abs(e.clientX - this.colDrag.startX) > 5) {\n this.colDrag.pending = false\n this.colDrag.active = true\n this.colDrag.dropIndex = this.colDrag.sourceFirstLeaf\n this.isMouseDown = false // cancel cell-selection drag\n this._startColDrag()\n }\n }\n if (this.colDrag.active) {\n this.handleColDragMove(e)\n return\n }\n\n if (this.resizing.active) {\n this.handleResizeMove(e)\n return\n }\n if (this.fillDrag.active) {\n this.handleFillDragMove(e)\n return\n }\n if (this.dragMove.active) {\n this.handleDragMoveMove(e)\n return\n }\n },\n\n onGlobalMouseUp(e) {\n if (this.colDrag.active) {\n this.commitColDrag()\n return\n }\n if (this.colDrag.pending) {\n this._resetColDrag()\n // fall through — normal mouseup\n }\n\n if (this.resizing.active) {\n this.commitResize()\n return\n }\n if (this.fillDrag.active) {\n this.commitFillDrag()\n return\n }\n if (this.dragMove.active) {\n this.commitDragMove(e)\n return\n }\n this.isMouseDown = false\n },\n\n // Resolves which (row, col) the mouse is currently over, given the\n // scroll body's CURRENT scrollTop/scrollLeft — called both directly from\n // mousemove and repeatedly from the edge-auto-scroll rAF loop below (as\n // scrollTop changes on its own, the row under a STATIONARY cursor changes\n // with it).\n _fillDragTargetFromEvent(e) {\n const scrollBody = this.$refs.scrollBody\n if (!scrollBody) return null\n const rect = scrollBody.getBoundingClientRect()\n const relY = e.clientY - rect.top + scrollBody.scrollTop\n const relX = e.clientX - rect.left + scrollBody.scrollLeft\n\n // Binary search on cumulativeHeights — O(log n) instead of O(n)\n const cum = this.cumulativeHeights\n const total = this.totalRows\n let lo = 0, hi = total - 1\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if (cum[mid + 1] <= relY) lo = mid + 1\n else hi = mid\n }\n const row = Math.min(lo, total - 1)\n\n // Columns are few — O(columns) is fast\n let left = this.getFrozenWidth()\n let col = 0\n for (let i = 0; i < this.leafCols.length; i++) {\n if (!this.isColumnScrollable(i)) continue\n left += this.getColW(i)\n if (relX < left) { col = i; break }\n col = i\n }\n return { row, col }\n },\n\n handleFillDragMove(e) {\n this._fillDragLastEvent = e\n const target = this._fillDragTargetFromEvent(e)\n if (!target) return\n this.fillDrag.currentRow = target.row\n this.fillDrag.currentCol = target.col\n // Covers the common case of a single big mousemove jump landing the\n // cursor (and its computed target row/col) outside the visible window.\n this.scrollToCell(target.row, target.col)\n // Covers the — far more common in practice — case of the cursor\n // PINNED at the container's edge (it usually can't travel further,\n // since the grid typically fills its host layout down to that edge):\n // keep scrolling for as long as it stays there. Same rAF/edge-zone\n // pattern as _updateDragAutoScroll (column-drag reordering) below.\n this._updateFillDragAutoScroll(e.clientX, e.clientY)\n },\n\n // Edge auto-scroll for fill-handle dragging — vertical AND horizontal\n // (column-drag's _updateDragAutoScroll is horizontal-only). Speed scales\n // with how deep into the 24px edge zone the cursor sits.\n _updateFillDragAutoScroll(clientX, clientY) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._fillDragScrollV = 0; this._fillDragScrollH = 0; return }\n const rect = sb.getBoundingClientRect()\n const ZONE = FILL_DRAG_EDGE_ZONE\n\n let v = 0\n if (clientY < rect.top + ZONE) v = -Math.ceil((rect.top + ZONE - clientY) / 4)\n else if (clientY > rect.bottom - ZONE) v = Math.ceil((clientY - (rect.bottom - ZONE)) / 4)\n this._fillDragScrollV = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, v))\n\n let h = 0\n if (clientX < rect.left + ZONE) h = -Math.ceil((rect.left + ZONE - clientX) / 4)\n else if (clientX > rect.right - ZONE) h = Math.ceil((clientX - (rect.right - ZONE)) / 4)\n this._fillDragScrollH = Math.max(-FILL_DRAG_MAX_SPEED, Math.min(FILL_DRAG_MAX_SPEED, h))\n\n if ((this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) && !this._fillDragScrollRAF) {\n const step = () => {\n this._fillDragScrollRAF = null\n if (!this.fillDrag.active) return\n const body = this.$refs.scrollBody\n if (body) {\n if (this._fillDragScrollV) body.scrollTop = Math.max(0, Math.min(body.scrollHeight - body.clientHeight, body.scrollTop + this._fillDragScrollV))\n if (this._fillDragScrollH) body.scrollLeft = Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollLeft + this._fillDragScrollH))\n // The cursor itself hasn't moved, but the content under it has —\n // re-derive the fill target at the new scroll position.\n if (this._fillDragLastEvent) {\n const target = this._fillDragTargetFromEvent(this._fillDragLastEvent)\n if (target) { this.fillDrag.currentRow = target.row; this.fillDrag.currentCol = target.col }\n }\n }\n if (this._fillDragScrollV !== 0 || this._fillDragScrollH !== 0) this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n this._fillDragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopFillDragAutoScroll() {\n this._fillDragScrollV = 0\n this._fillDragScrollH = 0\n this._fillDragLastEvent = null\n if (this._fillDragScrollRAF) {\n cancelAnimationFrame(this._fillDragScrollRAF)\n this._fillDragScrollRAF = null\n }\n },\n\n async commitFillDrag() {\n this._stopFillDragAutoScroll()\n if (this.internalLocked) { this.fillDrag.active = false; return }\n const src = this.fillDrag.sourceRange\n if (!src) { this.fillDrag.active = false; return }\n\n const targetRow = this.fillDrag.currentRow\n const targetCol = this.fillDrag.currentCol\n\n const oldData = this.data\n const newData = oldData.slice() // only affected rows get new objects\n let targetRange\n const skipped = []\n\n if (targetRow > src.endRow) {\n // Fill down\n const count = targetRow - src.endRow\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n targetRange = { startRow: src.endRow + 1, endRow: targetRow, startCol: src.startCol, endCol: src.endCol }\n } else if (targetCol > src.endCol) {\n // Fill right\n const count = targetCol - src.endCol\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n const srcValues = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n srcValues.push(col && oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ci = src.endCol + 1 + k\n if (ci < this.leafCols.length) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n }\n targetRange = { startRow: src.startRow, endRow: src.endRow, startCol: src.endCol + 1, endCol: targetCol }\n }\n\n let fireOnSelect = false\n if (targetRange) {\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n } else {\n this._emitDataChange(oldData, newData)\n this.$emit('fill', { sourceRange: src, targetRange, values: newData })\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n fireOnSelect = true\n }\n }\n\n this.fillDrag.active = false\n this.fillDrag.sourceRange = null\n\n if (fireOnSelect) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n doubleFillDown() {\n const pr = this.primaryRange\n if (!pr) return\n const src = normalizeRange(pr)\n // Find adjacent column filled length\n const adjCol = src.startCol > 0 ? src.startCol - 1 : src.endCol + 1\n if (adjCol < 0 || adjCol >= this.leafCols.length) return\n const adjColDef = this.leafCols[adjCol]\n if (!adjColDef) return\n let lastFilledRow = src.endRow\n for (let ri = src.endRow + 1; ri < this.totalRows; ri++) {\n if (this.data[ri] && this.data[ri][adjColDef.key] !== '' && this.data[ri][adjColDef.key] !== undefined) {\n lastFilledRow = ri\n } else break\n }\n if (lastFilledRow <= src.endRow) return\n\n const count = lastFilledRow - src.endRow\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcValues = []\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')\n }\n const filled = generateFillValues(srcValues, count)\n for (let k = 0; k < count; k++) {\n const ri = src.endRow + 1 + k\n if (ri < this.totalRows && this.isCellEditable(ri, ci)) {\n const v = this._coerceCellValue(ci, this._applyColMask(ci, filled[k]))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n }\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n this._emitDataChange(oldData, newData)\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n },\n\n // =========================================================\n // Ctrl+D / Ctrl+R — Excel's standard fill-down/fill-right (3.96)\n // =========================================================\n // Plain copy, NOT the fill-handle's series continuation (generateFillValues,\n // 1,2,3->4,5,6) — matches real Excel: Ctrl+D/Ctrl+R just duplicates a row/\n // column's values, it never extrapolates a pattern.\n async _fillAxis(direction) {\n if (this.internalLocked) return\n if (!this.selectionRanges.length) return\n const oldData = this.data\n const newData = oldData.slice()\n const skipped = []\n const appliedRanges = []\n\n for (const range of this.selectionRanges) {\n const r = normalizeRange(range)\n if (direction === 'down') {\n let srcRow, firstTargetRow, lastTargetRow\n if (r.startRow === r.endRow) {\n // Single-row selection: Excel fills from the row directly above.\n srcRow = r.startRow - 1\n if (srcRow < 0) continue\n firstTargetRow = r.startRow\n lastTargetRow = r.startRow\n } else {\n srcRow = r.startRow\n firstTargetRow = r.startRow + 1\n lastTargetRow = r.endRow\n }\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const srcVal = oldData[srcRow] ? oldData[srcRow][col.key] : ''\n for (let ri = firstTargetRow; ri <= lastTargetRow; ri++) {\n if (!this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: srcRow, endRow: srcRow, startCol: r.startCol, endCol: r.endCol },\n targetRange: { startRow: firstTargetRow, endRow: lastTargetRow, startCol: r.startCol, endCol: r.endCol }\n })\n } else {\n let srcCol, firstTargetCol, lastTargetCol\n if (r.startCol === r.endCol) {\n // Single-column selection: Excel fills from the column directly to the left.\n srcCol = r.startCol - 1\n if (srcCol < 0) continue\n firstTargetCol = r.startCol\n lastTargetCol = r.startCol\n } else {\n srcCol = r.startCol\n firstTargetCol = r.startCol + 1\n lastTargetCol = r.endCol\n }\n const srcColDef = this.leafCols[srcCol]\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n const srcVal = srcColDef && oldData[ri] ? oldData[ri][srcColDef.key] : ''\n for (let ci = firstTargetCol; ci <= lastTargetCol; ci++) {\n const col = this.leafCols[ci]\n if (!col || !this.isCellEditable(ri, ci)) continue\n const v = this._coerceCellValue(ci, this._applyColMask(ci, srcVal))\n if (!this.isCellValueValid(ri, ci, v)) { skipped.push({ row: ri, col: ci, value: v }); continue }\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n newData[ri][col.key] = v\n }\n }\n appliedRanges.push({\n sourceRange: { startRow: r.startRow, endRow: r.endRow, startCol: srcCol, endCol: srcCol },\n targetRange: { startRow: r.startRow, endRow: r.endRow, startCol: firstTargetCol, endCol: lastTargetCol }\n })\n }\n }\n\n if (!appliedRanges.length) return\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n this._emitDataChange(oldData, newData)\n for (const ar of appliedRanges) {\n this.$emit('fill', { sourceRange: ar.sourceRange, targetRange: ar.targetRange, values: newData })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n\n // See pasteAt's/commitFillDrag's _fireCellCallbacksForRange call — same\n // nextTick deferral reasoning (col.onInput/onSelect after the DOM settles).\n await this.$nextTick()\n for (const ar of appliedRanges) {\n this._fireCellCallbacksForRange(oldData, newData, ar.targetRange.startRow, ar.targetRange.endRow, ar.targetRange.startCol, ar.targetRange.endCol)\n }\n },\n\n fillDown() { return this._fillAxis('down') },\n fillRight() { return this._fillAxis('right') },\n\n // =========================================================\n // COPY / PASTE (3.4)\n // =========================================================\n copySelection() {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n this.clipboardMatrix = extractMatrix(this.data, this.leafCols, r)\n this.clipboardRange = r\n this.clipboardMode = 'copy'\n\n // Write to system clipboard as TSV\n const tsv = matrixToTSV(this.clipboardMatrix)\n if (navigator.clipboard) {\n navigator.clipboard.writeText(tsv).catch(() => {})\n }\n },\n\n cutSelection() {\n this.copySelection()\n this.clipboardMode = 'cut'\n },\n\n // Bulk cell writes (paste, fill handle, drag-move) set row values directly\n // and skip the single-cell paths (onComponentSelect / commitEdit /\n // setCheckboxValue / onDatePick) — so col.onSelect / col.onInput never\n // fire for them unless we replay it here for every cell whose value\n // actually changed in the range. select/treeSelect columns get onSelect\n // (matching the single-pick shape); everything else gets onInput —\n // fired immediately, no debounce (this is already a single bulk commit,\n // not live keystrokes).\n _fireCellCallbacksForRange(oldData, newData, startRow, endRow, startCol, endCol) {\n let fired = false\n for (let ri = startRow; ri <= endRow; ri++) {\n const oldRow = oldData[ri]\n const newRow = newData[ri]\n if (!newRow || newRow === oldRow) continue\n for (let ci = startCol; ci <= endCol; ci++) {\n const col = this.leafCols[ci]\n if (!col) continue\n const oldVal = oldRow ? oldRow[col.key] : undefined\n const newVal = newRow[col.key]\n if (newVal === oldVal) continue\n if (this.isSelectLikeCol(col)) {\n if (typeof col.onSelect === 'function') {\n col.onSelect({ value: newVal, label: this.getSelectLabel(ci, newVal, ri) }, newRow, ri, col)\n fired = true\n }\n } else if (typeof col.onInput === 'function') {\n col.onInput(newVal, newRow, ri, col)\n fired = true\n }\n }\n }\n // See _scheduleOnInput — one forceUpdate covers the whole range instead of\n // one per cell (this can fire many callbacks for a large paste/fill range).\n if (fired) this.$forceUpdate()\n },\n\n async pasteAt(targetRow, targetCol, options = {}) {\n if (this.internalLocked) return\n const { mode = 'all', transpose = false } = options\n\n let matrix = null\n\n // Try system clipboard first\n if (navigator.clipboard) {\n try {\n const text = await navigator.clipboard.readText()\n if (text) matrix = tsvToMatrix(text)\n } catch (_) {}\n }\n\n // Fall back to internal clipboard\n if (!matrix && this.clipboardMatrix) {\n matrix = this.clipboardMatrix\n }\n\n if (!matrix) return\n\n if (transpose) matrix = transposeMatrix(matrix)\n\n // ── Excel-style selection broadcast / tiling ──────────────────────────\n // When pasting over a multi-cell selection and the clipboard matrix is\n // smaller than the selection, tile the matrix to fill the selection —\n // provided the selection dimensions are exact multiples of the matrix\n // dimensions (matches Excel's tiling rule exactly).\n // Cut-paste is never tiled (source must be cleared exactly once).\n const isCopyPaste = !this.clipboardMode || this.clipboardMode === 'copy'\n if (isCopyPaste) {\n const pr = this.primaryRange\n if (pr) {\n const sel = normalizeRange(pr)\n const selRows = sel.endRow - sel.startRow + 1\n const selCols = sel.endCol - sel.startCol + 1\n const matRows = matrix.length\n const matCols = matrix[0] ? matrix[0].length : 0\n\n // Only tile when selection is strictly larger than clipboard\n if ((selRows > matRows || selCols > matCols) &&\n selRows % matRows === 0 && selCols % matCols === 0) {\n matrix = tileMatrix(matrix, selRows, selCols)\n // Paste at selection origin, not just at the active cell\n targetRow = sel.startRow\n targetCol = sel.startCol\n }\n }\n }\n\n const oldData = this.data\n // pasteGrowsRows: pre-extend the working copy with blank rows so\n // applyMatrix's own data.length clamp naturally covers the full\n // pasted range instead of truncating it. `oldData` itself (used below\n // for _emitDataChange's undo/redo diff) stays untouched.\n let pasteBase = oldData\n let addedStartIndex = 0\n let addedCount = 0\n if (this.pasteGrowsRows) {\n const neededLength = targetRow + matrix.length\n if (neededLength > oldData.length) {\n const blankRows = []\n for (let i = oldData.length; i < neededLength; i++) {\n const row = {}\n this.leafCols.forEach(col => { row[col.key] = this._blankValueForCol(col) })\n if (this.pasteRowDefaults) {\n const extra = typeof this.pasteRowDefaults === 'function' ? this.pasteRowDefaults(i) : this.pasteRowDefaults\n if (extra) Object.assign(row, extra)\n }\n blankRows.push(row)\n }\n pasteBase = oldData.concat(blankRows)\n addedStartIndex = oldData.length\n addedCount = blankRows.length\n }\n }\n const { newData, affectedRange, skipped } = applyMatrix(\n pasteBase, this.leafCols, targetRow, targetCol, matrix, mode,\n (ri, ci) => this.isCellEditable(ri, ci),\n (ri, ci, v) => this.isCellValueValid(ri, ci, v),\n (ri, ci, v) => this._coerceCellValue(ci, this._applyColMask(ci, v))\n )\n\n if (this.strictBulkValidation && skipped.length > 0) {\n this._emitBulkValidationError(skipped, true)\n return\n }\n\n if (this.clipboardMode === 'cut' && this.clipboardRange) {\n // Clear source — ensure each source row is a new copy before mutation\n const src = this.clipboardRange\n for (let ri = src.startRow; ri <= src.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = src.startCol; ci <= src.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci) && this.isCellValueValid(ri, ci, '')) {\n newData[ri][col.key] = ''\n }\n }\n }\n this.clipboardRange = null\n this.clipboardMode = null\n }\n\n this._emitDataChange(oldData, newData)\n // Fired once per paste operation (never per row), after 'input' — so by\n // the time hosts receive it, v-model already holds the final rows.\n // Lets hosts do a single aggregate action instead of reacting to\n // pasteRowDefaults' per-row calls.\n if (addedCount > 0) {\n this.$emit('rows-added', {\n startIndex: addedStartIndex,\n count: addedCount,\n rows: newData.slice(addedStartIndex, addedStartIndex + addedCount)\n })\n }\n if (skipped.length > 0) this._emitBulkValidationError(skipped, false)\n // Wait for Vue's reactivity queue to flush before firing onSelect — hosts\n // commonly react to the emitted 'input' with their own watcher (e.g. to\n // mirror the row into an external store); firing onSelect synchronously\n // would run it against that store's pre-paste, stale state.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, affectedRange.startRow, affectedRange.endRow, affectedRange.startCol, affectedRange.endCol)\n },\n\n ctxCopy() { this.hideContextMenu(); this.copySelection() },\n ctxCut() { this.hideContextMenu(); this.cutSelection() },\n ctxPaste() { this.hideContextMenu(); this.pasteAt(this.activeCell.row, this.activeCell.col) },\n ctxPasteSpecial(mode, transpose = false) {\n this.hideContextMenu()\n this.pasteAt(this.activeCell.row, this.activeCell.col, { mode, transpose })\n },\n\n // =========================================================\n // COLUMN DRAG-AND-DROP\n // =========================================================\n\n _computeDragOffsets(dropIndex) {\n const { sourceFirstLeaf, sourceLeafCount } = this.colDrag\n if (dropIndex < 0 || (dropIndex >= sourceFirstLeaf && dropIndex <= sourceFirstLeaf + sourceLeafCount)) return {}\n\n const previewCols = moveColumnNodeInTree(this.columns, sourceFirstLeaf, sourceLeafCount, dropIndex)\n const previewLeaves = flattenColumns(previewCols)\n\n const byKey = {}\n this.leafCols.forEach(c => { byKey[c.key] = c })\n\n const offsets = {}\n for (const zone of ['left', 'scroll', 'right']) {\n const inZone = zone === 'left'\n ? ci => this.isColumnPinnedLeft(ci)\n : zone === 'right'\n ? ci => this.isColumnPinnedRight(ci)\n : ci => this.isColumnScrollable(ci)\n\n const origPos = {}\n let acc = 0\n this.leafCols.forEach(col => {\n const ci = col._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) return\n origPos[col.key] = acc\n acc += this.getColW(ci)\n })\n\n const previewPos = {}\n acc = 0\n for (const col of previewLeaves) {\n const origCol = byKey[col.key]\n if (!origCol) continue\n const ci = origCol._flatIndex\n if (this.isColHidden(ci) || !inZone(ci)) continue\n previewPos[col.key] = acc\n acc += this.getColW(ci)\n }\n\n for (const key of Object.keys(origPos)) {\n offsets[key] = (previewPos[key] !== undefined ? previewPos[key] : origPos[key]) - origPos[key]\n }\n }\n return offsets\n },\n\n // Called once when the 5px movement threshold flips the drag active:\n // snapshots the original header geometry the whole drag will be measured\n // against, sizes the ghost from the grabbed header, and arms the CSS\n // transitions on every column element.\n _startColDrag() {\n const sb = this.$refs.scrollBody\n this._dragStartScrollLeft = sb ? sb.scrollLeft : 0\n const bounds = []\n if (this.$el) {\n this.$el.querySelectorAll('.eg-header-cell:not(.eg-header-group)').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n if (isNaN(fi)) return\n const rect = el.getBoundingClientRect()\n bounds.push({ fi, left: rect.left, right: rect.right, scrollable: this.isColumnScrollable(fi) })\n })\n }\n this._dragBounds = bounds\n this._dragScrollV = 0\n\n const hr = this._pendingDragHeaderRect\n if (hr && hr.width > 0) {\n this.colDrag.ghostWidth = Math.min(Math.round(hr.width), 320)\n this.colDrag.grabOffsetX = Math.min(\n Math.max(Math.round(this.colDrag.startX - hr.left), 12),\n this.colDrag.ghostWidth - 12\n )\n } else {\n this.colDrag.ghostWidth = 140\n this.colDrag.grabOffsetX = 70\n }\n\n this._setDragTransitions(true)\n },\n\n _setDragTransitions(on) {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = on ? COL_DRAG_TRANSITION : ''\n })\n },\n\n _applyDragTransforms(offsets) {\n if (!this.$el) return\n // Just retarget the transforms — the CSS transition set at drag start\n // animates each element from wherever it currently is. An explicit\n // translateX(0px) (not '') keeps returning columns gliding back instead\n // of snapping.\n this.$el.querySelectorAll('[data-fi]').forEach(el => {\n const fi = parseInt(el.dataset.fi)\n const col = this.leafCols[fi]\n const dx = col ? (offsets[col.key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n this.$el.querySelectorAll('[data-start]').forEach(el => {\n const start = parseInt(el.dataset.start)\n const end = parseInt(el.dataset.end)\n if (isNaN(start)) return\n let key = null\n for (let i = start; i <= end; i++) {\n if (!this.isColHidden(i) && this.leafCols[i]) { key = this.leafCols[i].key; break }\n }\n const dx = key ? (offsets[key] || 0) : 0\n el.style.transform = 'translateX(' + dx + 'px)'\n })\n },\n\n // Instant cleanup: transition and transform drop in the same style write,\n // so the (now transition-less) transform change never animates.\n _clearDragTransforms() {\n if (!this.$el) return\n this.$el.querySelectorAll('[data-fi], [data-start]').forEach(el => {\n el.style.transition = ''\n el.style.transform = ''\n })\n },\n\n handleColDragMove(e) {\n // Ghost follows the cursor with the original grab point preserved —\n // direct DOM writes, no Vue re-render per mousemove.\n const ghost = this.$refs.colDragGhost\n if (ghost) {\n ghost.style.left = (e.clientX - this.colDrag.grabOffsetX) + 'px'\n ghost.style.top = (e.clientY - 14) + 'px'\n }\n this._lastDragClientX = e.clientX\n this._refreshDragPreview(e.clientX)\n this._updateDragAutoScroll(e.clientX)\n },\n\n _refreshDragPreview(clientX) {\n const prevDrop = this.colDrag.dropIndex\n this._updateColDragDropIndex(clientX)\n if (this.colDrag.dropIndex !== prevDrop) {\n this._dragOffsets = this._computeDragOffsets(this.colDrag.dropIndex)\n this._applyDragTransforms(this._dragOffsets)\n }\n },\n\n _updateColDragDropIndex(clientX) {\n const bounds = this._dragBounds\n if (!bounds || !bounds.length) { this.colDrag.dropIndex = this.leafCols.length; return }\n\n // Boundaries come from the drag-start snapshot, shifted by how far the\n // body has scrolled since (pinned zones don't move with the scroll).\n // Live DOM rects are never read — they carry in-flight preview\n // transforms whose intermediate values would corrupt the math.\n const sb = this.$refs.scrollBody\n const scrollDelta = sb ? sb.scrollLeft - this._dragStartScrollLeft : 0\n const adjusted = bounds\n .map(b => ({\n fi: b.fi,\n left: b.scrollable ? b.left - scrollDelta : b.left,\n right: b.scrollable ? b.right - scrollDelta : b.right\n }))\n .sort((a, b) => a.left - b.left)\n\n let dropIndex = adjusted[adjusted.length - 1].fi + 1\n for (const { fi, left, right } of adjusted) {\n if (clientX < right) {\n dropIndex = clientX < (left + right) / 2 ? fi : fi + 1\n break\n }\n }\n this.colDrag.dropIndex = dropIndex\n },\n\n // Edge auto-scroll: dragging near the left/right edge of the visible\n // scrollable window (the part not covered by sticky pinned zones) scrolls\n // the body horizontally, speed proportional to how deep into the 40px\n // edge zone the cursor sits — so far-away drop targets are reachable.\n _updateDragAutoScroll(clientX) {\n const sb = this.$refs.scrollBody\n if (!sb) { this._dragScrollV = 0; return }\n const rect = sb.getBoundingClientRect()\n const leftEdge = rect.left + this.getFrozenWidth()\n const rightEdge = rect.left + sb.clientWidth - (this.hasPinnedRight ? this.pinnedRightWidth : 0)\n const ZONE = 40\n let v = 0\n if (clientX < leftEdge + ZONE) v = -Math.ceil((leftEdge + ZONE - clientX) / 4)\n else if (clientX > rightEdge - ZONE) v = Math.ceil((clientX - (rightEdge - ZONE)) / 4)\n this._dragScrollV = Math.max(-20, Math.min(20, v))\n\n if (this._dragScrollV !== 0 && !this._dragScrollRAF) {\n const step = () => {\n this._dragScrollRAF = null\n if (!this.colDrag.active || this._dragScrollV === 0) return\n const body = this.$refs.scrollBody\n if (body) {\n const before = body.scrollLeft\n body.scrollLeft += this._dragScrollV\n // Re-derive the drop slot at the new scroll position even though\n // the cursor itself hasn't moved.\n if (body.scrollLeft !== before) this._refreshDragPreview(this._lastDragClientX)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n this._dragScrollRAF = requestAnimationFrame(step)\n }\n },\n\n _stopDragAutoScroll() {\n this._dragScrollV = 0\n if (this._dragScrollRAF) {\n cancelAnimationFrame(this._dragScrollRAF)\n this._dragScrollRAF = null\n }\n },\n\n commitColDrag() {\n const from = this.colDrag.sourceFirstLeaf\n const leafCount = this.colDrag.sourceLeafCount\n const to = this.colDrag.dropIndex\n\n if (from < 0 || to < 0 || leafCount < 1) { this._resetColDrag(true); return }\n if (to >= from && to <= from + leafCount) { this._resetColDrag(true); return }\n\n // KEEP the preview transforms in the DOM while Vue re-renders with the\n // new column order. After Vue updates left positions ($nextTick), we\n // instantly clear them — both steps run as microtasks before the browser\n // paints, so there is never a visible intermediate state.\n this._stopDragAutoScroll()\n clearTimeout(this._dragCleanupTimer)\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n\n const newCols = moveColumnNodeInTree(this.columns, from, leafCount, to)\n this.$emit('update:columns', newCols)\n this.$emit('column-reorder', { fromIndex: from, toIndex: to, columns: newCols })\n\n // Clear transforms after Vue re-renders new column left positions\n this.$nextTick(() => { this._clearDragTransforms() })\n },\n\n // animateBack: cancelled drags (Escape, drop on the source slot) glide the\n // columns back to their original places instead of snapping.\n _resetColDrag(animateBack = false) {\n const wasActive = this.colDrag.active\n this._stopDragAutoScroll()\n this._dragOffsets = {}\n this._dragBounds = null\n this.colDrag.pending = false\n this.colDrag.active = false\n this.colDrag.sourceFirstLeaf = -1\n this.colDrag.sourceLeafCount = 1\n this.colDrag.dropIndex = -1\n this.colDrag.ghostLabel = ''\n clearTimeout(this._dragCleanupTimer)\n if (animateBack && wasActive && this.$el) {\n this._applyDragTransforms({}) // everything retargets to translateX(0) with transitions still on\n this._dragCleanupTimer = setTimeout(() => this._clearDragTransforms(), 230)\n } else {\n this._clearDragTransforms()\n }\n },\n\n // =========================================================\n // RESIZE (3.5)\n // =========================================================\n startColResize(e, ci) {\n // Under fitColumns, resizing a column turns IT into a fixed px\n // override (see commitResize/_applyFitColumns) — the rest keep\n // scaling proportionally to fill what's left, so this can reintroduce\n // horizontal scroll. That's an accepted tradeoff for letting a column\n // be pinned to an exact width while fitColumns is on.\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'col',\n index: ci,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getColW(ci),\n currentPos: e.clientX,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n // Manual resize turns a relative-unit width ('20%', ...) into a fixed px\n // override — it should no longer rescale on window resize.\n this.$delete(this.relativeColSpecs, ci)\n e.preventDefault()\n },\n\n startRowResize(e, ri) {\n if (this.internalLocked) return\n const rect = this.$refs.container.getBoundingClientRect()\n this.resizing = {\n active: true,\n type: 'row',\n index: ri,\n startX: e.clientX,\n startY: e.clientY,\n startSize: this.getRowH(ri),\n currentPos: e.clientY,\n containerLeft: rect.left,\n containerTop: rect.top\n }\n e.preventDefault()\n },\n\n handleResizeMove(e) {\n if (this.resizing.type === 'col') {\n this.resizing.currentPos = e.clientX\n const newWidth = Math.max(20, this.resizing.startSize + (e.clientX - this.resizing.startX))\n this.$set(this.colWidths, this.resizing.index, newWidth)\n this._cellStyleCache = Object.create(null)\n } else {\n this.resizing.currentPos = e.clientY\n this._resizePreviewHeight = Math.max(10, this.resizing.startSize + (e.clientY - this.resizing.startY))\n // Batch $set + updateVirtualScroll to rAF — prevents O(n) cumulativeHeights rebuild\n // on every mousemove (200+ Hz). At most 60 rebuilds/sec instead of 200+.\n if (!this._resizeRAF) {\n this._resizeRAF = requestAnimationFrame(() => {\n this._resizeRAF = null\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n this.updateVirtualScroll()\n })\n }\n }\n },\n\n commitResize() {\n if (this._resizeRAF) {\n cancelAnimationFrame(this._resizeRAF)\n this._resizeRAF = null\n // Apply the last pending preview height immediately on commit\n if (this.resizing.type === 'row' && this._resizePreviewHeight !== undefined) {\n this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)\n }\n }\n if (this.resizing.type === 'col') {\n this._cellStyleCache = Object.create(null)\n const newSize = this.colWidths[this.resizing.index]\n if (this.fitColumns) {\n const col = this.leafCols[this.resizing.index]\n if (col) this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: this.resizing.index, newSize })\n } else {\n this._rowStyleCache = Object.create(null)\n this._rowHeaderStyleCache = Object.create(null)\n this._cellStyleCache = Object.create(null)\n const newSize = this.rowHeights[this.resizing.index]\n this.$emit('row-resize', { index: this.resizing.index, newSize })\n this.updateVirtualScroll()\n }\n this.resizing.active = false\n },\n\n // Double-click-the-header \"AutoFit column width\" — same idea as Excel/Sheets'\n // double-click-the-border shortcut. Under fitColumns, same override\n // treatment as a manual drag-resize (see commitResize/_applyFitColumns):\n // this column becomes a fixed px width excluded from the proportional\n // fit, the rest keep scaling in what's left.\n autoFitColumn(ci) {\n const col = this.leafCols[ci]\n if (!col) return\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n const newWidth = Math.max(50, maxLen * 8 + 16)\n this.$set(this.colWidths, ci, newWidth)\n this.$delete(this.relativeColSpecs, ci)\n this._cellStyleCache = Object.create(null)\n if (this.fitColumns) {\n this.$set(this.fitColumnsOverrides, col.key, true)\n this._applyFitColumns()\n }\n this.$emit('column-resize', { index: ci, newSize: newWidth })\n },\n\n _applyAutoWidth() {\n // fitColumns is the higher-priority width control — see its own JSDoc.\n if (!this.autoWidth || this.fitColumns || !this.leafCols.length) return\n this.leafCols.forEach((col, ci) => {\n let maxLen = (col.title || '').length\n for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {\n const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''\n if (v.length > maxLen) maxLen = v.length\n }\n this.$set(this.colWidths, ci, Math.max(50, maxLen * 8 + 16))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n // fitColumns prop — ignores col.width as an absolute value, using it only\n // as a relative WEIGHT so every visible leaf column (pinned or not)\n // scales by one shared factor to exactly fill _relativeWidthContainer\n // (the same circular-dependency-safe reference %/CSS-length widths\n // already resolve against — see its declaration comment). Mirrors\n // naturalGridWidth's formula in reverse: that computed derives the\n // grid's natural width FROM the column widths; this derives the column\n // widths FROM the available width.\n //\n // Columns in fitColumnsOverrides (manually resized while fitColumns was\n // on — see commitResize) are excluded from the scaling pool and keep\n // their current colWidths px value untouched; the remaining columns\n // scale to fill whatever space is left after subtracting the overrides'\n // total width, which can go negative (horizontal scroll returns) if the\n // overrides alone exceed the available width.\n _applyFitColumns() {\n if (!this.fitColumns || !this.leafCols.length) return\n const containerWidth = this._relativeWidthContainer\n if (!containerWidth) return // not measured yet — re-run once hostContainerWidth arrives\n const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0\n const available = containerWidth - ROW_HEADER_WIDTH - outlineW - this.vScrollbarW - this.shellBorderW\n if (available <= 0) return\n\n const visible = []\n let totalWeight = 0\n let overriddenWidth = 0\n this.leafCols.forEach((col, ci) => {\n if (this.isColHidden(ci)) return\n if (this.fitColumnsOverrides[col.key]) {\n const w = this.colWidths[ci] !== undefined ? this.colWidths[ci] : this.resolveColWidth(col.width, containerWidth)\n overriddenWidth += w\n return\n }\n const w = this.resolveColWidth(col.width, containerWidth)\n visible.push({ ci, weight: w })\n totalWeight += w\n })\n if (!visible.length || totalWeight <= 0) return\n\n const remaining = available - overriddenWidth\n const scale = remaining > 0 ? remaining / totalWeight : 0\n visible.forEach(({ ci, weight }) => {\n this.$set(this.colWidths, ci, Math.max(FIT_COLUMNS_MIN_WIDTH, weight * scale))\n this.$delete(this.relativeColSpecs, ci)\n })\n this._cellStyleCache = Object.create(null)\n },\n\n ctxHideRow() {\n const targetRow = this.contextMenu.row\n // Row header: row >= 0 AND col = -1 (no column context)\n const fromRowHeader = targetRow >= 0 && this.contextMenu.col < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenRows]\n if (fromRowHeader) {\n if (!newHidden.includes(targetRow)) newHidden.push(targetRow)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ri = r.startRow; ri <= r.endRow; ri++) {\n if (!newHidden.includes(ri)) newHidden.push(ri)\n }\n }\n this.$emit('update:hiddenRows', newHidden)\n },\n\n ctxHideColumn() {\n const targetCol = this.contextMenu.col\n // Column header: col >= 0 AND row = -1 (no row context)\n const fromColHeader = targetCol >= 0 && this.contextMenu.row < 0\n this.hideContextMenu()\n if (this.internalLocked) return\n const newHidden = [...this.hiddenColumns]\n if (fromColHeader) {\n if (!newHidden.includes(targetCol)) newHidden.push(targetCol)\n } else {\n const pr = this.primaryRange\n if (!pr) return\n const r = normalizeRange(pr)\n for (let ci = r.startCol; ci <= r.endCol; ci++) {\n if (!newHidden.includes(ci)) newHidden.push(ci)\n }\n }\n this.$emit('update:hiddenColumns', newHidden)\n },\n\n ctxUnhideRows() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenRows', [])\n },\n\n ctxUnhideColumns() {\n this.hideContextMenu()\n if (this.internalLocked) return\n this.$emit('update:hiddenColumns', [])\n },\n\n ctxPinLeft() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'left')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxPinRight() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$set(this.internalPinnedColumns, col.key, 'right')\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n ctxUnpin() {\n const col = this.leafCols[this.contextMenu.col]\n this.hideContextMenu()\n if (!col) return\n this.$delete(this.internalPinnedColumns, col.key)\n this.$emit('update:pinnedColumns', { ...this.internalPinnedColumns })\n },\n\n isColPinned(ci) {\n const col = this.leafCols[ci]\n return !!(col && this.internalPinnedColumns[col.key])\n },\n\n // =========================================================\n // DEV MODE — column/row config info (context menu → modal)\n // =========================================================\n _stringifyForInfo(value) {\n const seen = new WeakSet()\n return JSON.stringify(value, (key, val) => {\n if (typeof val === 'function') return 'ƒ ' + (val.name || 'anonymous') + '()'\n if (val && typeof val === 'object') {\n if (seen.has(val)) return '[Circular]'\n seen.add(val)\n }\n return val\n }, 2)\n },\n\n ctxShowColumnInfo() {\n const ci = this.contextMenu.col\n this.hideContextMenu()\n const col = this.leafCols[ci]\n if (!col) return\n const { _flatIndex, ...rest } = col\n this.infoModal = { title: `${this.t.columnInfo}: ${col.title || col.key}`, json: this._stringifyForInfo(rest) }\n },\n\n ctxShowRowInfo() {\n const ri = this.contextMenu.row\n this.hideContextMenu()\n const row = this._dataRaw[ri]\n if (!row) return\n this.infoModal = { title: `${this.t.rowInfo} #${ri + 1}`, json: this._stringifyForInfo(row) }\n },\n\n closeInfoModal() {\n this.infoModal = null\n },\n\n // =========================================================\n // MERGE CELLS (3.8)\n // =========================================================\n mergeCells(range) {\n if (this.internalLocked) return false\n const oldMerges = this.internalMerges.slice()\n const { merges: newMerges, conflict } = addMerge(range, this.internalMerges)\n if (conflict) {\n this.$emit('merge-error', { range, reason: 'Overlaps existing merge' })\n return false\n }\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n return true\n },\n\n unmergeCells(row, col) {\n if (this.internalLocked) return\n const oldMerges = this.internalMerges.slice()\n const newMerges = removeMergeAt(row, col, this.internalMerges)\n this.internalMerges = newMerges\n this._emitMergeChange(oldMerges, [...newMerges])\n },\n\n ctxMerge() {\n this.hideContextMenu()\n const pr = this.primaryRange\n if (!pr) return\n this.mergeCells(normalizeRange(pr))\n },\n\n ctxUnmerge() {\n this.hideContextMenu()\n this.unmergeCells(this.activeCell.row, this.activeCell.col)\n },\n\n // =========================================================\n // GROUPING (3.7)\n // =========================================================\n groupRows(startIndex, endIndex) {\n this.internalGroups = addGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n groupColumns(startIndex, endIndex) {\n this.internalGroups = addGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupRows(startIndex, endIndex) {\n this.internalGroups = removeGroup('row', startIndex, endIndex, this.internalGroups)\n },\n\n ungroupColumns(startIndex, endIndex) {\n this.internalGroups = removeGroup('column', startIndex, endIndex, this.internalGroups)\n },\n\n toggleGroup(group) {\n this.internalCollapsedGroups = toggleGroupCollapse(group, this.internalCollapsedGroups)\n this.$emit('update:collapsedGroups', [...this.internalCollapsedGroups])\n this.$emit('group-toggle', { group, collapsed: _isGroupCollapsed(group, this.internalCollapsedGroups) })\n this.updateVirtualScroll()\n },\n\n isGroupCollapsed(group) {\n return _isGroupCollapsed(group, this.internalCollapsedGroups)\n },\n\n expandAll() {\n this.internalCollapsedGroups = []\n this.$emit('update:collapsedGroups', [])\n this.updateVirtualScroll()\n },\n\n collapseAll() {\n this.internalCollapsedGroups = [...this.internalGroups]\n this.$emit('update:collapsedGroups', [...this.internalGroups])\n this.updateVirtualScroll()\n },\n\n // =========================================================\n // DRAG AND DROP (3.9)\n // =========================================================\n startDragMove(e, ri, ci) {\n const pr = this.primaryRange\n if (!pr) return\n this.dragMove = {\n active: true,\n sourceRange: normalizeRange(pr),\n startX: e.clientX,\n startY: e.clientY,\n targetRow: ri,\n targetCol: ci,\n copyMode: e.ctrlKey || e.metaKey\n }\n this.$emit('cell-drag-start', { sourceRange: this.dragMove.sourceRange })\n e.preventDefault()\n },\n\n handleDragMoveMove(e) {\n this.dragMove.copyMode = e.ctrlKey || e.metaKey\n this.$emit('cell-drag-move', {\n sourceRange: this.dragMove.sourceRange,\n targetRow: this.dragMove.targetRow,\n targetCol: this.dragMove.targetCol\n })\n },\n\n async commitDragMove(e) {\n if (!this.dragMove.active) return\n const { sourceRange, targetRow, targetCol, copyMode } = this.dragMove\n\n const targetRange = {\n startRow: targetRow,\n startCol: targetCol,\n endRow: targetRow + (sourceRange.endRow - sourceRange.startRow),\n endCol: targetCol + (sourceRange.endCol - sourceRange.startCol)\n }\n\n let proceed = true\n if (this.beforeDrop) {\n proceed = await Promise.resolve(this.beforeDrop(sourceRange, targetRange))\n }\n\n let oldData = null\n let newData = null\n if (proceed) {\n oldData = this.data\n const matrix = extractMatrix(oldData, this.leafCols, sourceRange)\n newData = applyMatrix(oldData, this.leafCols, targetRow, targetCol, matrix, 'all', (ri, ci) => this.isCellEditable(ri, ci)).newData\n\n if (!copyMode) {\n // Clear source — ensure each source row is a new copy before mutation\n for (let ri = sourceRange.startRow; ri <= sourceRange.endRow; ri++) {\n if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }\n for (let ci = sourceRange.startCol; ci <= sourceRange.endCol; ci++) {\n const col = this.leafCols[ci]\n if (col && this.isCellEditable(ri, ci)) newData[ri][col.key] = ''\n }\n }\n }\n this._emitDataChange(oldData, newData)\n this.$emit('cell-drop', { sourceRange, targetRange })\n }\n\n this.dragMove = { active: false, sourceRange: null, startX: 0, startY: 0, targetRow: -1, targetCol: -1, copyMode: false }\n this.isMouseDown = false\n\n if (proceed) {\n // See pasteAt's _fireCellCallbacksForRange call — same nextTick deferral reasoning.\n await this.$nextTick()\n this._fireCellCallbacksForRange(oldData, newData, targetRange.startRow, targetRange.endRow, targetRange.startCol, targetRange.endCol)\n }\n },\n\n // =========================================================\n // EXCEL EXPORT (3.11)\n // =========================================================\n async exportToExcel(options = {}) {\n const {\n filename = 'export.xlsx',\n sheetName = 'Sheet1',\n scope = 'all',\n includeHidden = false\n } = options\n\n this.$emit('export-start')\n this.exportState = { active: true }\n\n const workerPayload = {\n data: this.data,\n columns: this.columns,\n // width resolved to a concrete px number (getColW handles px/%/other CSS\n // units) — excelExport.js's char-width math needs a real number, and a\n // worker has no DOM to resolve a raw '20%'/'10em' spec itself.\n leafCols: this.leafCols.map((col, ci) => ({ ...col, width: this.getColW(ci) })),\n merges: this.internalMerges,\n hiddenCols: [...this.effectiveHiddenColSet],\n hiddenRows: [...this.hiddenRowSet],\n range: scope === 'selection' ? this.primaryRange : null,\n filename,\n sheetName,\n scope,\n includeHidden\n }\n\n try {\n const worker = createExcelExportWorker()\n if (worker) {\n await this._exportViaWorker(worker, workerPayload)\n } else {\n // Fallback: synchronous export on main thread\n _exportToExcel({\n ...workerPayload,\n hiddenColSet: this.effectiveHiddenColSet,\n hiddenRowSet: this.hiddenRowSet\n })\n }\n this.$emit('export-success')\n } catch (err) {\n this.$emit('export-error', err)\n } finally {\n this.exportState = { active: false }\n }\n },\n\n /**\n * Print all (visible) grid data as a plain, theme-independent HTML\n * table — not the styled data-grid itself. Honors hidden columns/rows,\n * merged cells, and footer totals, same sources `exportToExcel()` uses.\n * `type: 'action'` columns (buttons — add/remove row, etc.) are always\n * excluded: they're interactive UI, not printable data.\n */\n printGrid() {\n const rowIndices = []\n for (let ri = 0; ri < this.totalRows; ri++) {\n if (!this.hiddenRowSet.has(ri)) rowIndices.push(ri)\n }\n const printExcludedColSet = new Set(this.effectiveHiddenColSet)\n this.leafCols.forEach(col => { if (col.type === 'action') printExcludedColSet.add(col._flatIndex) })\n const html = buildPrintTableHtml({\n columns: this.columns,\n leafCols: this.leafCols,\n rowIndices,\n merges: this.internalMerges,\n hiddenColSet: printExcludedColSet,\n // Resolved px width per column (same getColW() exportToExcel() uses)\n // so print columns stay proportional to their on-screen width.\n colWidths: this.leafCols.map((col, ci) => this.getColW(ci)),\n getCellText: (ri, ci) => {\n const col = this.leafCols[ci]\n if (col && col.type === 'checkbox') return this.isCheckboxChecked(ri, ci) ? '✓' : ''\n return String(this.getCellDisplayValue(ci, this.getCellValue(ri, ci), ri))\n },\n showFooter: this.hasFooter,\n getFooterText: ci => this.getColumnTotalDisplay(ci),\n printTitle: this.printTitle\n })\n this._openPrintFrame(html)\n },\n\n // Hidden same-origin <iframe> instead of window.open(): can't be\n // popup-blocked, and works the same when the host app itself runs\n // inside an iframe. srcdoc's document is fully self-contained (its own\n // <style>, nothing inherited from the host page), so the printed table\n // never picks up the grid's current theme.\n _openPrintFrame(html) {\n const iframe = document.createElement('iframe')\n iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden;'\n document.body.appendChild(iframe)\n const cleanup = () => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe) }\n iframe.onload = () => {\n const win = iframe.contentWindow\n win.addEventListener('afterprint', cleanup)\n win.focus()\n win.print()\n // Fallback in case `afterprint` never fires (observed on some\n // browsers when the print dialog is dismissed a certain way).\n setTimeout(cleanup, 2000)\n }\n iframe.srcdoc = html\n },\n\n _exportViaWorker(worker, payload) {\n return new Promise((resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, buffer, filename, message } = e.data\n if (type === 'result') {\n // Trigger browser download from the main thread (DOM required)\n const blob = new Blob([new Uint8Array(buffer)], {\n type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n a.href = url\n a.download = filename\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n URL.revokeObjectURL(url)\n finish(resolve)\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Export worker xatosi'))\n\n worker.postMessage(payload)\n })\n },\n\n // =========================================================\n // EXCEL IMPORT (3.12)\n // =========================================================\n async importFromFile(file, options = {}) {\n this.importState = { active: true, percent: 5, message: \"Fayl o'qilmoqda...\" }\n\n try {\n let result\n const worker = createExcelImportWorker()\n\n if (worker) {\n result = await this._importViaWorker(worker, file, options)\n } else {\n // Fallback: chunked async on main thread\n result = await _importFromFile(\n file,\n this.columns,\n options,\n (percent, message) => { this.importState = { active: true, percent, message } }\n )\n }\n\n const { columns, merges } = result\n // Freeze each row so Vue 2 skips deep Object.defineProperty observation.\n // Vue 2 checks Object.isExtensible() — frozen objects are skipped entirely.\n // All edit paths already use spread ({ ...row }) so frozen rows are safe.\n const data = result.data.map(row => Object.freeze(row))\n this.$emit('input', data)\n this._scheduleBufferWrite(data)\n this.$emit('update:columns', columns)\n\n const hasMerges = merges && merges.length > 0\n this.internalMerges = hasMerges ? merges : []\n this.$emit('update:merges', hasMerges ? merges : [])\n this.$emit('update:collapsedGroups', [])\n this.$emit('update:hiddenRows', [])\n this.$emit('import-success', { data, columns, merges })\n\n // Reset scroll state and re-derive virtual scroll after DOM settles.\n // Without this, stale scrollTop + old rowHeights cause blank bands on scroll.\n this.rowHeights = {}\n this.scrollTop = 0\n this.scrollLeft = 0\n this.$nextTick(() => {\n if (this.$refs.scrollBody) {\n this.$refs.scrollBody.scrollTop = 0\n this.$refs.scrollBody.scrollLeft = 0\n }\n if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = 0\n if (this.$refs.scrollFooter) this.$refs.scrollFooter.scrollLeft = 0\n if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = 0\n this.updateVirtualScroll()\n })\n } catch (err) {\n this.$emit('import-error', err)\n } finally {\n this.importState = { active: false, percent: 0, message: '' }\n }\n },\n\n _importViaWorker(worker, file, options) {\n return new Promise(async (resolve, reject) => {\n let done = false\n const finish = (fn, val) => {\n if (done) return\n done = true\n worker.terminate()\n fn(val)\n }\n\n worker.onmessage = (e) => {\n const { type, percent, message, data, columns, merges } = e.data\n if (type === 'progress') {\n this.importState = { active: true, percent, message }\n } else if (type === 'result') {\n finish(resolve, { data, columns, merges })\n } else if (type === 'error') {\n finish(reject, new Error(message))\n }\n }\n\n worker.onerror = (err) => finish(reject, new Error(err.message || 'Import worker xatosi'))\n\n try {\n // Transfer the ArrayBuffer zero-copy into the worker\n const buffer = await file.arrayBuffer()\n worker.postMessage(\n { buffer, existingColumns: this.columns, options },\n [buffer]\n )\n } catch (err) {\n finish(reject, err)\n }\n })\n },\n\n // =========================================================\n // UNDO / REDO (3.13)\n // =========================================================\n\n // Push one entry onto the non-reactive history stack.\n // Truncates the redo tail, caps at 100 entries.\n _pushHistory(entry) {\n if (this._historyIndex < this._history.length - 1) {\n this._history.splice(this._historyIndex + 1)\n }\n this._history.push(entry)\n if (this._history.length > 100) {\n this._history.shift()\n // historyIndex stays the same — it now points to the new last entry\n } else {\n this._historyIndex++\n }\n this.historyVersion++\n },\n\n // Emit 'input' and record a data diff entry (only changed row indices).\n // Use slice() + per-row spread in callers so unchanged rows keep the\n // same reference — then the identity check here catches only real changes.\n _emitDataChange(oldData, newData) {\n if (!this._historySkip) {\n const indices = []\n const beforeRows = []\n const afterRows = []\n const len = Math.max(oldData.length, newData.length)\n for (let i = 0; i < len; i++) {\n if (oldData[i] !== newData[i]) {\n indices.push(i)\n beforeRows.push(oldData[i])\n afterRows.push(newData[i])\n }\n }\n if (indices.length > 0) {\n // rowCount lets undo() truncate the array back down when this change\n // added rows (e.g. pasteGrowsRows) — a no-op for every change that\n // doesn't touch row count, since rowCount then matches on both sides.\n this._pushHistory({\n type: 'data',\n before: { indices, rows: beforeRows, rowCount: oldData.length },\n after: { indices, rows: afterRows, rowCount: newData.length }\n })\n }\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n },\n\n // Emit 'update:merges' and record a merges diff entry.\n _emitMergeChange(oldMerges, newMerges) {\n if (!this._historySkip) {\n this._pushHistory({\n type: 'merges',\n before: oldMerges,\n after: newMerges\n })\n }\n this.$emit('update:merges', newMerges)\n },\n\n undo() {\n if (this.internalLocked) return\n if (this._historyIndex < 0) return\n const entry = this._history[this._historyIndex--]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.before.indices.length; i++) {\n newData[entry.before.indices[i]] = entry.before.rows[i]\n }\n // Assigning `undefined` to a trailing index doesn't shrink the\n // array — truncate explicitly when this entry added rows (e.g.\n // pasteGrowsRows), so undo actually removes them instead of\n // leaving `undefined` rows at the tail.\n if (entry.before.rowCount != null && entry.before.rowCount < newData.length) {\n newData.length = entry.before.rowCount\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.before.slice()\n this.$emit('update:merges', entry.before.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n redo() {\n if (this.internalLocked) return\n if (this._historyIndex >= this._history.length - 1) return\n const entry = this._history[++this._historyIndex]\n this._historySkip = true\n try {\n if (entry.type === 'data') {\n const newData = this.data.slice()\n for (let i = 0; i < entry.after.indices.length; i++) {\n newData[entry.after.indices[i]] = entry.after.rows[i]\n }\n this.$emit('input', newData)\n this._scheduleBufferWrite(newData)\n } else if (entry.type === 'merges') {\n this.internalMerges = entry.after.slice()\n this.$emit('update:merges', entry.after.slice())\n }\n } finally {\n this._historySkip = false\n this.historyVersion++\n }\n },\n\n // =========================================================\n // TOOLBAR (fullscreen / lock / export / import / custom buttons)\n // =========================================================\n\n // Public, ref-callable: returns keyboard focus to the grid. Meant for\n // hosts whose action-cell `handler` opens a modal/popup outside the\n // grid's control — call this once that modal closes, otherwise the\n // browser leaves focus on document.body (its subtree was removed) and\n // arrow-key navigation goes dead until a cell is clicked again.\n focus() {\n this.$refs.wrapper && this.$refs.wrapper.focus()\n },\n\n // Public, ref-callable: formats a number with a custom thousands\n // separator (decimal point stays '.', no forced/padded decimals). Any\n // value that isn't a finite number — already-formatted text like\n // \"6 000 000,00\", arbitrary strings, etc. — is returned unchanged, so\n // hosts can call this on raw cell values without checking the type first.\n formatNumber(value, separator) {\n return _formatNumber(value, separator)\n },\n\n // =========================================================\n // LOCAL SYNC — save `data` as a local IndexedDB *version*, keyed by\n // localSyncKey (optionally namespaced by tenant), only when the page is\n // being left (not per-edit).\n // =========================================================\n\n // Shared by the localSyncKey and tenant watchers above — either one\n // changing means effectiveSyncKey now points at a (possibly) different\n // version history, so the badge/version-list state must be reloaded\n // from scratch rather than left stale.\n _reloadLocalSyncForKeyChange() {\n this.localSyncAvailable = false\n if (this.effectiveSyncKey) this._loadLocalVersionsOnMount()\n },\n\n // Internal, called once from mounted(): populates the toolbar \"unseen\n // local versions\" dot and emits local-sync-available if any versions\n // already exist for this key (e.g. saved in an earlier visit).\n async _loadLocalVersionsOnMount() {\n if (!this.localSyncKey) return\n await this._promoteBufferIfAny()\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n if (!versions.length) return\n this.localSyncAvailable = true\n this.$emit('local-sync-available', {\n key: this.localSyncKey,\n versions: versions.length,\n mostRecentUpdatedAt: versions[0].updatedAt\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n }\n },\n\n // Debounced (800ms), called from the grid's own real-change points\n // (_emitDataChange, import, undo, redo — the exact same \"user worked\"\n // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,\n // for the same reason spelled out on the localSyncKey prop's JSDoc: a\n // host loading rows from an API must never look like a user edit).\n //\n // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no\n // hash, no history) at a moment with no teardown race at all — the user\n // is actively still on the page. This is what actually fixes reload/\n // close data loss: a version-store write ATTEMPTED at the leave-signal\n // has no guarantee of finishing before an actual page teardown (verified\n // directly — even a trivial, unrelated IndexedDB write failed to survive\n // a real page.reload() in Chromium), but a buffer write that already\n // completed minutes or seconds earlier, during ordinary editing, needs no\n // such guarantee — it's already durably there. _promoteBufferIfAny()\n // turns it into a real version next time the grid mounts.\n _scheduleBufferWrite(data) {\n if (!this.localSyncKey) return\n const key = this.effectiveSyncKey // captured now, not re-read 800ms later —\n // a host that swaps localSyncKey/tenant mid-edit (rare, but now\n // reactive, see the watchers below) must not have this land under the\n // NEW key with the OLD key's data.\n clearTimeout(this._bufferWriteTimer)\n this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)\n },\n\n async _writeBufferNow(key, data) {\n if (!key) return\n try {\n await putBuffer(key, data)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Called once from _loadLocalVersionsOnMount(), before this key's version\n // list is read — folds a leftover buffer (from an edit that was saved\n // during the PREVIOUS visit but never made it into 'versions' — the\n // reload/close case this whole mechanism exists for) into a real version.\n // No time pressure here, so this uses the Worker-backed hashRowsAsync()\n // like any other non-leave-signal path.\n async _promoteBufferIfAny() {\n try {\n const buffered = await getBuffer(this.effectiveSyncKey)\n if (!buffered) return\n const hash = await hashRowsAsync(buffered.data)\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, buffered.data, hash, this.localSyncMaxVersions\n )\n await clearBuffer(this.effectiveSyncKey)\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: buffered.data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the\n // passive leave-triggers can't afford a Worker round-trip.\n _onVisibilityChangeForLocalSync() {\n if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)\n },\n\n _onPageHideForLocalSync() {\n this._flushLocalVersionOnHide(false, true)\n },\n\n // The core save-on-leave path — replaces a per-edit autosave (see the\n // localSyncKey prop's JSDoc for why: an autosave tied to \"data changed\"\n // can't distinguish a user's edit from a host loading rows from an API,\n // and would silently overwrite a user's saved work the moment a host\n // does the latter). Triggered from mounted()'s visibilitychange/pagehide\n // listeners and beforeDestroy() — see those for the three trigger points.\n //\n // Content-hashes the current `data` and either touches an existing local\n // version with the same hash (just refreshes its timestamp — \"ustiga\n // yoziladi, alohida versiya qilinmaydi\") or creates a brand-new one,\n // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's\n // saveOrTouchVersion()). Never throws — IndexedDB failures become\n // local-sync-error.\n //\n // `force` skips the \"nothing changed since the last flush\" short-circuit\n // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)\n // fire often on an idle tab and must NOT wake IndexedDB for no reason, so\n // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,\n // host/modal-initiated \"checkpoint now\" action — passes force so it never\n // silently does nothing: even unchanged data still gets its existing\n // version's timestamp refreshed via the same hash-dedup path, rather than\n // the caller seeing no effect at all.\n //\n // `sync` picks hashRows() (main thread) over the Worker-based\n // hashRowsAsync(). This was a real, observed bug, not a theoretical one:\n // a Worker round-trip (postMessage out, worker computes, postMessage\n // back) is one more hop through the event loop than a page that's\n // actually being torn down (as opposed to merely hidden — e.g. a tab\n // switch, where the page stays alive indefinitely and the extra hop\n // costs nothing) can be relied on to survive. visibilitychange/pagehide/\n // beforeDestroy all fire in exactly that \"might be torn down any moment\"\n // window, so they hash synchronously to remove that hop — the whole\n // flush then only has the unavoidable async part (the IndexedDB write\n // itself) left, giving it the best realistic chance to land before\n // teardown. saveLocalVersionNow() (an explicit host/modal action with no\n // teardown racing it) keeps the Worker, so hashing a large `data` array\n // doesn't block the main thread during ordinary use.\n async _flushLocalVersionOnHide(force = false, sync = false) {\n if (!this.localSyncKey) return\n if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush\n const data = this.data\n // Fires independently of the version-save attempt below, started as\n // early as possible — see _scheduleBufferWrite()'s comment. This is\n // the save that's actually likely to survive an imminent teardown;\n // the version-store write below is best-effort on top of it, reliable\n // for the common case (tab switch/background, page stays alive).\n clearTimeout(this._bufferWriteTimer)\n this._writeBufferNow(this.effectiveSyncKey, data)\n try {\n const hash = sync ? hashRows(data) : await hashRowsAsync(data)\n if (this.data !== data) return // data changed again mid-hash — a later flush will catch it\n const { version, overwritten } = await saveOrTouchVersion(\n this.effectiveSyncKey, data, hash, this.localSyncMaxVersions\n )\n this._lastFlushedDataRef = data\n // The version store now has this content durably — the buffer's job\n // for it is done. Best-effort like everything else here: if this\n // doesn't complete before teardown, _promoteBufferIfAny() finds the\n // (now-redundant but harmless, dedup makes it a no-op touch) buffer\n // on next mount anyway.\n clearBuffer(this.effectiveSyncKey).catch(() => {})\n this.$emit('local-sync-save', {\n key: this.localSyncKey,\n id: version.id,\n rows: data.length,\n updatedAt: version.updatedAt,\n overwritten\n })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'save', error })\n }\n },\n\n // Public, ref-callable: runs the save-on-leave flow immediately, for a\n // host that wants its own explicit \"save checkpoint\" button instead of\n // waiting for a page-hide. Always goes through IndexedDB (force — see\n // _flushLocalVersionOnHide()), but still through the same hash-dedup\n // logic, so repeated calls with unchanged data don't spam duplicate\n // versions — they just refresh the existing matching version.\n async saveLocalVersionNow() {\n await this._flushLocalVersionOnHide(true)\n },\n\n // Public, ref-callable: lightweight metadata for every saved version of\n // this localSyncKey, newest first — no row data (use getLocalVersion(id)\n // for that). Powers the built-in modal's list; also usable to build a\n // custom UI.\n async listLocalVersions() {\n if (!this.localSyncKey) return []\n try {\n const versions = await listVersions(this.effectiveSyncKey)\n return versions.map(v => ({ id: v.id, createdAt: v.createdAt, updatedAt: v.updatedAt, rowCount: v.data.length }))\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return []\n }\n },\n\n // Public, ref-callable: the full { id, data, createdAt, updatedAt } for\n // one saved version, WITHOUT applying it to v-model — the pure \"Read\"\n // for a custom UI (as opposed to restoreLocalVersion(), which reads AND\n // commits in one step).\n async getLocalVersion(id) {\n try {\n return await getVersion(id)\n } catch (error) {\n this.$emit('local-sync-error', { action: 'load', error })\n return null\n }\n },\n\n // Public, ref-callable: writes one saved version back into v-model\n // (input/update:data) — the modal's \"Restore\" button calls this too.\n async restoreLocalVersion(id) {\n try {\n const version = await this.getLocalVersion(id)\n if (!version) return\n this.$emit('input', version.data)\n this.$emit('update:data', version.data)\n this._lastFlushedDataRef = version.data // what's now on screen is already what's stored\n this.localSyncAvailable = false\n this.$emit('local-sync-restore', { key: this.localSyncKey, id, rows: version.data.length })\n this.closeLocalSyncModal()\n } catch (error) {\n this.$emit('local-sync-error', { action: 'restore', error })\n }\n },\n\n // Public, ref-callable: deletes one saved version — the modal's \"Delete\"\n // button calls this too (after its own inline confirm step).\n async deleteLocalVersion(id) {\n try {\n await deleteVersion(id)\n if (this.localSyncModal.selectedId === id) {\n this.localSyncModal.selectedId = null\n this.localSyncModal.selectedVersion = null\n }\n this.localSyncModal.versions = this.localSyncModal.versions.filter(v => v.id !== id)\n this.$emit('local-sync-clear', { key: this.localSyncKey, id })\n } catch (error) {\n this.$emit('local-sync-error', { action: 'clear', error })\n }\n },\n\n // Public, ref-callable: opens the local-sync modal (also reachable via\n // the toolbar button), loading the version list fresh each time.\n async openLocalSyncModal() {\n if (!this.localSyncKey) return\n this.localSyncModal.visible = true\n this.localSyncAvailable = false // the version(s) have now been seen\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Internal: modal's list-selection handler — loads the selected\n // version's full data for the nested-grid preview (EgLocalSyncModal.vue).\n async _selectLocalSyncModalVersion(id) {\n this.localSyncModal.selectedId = id\n this.localSyncModal.selectedVersion = id ? await this.getLocalVersion(id) : null\n },\n\n // Modal's \"Save\" button — same as saveLocalVersionNow(), then refreshes\n // the modal's own list/selection in place so the user sees it happened.\n async _onLocalSyncModalSave() {\n await this.saveLocalVersionNow()\n const versions = await this.listLocalVersions()\n this.localSyncModal.versions = versions\n await this._selectLocalSyncModalVersion(versions.length ? versions[0].id : null)\n },\n\n // Modal's \"Delete\" button (after its own inline confirm) — deletes the\n // given version, then selects the next-most-recent one if the deleted\n // one was selected.\n async _onLocalSyncModalDelete(id) {\n await this.deleteLocalVersion(id)\n if (this.localSyncModal.selectedId == null) {\n const remaining = this.localSyncModal.versions\n await this._selectLocalSyncModalVersion(remaining.length ? remaining[0].id : null)\n }\n },\n\n // Public, ref-callable.\n closeLocalSyncModal() {\n this.localSyncModal.visible = false\n },\n\n toggleLock() {\n this.internalLocked = !this.internalLocked\n this.$emit('update:locked', this.internalLocked)\n this.$emit('lock-change', { locked: this.internalLocked })\n this._flashLock(this.internalLocked)\n },\n\n // Pops a big lock/unlock icon in the center of the grid and fades it\n // out — pure visual feedback, mirrors the video-player OSD pattern.\n // Triggered by the internalLocked watcher, so it covers every path that\n // can change the lock state (toolbar click, toggleLock(), :locked.sync).\n _flashLock(locked) {\n this.lockFlashLocked = locked\n this.lockFlashVisible = true\n this.$nextTick(() => {\n const el = this.$refs.lockFlashIconEl\n if (!el) return\n anime.remove(el)\n anime.set(el, { opacity: 0, scale: 0.5 })\n anime.timeline()\n .add({ targets: el, opacity: 1, scale: 1.12, duration: 180, easing: 'easeOutBack' })\n .add({ targets: el, scale: 1, duration: 120, easing: 'easeOutQuad' })\n .add({ targets: el, opacity: 0, duration: 350, easing: 'easeInQuad' }, '+=250')\n .finished.then(() => { this.lockFlashVisible = false })\n })\n },\n\n toolbarHas(key) {\n return this._toolbarList.indexOf(key) !== -1\n },\n\n toolbarDisabled(key) {\n return this._toolbarDisabledList.indexOf(key) !== -1\n },\n\n setSize(newSize) {\n if (!SIZE_CONFIG[newSize] || newSize === this.internalSize) return\n this.internalSize = newSize\n this.$emit('update:size', newSize)\n this.$emit('size-change', { size: newSize })\n },\n\n setTheme(newTheme) {\n if (THEME_LIST.indexOf(newTheme) === -1 || newTheme === this.internalTheme) return\n this.internalTheme = newTheme\n this.$emit('update:theme', newTheme)\n this.$emit('theme-change', { theme: newTheme })\n },\n\n sizeTitle(key) {\n return { small: this.t.sizeSmall, middle: this.t.sizeMiddle, large: this.t.sizeLarge }[key] || key\n },\n\n toggleFullscreen() {\n this.isFullscreen = !this.isFullscreen\n if (this.isFullscreen) {\n this._prevBodyOverflow = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n document.addEventListener('keydown', this._onFullscreenKeyDown)\n } else {\n document.body.style.overflow = this._prevBodyOverflow || ''\n document.removeEventListener('keydown', this._onFullscreenKeyDown)\n }\n this.$emit('fullscreen-change', { fullscreen: this.isFullscreen })\n this.$nextTick(() => this.updateVirtualScroll())\n },\n\n _onFullscreenKeyDown(e) {\n if (e.key === 'Escape' && this.isFullscreen) this.toggleFullscreen()\n },\n\n toolbarExportClick() {\n this.exportToExcel()\n },\n\n toolbarImportClick() {\n if (this.internalLocked) return\n const input = this.$refs.toolbarImportInput\n if (input) input.click()\n },\n\n onToolbarImportFile(e) {\n const file = e.target.files && e.target.files[0]\n e.target.value = ''\n if (!file || this.internalLocked) return\n this.importFromFile(file)\n },\n\n onToolbarButtonClick(btn, event) {\n if (typeof btn.onClick === 'function') btn.onClick(event, this)\n }\n }\n}\n\nexport default ExcelGridComponent\n</script>\n\n<style scoped>\n.eg-shell {\n /* ── Design tokens (Excel 365 defaults) — override on :root or any ancestor of <excel-grid> ──\n Full reference: docs/theming.md\n */\n /* Tokens are defined on this element and cascade to all children */\n\n /* --- Typography --- */\n --eg-font-family-default: 'Calibri', 'Segoe UI', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 11px;\n\n /* --- Shape --- */\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 3px;\n\n /* --- Accent (Excel green) --- */\n --eg-color-accent-default: #217346;\n --eg-color-accent-dark-default: #185a30;\n --eg-color-accent-soft-default: rgba(33, 115, 70, 0.15);\n\n /* --- Error / Validation --- */\n --eg-color-error-default: #c00000;\n --eg-color-error-soft-default: #fff2f2;\n --eg-color-error-dark-default: #9b0000;\n\n /* --- Borders --- */\n --eg-color-border-default: #d9d9d9;\n --eg-color-border-cell-default: #d9d9d9;\n --eg-color-border-strong-default: #bfbfbf;\n\n /* --- Header --- */\n --eg-color-header-bg-default: #f2f2f2;\n --eg-color-header-group-bg-default: #f2f2f2;\n --eg-color-group-border-default: #92d050;\n --eg-color-group-bg-collapsed-default: #70ad47;\n\n /* --- Cell states --- */\n --eg-color-cell-bg-default: #ffffff;\n --eg-color-cell-selected-default: #bdd7ee;\n --eg-color-cell-fill-preview-default: #9bc2e6;\n --eg-color-cell-readonly-default: #f2f2f2;\n --eg-color-cell-readonly-selected-default: #a9c9e4;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #f2f2f2;\n\n /* --- Text --- */\n --eg-color-text-default: #000000;\n --eg-color-text-secondary-default: #595959;\n --eg-color-text-muted-default: #767676;\n\n /* --- Shadows --- */\n --eg-shadow-dropdown-default: 1px 2px 8px rgba(0, 0, 0, 0.18);\n --eg-shadow-dialog-default: 0 2px 12px rgba(0, 0, 0, 0.22);\n --eg-shadow-tooltip-default: 0 1px 4px rgba(0, 0, 0, 0.18);\n --eg-color-freeze-shadow-default: rgba(0, 0, 0, 0.12);\n\n /* --- Overlay & dropdown options --- */\n --eg-color-overlay-bg-default: rgba(255, 255, 255, 0.88);\n --eg-color-option-hover-bg-default: #e2efda;\n --eg-color-option-active-hover-bg-default: #c6e0b4;\n\n /* --- Checkbox --- */\n --eg-color-checkbox-border-default: #767676;\n --eg-color-checkbox-disabled-checked-default: #bfbfbf;\n\n /* --- Row effects (off by default) --- */\n --eg-color-row-hover-default: transparent;\n --eg-color-row-stripe-default: transparent;\n\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n}\n\n.eg-shell-fullscreen {\n position: fixed;\n inset: 0;\n z-index: 9999;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n display: flex;\n flex-direction: column;\n border: none;\n border-radius: 0;\n overflow: hidden;\n}\n\n/* ── Material Design 3 — ko'k-ranglantirilgan header, 8px radius ── */\n.eg-shell.eg-theme-material {\n --eg-font-family-default: 'Roboto', 'Google Sans', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 8px;\n --eg-cell-padding-x-default: 6px;\n\n --eg-color-accent-default: #1565C0;\n --eg-color-accent-dark-default: #0D47A1;\n --eg-color-accent-soft-default: #DBEAFE;\n\n --eg-color-error-default: #D32F2F;\n --eg-color-error-soft-default: #FFEBEE;\n --eg-color-error-dark-default: #B71C1C;\n\n /* Ko'k chegaralar — Excel'dan aniq farqli */\n --eg-color-border-default: #90CAF9;\n --eg-color-border-cell-default: #BBDEFB;\n --eg-color-border-strong-default: #64B5F6;\n\n /* Ko'k-ranglantirilgan header — eng ko'zga ko'rinuvchi farq */\n --eg-color-header-bg-default: #DBEAFE;\n --eg-color-header-group-bg-default: #BFDBFE;\n --eg-color-group-border-default: #1565C0;\n --eg-color-group-bg-collapsed-default: #0D47A1;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #BFDBFE;\n --eg-color-cell-fill-preview-default: #93C5FD;\n --eg-color-cell-readonly-default: #EFF6FF;\n --eg-color-cell-readonly-selected-default: #DBEAFE;\n --eg-color-cell-readonly-pattern-default: rgba(21, 101, 192, 0.08);\n --eg-color-footer-bg-default: #EFF6FF;\n\n --eg-color-text-default: #1E3A5F;\n --eg-color-text-secondary-default: #4B7BA8;\n --eg-color-text-muted-default: #93C5FD;\n\n --eg-shadow-dropdown-default: 0 4px 12px rgba(21,101,192,0.20), 0 1px 4px rgba(21,101,192,0.12);\n --eg-shadow-dialog-default: 0 6px 24px rgba(21,101,192,0.18);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(21,101,192,0.20);\n --eg-color-freeze-shadow-default: rgba(21,101,192,0.20);\n\n --eg-color-overlay-bg-default: rgba(239,246,255,0.94);\n --eg-color-option-hover-bg-default: #EFF6FF;\n --eg-color-option-active-hover-bg-default: #DBEAFE;\n\n --eg-color-checkbox-border-default: #64B5F6;\n --eg-color-checkbox-disabled-checked-default: #BBDEFB;\n\n /* Ko'k hover va stripe */\n --eg-color-row-hover-default: #EFF6FF;\n --eg-color-row-stripe-default: #F0F7FF;\n}\n\n/* ── Ant Design 5 — minimal, toza, 6px radius ─────────────────── */\n.eg-shell.eg-theme-antd {\n --eg-font-family-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 6px;\n --eg-cell-padding-x-default: 8px;\n\n --eg-color-accent-default: #1677FF;\n --eg-color-accent-dark-default: #0958D9;\n --eg-color-accent-soft-default: #E6F4FF;\n\n --eg-color-error-default: #FF4D4F;\n --eg-color-error-soft-default: #FFF2F0;\n --eg-color-error-dark-default: #CF1322;\n\n /* Ant: ingichka kulrang chegaralar */\n --eg-color-border-default: #D9D9D9;\n --eg-color-border-cell-default: #F0F0F0;\n --eg-color-border-strong-default: #BFBFBF;\n\n /* Ant header: mo'tadil ko'k-kulrang, zamonaviy jadval uslubi */\n --eg-color-header-bg-default: #F0F5FF;\n --eg-color-header-group-bg-default: #E6EEFF;\n --eg-color-group-border-default: #1677FF;\n --eg-color-group-bg-collapsed-default: #0958D9;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #E6F4FF;\n --eg-color-cell-fill-preview-default: #BAE0FF;\n --eg-color-cell-readonly-default: #FAFAFA;\n --eg-color-cell-readonly-selected-default: #D6EEFF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.06);\n --eg-color-footer-bg-default: #F0F5FF;\n\n --eg-color-text-default: rgba(0,0,0,0.88);\n --eg-color-text-secondary-default: rgba(0,0,0,0.45);\n --eg-color-text-muted-default: rgba(0,0,0,0.25);\n\n --eg-shadow-dropdown-default: 0 6px 16px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.12), 0 9px 28px 8px rgba(0,0,0,0.05);\n --eg-shadow-dialog-default: 0 6px 16px rgba(0,0,0,0.08);\n --eg-shadow-tooltip-default: 0 2px 8px rgba(0,0,0,0.15);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.10);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #E6F4FF;\n --eg-color-option-active-hover-bg-default: #BAE0FF;\n\n --eg-color-checkbox-border-default: #D9D9D9;\n --eg-color-checkbox-disabled-checked-default: #BFBFBF;\n\n /* Ant hover: ko'zga ko'rinuvchi ko'k tint */\n --eg-color-row-hover-default: #E6F4FF;\n --eg-color-row-stripe-default: #FAFBFF;\n}\n\n/* ── Fluent UI / Office 365 — iliq kulrang, 2px radius ─────────── */\n.eg-shell.eg-theme-fluent {\n --eg-font-family-default: 'Segoe UI', Calibri, Arial, sans-serif;\n --eg-font-size-default: 13px;\n --eg-font-size-header-default: 11px;\n --eg-radius-default: 2px;\n --eg-cell-padding-x-default: 5px;\n\n --eg-color-accent-default: #0078D4;\n --eg-color-accent-dark-default: #005EA6;\n --eg-color-accent-soft-default: #EBF3FB;\n\n --eg-color-error-default: #C50F1F;\n --eg-color-error-soft-default: #FDE7E9;\n --eg-color-error-dark-default: #A80000;\n\n /* Fluent: iliq kulrang chegaralar */\n --eg-color-border-default: #D2D0CE;\n --eg-color-border-cell-default: #EDEBE9;\n --eg-color-border-strong-default: #B3B0AD;\n\n /* Fluent header: iliq kulrang (Office 365 uslubi) */\n --eg-color-header-bg-default: #E9E7E4;\n --eg-color-header-group-bg-default: #DDD9D4;\n --eg-color-group-border-default: #0078D4;\n --eg-color-group-bg-collapsed-default: #005EA6;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #DEECF9;\n --eg-color-cell-fill-preview-default: #C7E0F4;\n --eg-color-cell-readonly-default: #F3F2F1;\n --eg-color-cell-readonly-selected-default: #D0E8F8;\n --eg-color-cell-readonly-pattern-default: rgba(50, 49, 48, 0.06);\n --eg-color-footer-bg-default: #E9E7E4;\n\n --eg-color-text-default: #323130;\n --eg-color-text-secondary-default: #605E5C;\n --eg-color-text-muted-default: #A19F9D;\n\n --eg-shadow-dropdown-default: 0 3.2px 7.2px rgba(0,0,0,0.132), 0 0.6px 1.8px rgba(0,0,0,0.108);\n --eg-shadow-dialog-default: 0 8px 16px rgba(0,0,0,0.14);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.12);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.14);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.92);\n --eg-color-option-hover-bg-default: #F3F2F1;\n --eg-color-option-active-hover-bg-default: #DEECF9;\n\n --eg-color-checkbox-border-default: #8A8886;\n --eg-color-checkbox-disabled-checked-default: #C8C6C4;\n\n /* Fluent: iliq hover */\n --eg-color-row-hover-default: #F3F2F1;\n --eg-color-row-stripe-default: #FAF9F8;\n}\n\n/* ── IBM Carbon — to'q kulrang header, zebra stripe, 0px radius ── */\n.eg-shell.eg-theme-carbon {\n --eg-font-family-default: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;\n --eg-font-size-default: 14px;\n --eg-font-size-header-default: 12px;\n --eg-radius-default: 0px;\n --eg-cell-padding-x-default: 16px;\n\n --eg-color-accent-default: #0F62FE;\n --eg-color-accent-dark-default: #0043CE;\n --eg-color-accent-soft-default: #D0E2FF;\n\n --eg-color-error-default: #DA1E28;\n --eg-color-error-soft-default: #FFF1F1;\n --eg-color-error-dark-default: #A2191F;\n\n /* Carbon: og'ir kulrang chegaralar (Gray-20 / Gray-50) */\n --eg-color-border-default: #C6C6C6;\n --eg-color-border-cell-default: #E0E0E0;\n --eg-color-border-strong-default: #8D8D8D;\n\n /* Carbon: to'q kulrang header — Excel'dan eng aniq farq */\n --eg-color-header-bg-default: #C1C7CD;\n --eg-color-header-group-bg-default: #A2A9B0;\n --eg-color-group-border-default: #0F62FE;\n --eg-color-group-bg-collapsed-default: #0043CE;\n\n --eg-color-cell-bg-default: #FFFFFF;\n --eg-color-cell-selected-default: #D0E2FF;\n --eg-color-cell-fill-preview-default: #A6C8FF;\n --eg-color-cell-readonly-default: #F4F4F4;\n --eg-color-cell-readonly-selected-default: #BDD6FF;\n --eg-color-cell-readonly-pattern-default: rgba(0, 0, 0, 0.08);\n --eg-color-footer-bg-default: #C1C7CD;\n\n --eg-color-text-default: #161616;\n --eg-color-text-secondary-default: #525252;\n --eg-color-text-muted-default: #8D8D8D;\n\n --eg-shadow-dropdown-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-dialog-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-shadow-tooltip-default: 0 2px 6px rgba(0,0,0,0.30);\n --eg-color-freeze-shadow-default: rgba(0,0,0,0.25);\n\n --eg-color-overlay-bg-default: rgba(255,255,255,0.88);\n --eg-color-option-hover-bg-default: #E8E8E8;\n --eg-color-option-active-hover-bg-default: #D0E2FF;\n\n --eg-color-checkbox-border-default: #8D8D8D;\n --eg-color-checkbox-disabled-checked-default: #C6C6C6;\n\n /* Carbon: ko'zga ko'rinuvchi zebra stripe */\n --eg-color-row-hover-default: #E8E8E8;\n --eg-color-row-stripe-default: #F4F4F4;\n}\n\n.eg-wrapper {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n box-sizing: border-box;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-toolbar {\n display: flex;\n align-items: center;\n gap: 4px;\n flex-shrink: 0;\n height: 36px;\n padding: 0 8px;\n box-sizing: border-box;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-group {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 4px;\n}\n\n.eg-toolbar-group + .eg-toolbar-group {\n border-left: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-spacer {\n flex: 1 1 auto;\n}\n\n.eg-toolbar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n padding: 0;\n border: 1px solid transparent;\n border-radius: 3px;\n background: transparent;\n cursor: pointer;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n}\n\n/* Anchors the \"unseen local draft\" dot (localSyncKey feature) — only the\n local-sync button carries it, but kept as its own class so any future\n badged tool gets the same positioning. */\n.eg-toolbar-btn-badged {\n position: relative;\n}\n.eg-toolbar-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n}\n.eg-toolbar-btn:hover:not(:disabled) {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n border-color: var(--eg-color-border, var(--eg-color-border-default));\n}\n\n.eg-toolbar-btn:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-btn-active {\n background: var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n color: var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n}\n\n.eg-toolbar-select {\n height: 26px;\n padding: 0 22px 0 8px;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 3px;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n /* Stroke-based chevron (round caps/joins) instead of a solid triangle, to\n match the rounded-line language the rest of the toolbar icons use. */\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 7px center;\n background-size: 9px 6px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n font-size: 12px;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n cursor: pointer;\n appearance: none;\n -webkit-appearance: none;\n outline: none;\n}\n\n.eg-toolbar-select:hover:not(:disabled) {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n.eg-toolbar-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n\n.eg-toolbar-select:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.eg-toolbar-segmented {\n display: flex;\n align-items: center;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: 4px;\n overflow: hidden;\n padding: 1px;\n gap: 1px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n}\n\n.eg-toolbar-segment {\n border-radius: 2px;\n}\n\n/* Higher specificity than .eg-size-{x} .eg-toolbar-icon so each segment keeps its\n own progressively-sized icon regardless of the grid's current size variant. */\n.eg-toolbar-segmented .eg-toolbar-segment-small .eg-toolbar-icon { width: 12px; height: 12px; }\n.eg-toolbar-segmented .eg-toolbar-segment-middle .eg-toolbar-icon { width: 15px; height: 15px; }\n.eg-toolbar-segmented .eg-toolbar-segment-large .eg-toolbar-icon { width: 18px; height: 18px; }\n\n.eg-toolbar-icon {\n display: inline-block;\n width: 15px;\n height: 15px;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n}\n\n.eg-icon-undo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 15 4 10 9 5'/%3E%3Cpath d='M4 10h11a5 5 0 0 1 5 5'/%3E%3C/svg%3E\"); }\n.eg-icon-redo { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 15 20 10 15 5'/%3E%3Cpath d='M20 10H9a5 5 0 0 0-5 5'/%3E%3C/svg%3E\"); }\n/* Cloud-upload / cloud-download — replaces the old \"document with arrow\" glyphs\n so Export/Import read as one coherent rounded-line icon family with the\n rest of the toolbar. */\n.eg-icon-export { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 18a4.6 4.4 0 0 1 0-9a5 4.5 0 0 1 11 2h1a3.5 3.5 0 0 1 0 7h-1'/%3E%3Cpath d='M9 15l3-3 3 3'/%3E%3Cpath d='M12 12v9'/%3E%3C/svg%3E\"); }\n.eg-icon-import { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 18a3.5 3.5 0 0 0 0-7h-1a5 4.5 0 0 0-11-2a4.6 4.4 0 0 0-1 9'/%3E%3Cpath d='M9 16l3 3 3-3'/%3E%3Cpath d='M12 10v9'/%3E%3C/svg%3E\"); }\n.eg-icon-print { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 6 2 18 2 18 9'/%3E%3Cpath d='M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2'/%3E%3Crect x='6' y='14' width='12' height='8'/%3E%3C/svg%3E\"); }\n.eg-icon-local-sync { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cellipse cx='12' cy='5' rx='9' ry='3'/%3E%3Cpath d='M3 5v14a9 3 0 0 0 18 0V5'/%3E%3Cpath d='M3 12a9 3 0 0 0 18 0'/%3E%3C/svg%3E\"); }\n.eg-icon-lock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E\"); }\n.eg-icon-unlock { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='9' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 9.5-2'/%3E%3C/svg%3E\"); }\n/* Maximize / minimize — two diagonal corner arrows instead of four separate\n brackets, matching the export/import arrows' visual weight. */\n.eg-icon-expand { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 4h5v5'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M9 20H4v-5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n.eg-icon-compress{ mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 9h-5V4'/%3E%3Cpath d='M20 4l-7 7'/%3E%3Cpath d='M4 15h5v5'/%3E%3Cpath d='M4 20l7-7'/%3E%3C/svg%3E\"); }\n\n/* Sort icon (header) — \"more actions\" trigger (kebab dots) that opens the sort\n popup menu; faint until hover/active, accent-colored while this column is\n the active sort (see .eg-sort-icon-active below). Same glyph in every\n state — direction is shown inside the popup menu, not on the icon. */\n.eg-icon-sort-menu { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E\"); }\n\n/* Size segmented control — each icon shows row density as three stroked lines\n (round caps, same visual language as the rest of the toolbar now — thin +\n tight spacing → small, thick + wide spacing → large), replacing the old\n filled-rect bars. */\n.eg-icon-size-small { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round'%3E%3Cpath d='M4 7h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 17h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-middle { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4 6h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 18h16'/%3E%3C/svg%3E\"); }\n.eg-icon-size-large { mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M4 5h16'/%3E%3Cpath d='M4 12h16'/%3E%3Cpath d='M4 19h16'/%3E%3C/svg%3E\"); }\n\n.eg-container {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: auto;\n position: relative;\n}\n\n.eg-header-area {\n display: flex;\n flex-shrink: 0;\n box-shadow: 0 2px 0 0 var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: relative;\n z-index: 31;\n}\n\n.eg-header-cell {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n position: absolute;\n user-select: none;\n cursor: pointer;\n}\n\n.eg-header-cell:hover { background: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-header-group { background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default)); }\n\n.eg-sort-icon {\n display: inline-block;\n width: 11px;\n height: 11px;\n margin-left: 4px;\n vertical-align: middle;\n flex-shrink: 0;\n background-color: currentColor;\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n cursor: pointer;\n opacity: 0.3;\n}\n.eg-header-cell:hover .eg-sort-icon { opacity: 0.65; }\n.eg-sort-icon:hover { opacity: 1; }\n.eg-sort-icon.eg-sort-icon-active {\n opacity: 1;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n\n/* ===== FILTER ROW ===== */\n.eg-filter-cell {\n box-sizing: border-box;\n padding: 3px 4px;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n position: absolute;\n display: flex;\n align-items: center;\n}\n.eg-filter-cell-empty { background: var(--eg-color-header-bg, var(--eg-color-header-bg-default)); }\n\n.eg-filter-input,\n.eg-filter-select {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n font-weight: 400;\n padding: 0 6px;\n outline: none;\n}\n.eg-filter-input:focus,\n.eg-filter-select:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-filter-select {\n appearance: none;\n -webkit-appearance: none;\n cursor: pointer;\n padding-right: 20px;\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 6px center;\n background-size: 8px 5px;\n}\n\n.eg-col-resize-handle {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 5px;\n cursor: col-resize;\n z-index: 5;\n}\n\n.eg-body-area {\n display: flex;\n flex: 1;\n overflow: hidden;\n}\n\n/* ===== FOOTER AREA (column totals) ===== */\n.eg-frozen-col-footer,\n.eg-scroll-col-footer,\n.eg-pinned-right-footer {\n position: relative;\n}\n\n.eg-footer-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-footer-bg, var(--eg-color-footer-bg-default));\n display: flex;\n align-items: center;\n justify-content: flex-end;\n}\n\n/* Cells that actually show a computed total (col.showTotal / totalConfig.visible)\n stand out from the rest of the (otherwise mostly-empty) footer row. */\n.eg-footer-cell-total {\n font-weight: 700;\n}\n\n.eg-row {\n position: relative;\n}\n\n.eg-cell {\n box-sizing: border-box;\n border-right: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n overflow: hidden;\n white-space: nowrap;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 0 var(--eg-cell-padding-x, var(--eg-cell-padding-x-default));\n position: absolute;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: default;\n display: flex;\n align-items: center;\n}\n\n.eg-locked .eg-cell { cursor: not-allowed; }\n\n.eg-cell-value {\n flex: 1;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n min-width: 0;\n}\n.eg-cell-align-right { justify-content: flex-end; }\n.eg-cell-align-center { justify-content: center; }\n/* .eg-cell-value is flex:1 (v1.1.126, to shrink/ellipsize correctly), so it\n always fills the cell's full width — .eg-cell-align-*'s justify-content\n above has no leftover space left to act on and never visibly moves it.\n Same root cause .eg-cell-action-wrap already works around below; apply the\n same fix here via text-align on the value span itself. */\n.eg-cell-align-right .eg-cell-value { text-align: right; }\n.eg-cell-align-center .eg-cell-value { text-align: center; }\n.eg-cell--has-suffix.eg-cell-select::after { display: none; }\n.eg-cell-select-trigger-wrap {\n flex: 1;\n display: flex;\n align-items: center;\n overflow: hidden;\n min-width: 0;\n}\n.eg-cell-select-caret-inline {\n flex-shrink: 0;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n margin-left: 2px;\n}\n.eg-cell-suffix-wrap {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n}\n.eg-cell-active {\n outline: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n outline-offset: -2px;\n z-index: 3;\n}\n\n.eg-cell-selected {\n background: var(--eg-color-cell-selected, var(--eg-color-cell-selected-default)) !important;\n}\n\n/* Outer-perimeter border for a multi-cell selection/paste range — .eg-cell\n already has box-sizing:border-box and its own border-right/border-bottom\n grid lines, so these directional overrides don't shift cell layout. */\n.eg-cell-range-top {\n border-top: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-bottom {\n border-bottom: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-left {\n border-left: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n.eg-cell-range-right {\n border-right: 2px solid var(--eg-color-accent, var(--eg-color-accent-default)) !important;\n}\n\n.eg-cell-fill-preview {\n background: var(--eg-color-cell-fill-preview, var(--eg-color-cell-fill-preview-default)) !important;\n outline: 1px dashed var(--eg-color-accent-dark, var(--eg-color-accent-dark-default));\n outline-offset: -1px;\n}\n\n.eg-cell-copy {\n outline: 1px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n\n.eg-cell-merged-hidden {\n display: none !important;\n}\n\n.eg-cell-invalid {\n outline: 2px solid var(--eg-color-error, var(--eg-color-error-default)) !important;\n outline-offset: -2px;\n z-index: 4;\n}\n\n.eg-cell-input {\n width: 100%;\n height: 100%;\n border: none;\n outline: none;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n padding: 0;\n}\n\n.eg-row-header-cell {\n box-sizing: border-box;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-right: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n text-align: center;\n font-size: 11px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n cursor: pointer;\n user-select: none;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-row-resize-handle {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 4px;\n cursor: row-resize;\n z-index: 5;\n}\n\n.eg-fill-handle {\n position: absolute;\n width: 6px;\n height: 6px;\n background: var(--eg-color-accent, var(--eg-color-accent-default));\n border: 1px solid white;\n cursor: crosshair;\n z-index: 10;\n}\n\n.eg-context-menu {\n position: fixed;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n z-index: 9999;\n min-width: 160px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 4px 0;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Only 3 short items (\"A → Z\" / \"Z → A\" / \"Clear sort\") — the generic\n 160px context-menu min-width looks oversized next to it, especially\n at size=\"small\" where rows/text are much smaller. */\n.eg-sort-menu {\n min-width: 110px;\n}\n\n.eg-context-item {\n padding: 6px 16px;\n cursor: pointer;\n position: relative;\n white-space: nowrap;\n}\n\n.eg-context-item:hover {\n background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n/* Sort popup: marks whichever direction is currently applied (see\n isSortMenuOptionActive()) with a checkmark + accent color/weight. */\n.eg-context-item-active {\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-context-item-active::before {\n content: '✓';\n position: absolute;\n left: 4px;\n top: 50%;\n transform: translateY(-50%);\n}\n\n.eg-context-separator {\n height: 1px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n margin: 4px 0;\n}\n\n.eg-context-submenu-parent::after { content: ' ▶'; float: right; }\n\n.eg-context-submenu {\n display: none;\n position: absolute;\n left: 100%;\n top: -4px;\n background: var(--eg-color-menu-bg, var(--eg-color-cell-bg, var(--eg-color-cell-bg-default)));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-top: 3px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n min-width: 160px;\n padding: 4px 0;\n z-index: 10000;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-context-submenu-parent:hover .eg-context-submenu { display: block; }\n\n/* devMode — column/row info modal */\n.eg-info-modal-backdrop {\n position: fixed;\n inset: 0;\n z-index: 10001;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.eg-info-modal {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n width: 90%;\n max-width: 640px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n font-family: var(--eg-font-family, var(--eg-font-family-default));\n}\n\n.eg-info-modal-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n flex-shrink: 0;\n}\n\n.eg-info-modal-title {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n\n.eg-info-modal-close {\n background: none;\n border: none;\n cursor: pointer;\n font-size: 18px;\n line-height: 1;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n padding: 2px 6px;\n}\n\n.eg-info-modal-close:hover { color: var(--eg-color-text, var(--eg-color-text-default)); }\n\n.eg-info-modal-body {\n margin: 0;\n padding: 14px;\n overflow: auto;\n flex: 1;\n font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.eg-group-btn {\n position: absolute;\n width: 16px;\n height: 16px;\n line-height: 14px;\n text-align: center;\n font-size: 10px;\n cursor: pointer;\n background: var(--eg-color-header-group-bg, var(--eg-color-header-group-bg-default));\n border: 1px solid var(--eg-color-group-border, var(--eg-color-group-border-default));\n border-radius: 2px;\n z-index: 5;\n user-select: none;\n}\n\n.eg-group-btn.collapsed { background: var(--eg-color-group-bg-collapsed, var(--eg-color-group-bg-collapsed-default)); }\n\n.eg-drag-ghost {\n position: absolute;\n border: 2px dashed var(--eg-color-accent, var(--eg-color-accent-default));\n pointer-events: none;\n z-index: 20;\n}\n\n/* Column drag-and-drop */\n.eg-header-cell--drag-source {\n opacity: 0.5;\n outline: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n outline-offset: -1px;\n}\n.eg-cell--drag-source {\n opacity: 0.4;\n}\n/* Header-shaped drag ghost: mirrors the grabbed header's width/height (set\n inline), so the user drags something that looks like the actual column\n header, AG Grid-style. */\n.eg-col-drag-ghost {\n position: fixed;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: 0 10px;\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n border: 1px solid var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);\n font-size: var(--eg-font-size-header, var(--eg-font-size-header-default));\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n z-index: 9999;\n opacity: 0.9;\n}\n\n/* While a column drag is in flight, everything under the cursor shows the\n grabbing cursor instead of per-cell cursors. */\n.eg-container.eg-col-dragging,\n.eg-container.eg-col-dragging * {\n cursor: grabbing !important;\n}\n\n.eg-resize-line {\n pointer-events: none;\n z-index: 9999;\n}\n\n/* Pin left/right separator shadow — only visible while there's actually scrolled-away\n content hiding under the pinned zone on that side (see hasHiddenScrollLeft/Right()),\n not a permanent decoration. Applies to all 3 row-bands (header/body/footer) on each side.\n Positioned INSIDE the zone's own box (right:0/left:0, not a negative offset) — every one\n of these 6 containers has overflow:hidden (needed to clip cell content during virtual\n scroll), so a pseudo-element placed outside the box's own edge gets silently clipped away\n and never paints at all, regardless of opacity/z-index. Cost: the gradient overlays the\n last ~24px of the pinned zone's own cell content instead of bleeding into the scrollable\n zone — same trade-off Ant Design/AG Grid make, and unavoidable given the clipping\n requirement. */\n.eg-frozen-col-header, .eg-frozen-col-body, .eg-frozen-col-footer { position: relative; }\n.eg-pinned-right-header, .eg-pinned-right-footer { position: relative; }\n\n.eg-frozen-col-header::after, .eg-frozen-col-body::after, .eg-frozen-col-footer::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 24px;\n height: 100%;\n background: linear-gradient(to right, transparent, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)));\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-frozen-col-header.eg-shadow-edge::after,\n.eg-frozen-col-body.eg-shadow-edge::after,\n.eg-frozen-col-footer.eg-shadow-edge::after {\n opacity: 1;\n}\n\n.eg-pinned-right-body {\n position: sticky;\n right: 0;\n z-index: 10;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n flex-shrink: 0;\n}\n\n.eg-pinned-right-header::before, .eg-pinned-right-body::before, .eg-pinned-right-footer::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 24px;\n background: linear-gradient(to right, var(--eg-color-freeze-shadow, var(--eg-color-freeze-shadow-default)), transparent);\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n}\n.eg-pinned-right-header.eg-shadow-edge::before,\n.eg-pinned-right-body.eg-shadow-edge::before,\n.eg-pinned-right-footer.eg-shadow-edge::before {\n opacity: 1;\n}\n\n.eg-pinned-right-header {\n overflow: hidden;\n flex-shrink: 0;\n border-left: 2px solid var(--eg-color-border-strong, var(--eg-color-border-strong-default));\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n\n.eg-import-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 100;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n}\n\n.eg-lock-flash {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n z-index: 60;\n}\n.eg-lock-flash-icon {\n display: inline-block;\n width: 96px;\n height: 96px;\n background-color: var(--eg-color-text, var(--eg-color-text-default));\n mask-size: contain;\n mask-repeat: no-repeat;\n mask-position: center;\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n -webkit-mask-position: center;\n filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.28));\n}\n\n.eg-import-dialog {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: 8px;\n padding: 24px 32px;\n min-width: 280px;\n box-shadow: var(--eg-shadow-dialog, var(--eg-shadow-dialog-default));\n text-align: center;\n}\n\n.eg-import-title {\n font-size: 14px;\n font-weight: 600;\n color: var(--eg-color-text, var(--eg-color-text-default));\n margin-bottom: 16px;\n}\n\n.eg-import-bar-wrap {\n height: 8px;\n background: var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n overflow: hidden;\n margin-bottom: 10px;\n}\n\n.eg-import-bar {\n height: 100%;\n background: linear-gradient(to right, var(--eg-color-accent, var(--eg-color-accent-default)), #4caf50);\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: width 0.15s ease;\n}\n\n.eg-import-msg {\n font-size: 12px;\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default));\n margin-bottom: 4px;\n}\n\n.eg-import-pct {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-weight: 600;\n color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-export-spinner {\n width: 32px;\n height: 32px;\n margin: 12px auto 0;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n@keyframes eg-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ===== LOADING OVERLAY (loading prop) ===== */\n.eg-loading-overlay {\n position: absolute;\n inset: 0;\n background: var(--eg-color-overlay-bg, var(--eg-color-overlay-bg-default));\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 90;\n overflow: hidden;\n}\n.eg-loading-spinner {\n width: 32px;\n height: 32px;\n border: 3px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n border-top-color: var(--eg-color-accent, var(--eg-color-accent-default));\n border-radius: 50%;\n animation: eg-spin 0.8s linear infinite;\n}\n.eg-skeleton {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n.eg-skeleton-row {\n display: flex;\n align-items: center;\n padding: 0 4px;\n box-sizing: border-box;\n}\n.eg-skeleton-row-header {\n background: var(--eg-color-header-bg, var(--eg-color-header-bg-default));\n}\n.eg-skeleton-block {\n height: 60%;\n margin: 0 2px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: linear-gradient(\n 90deg,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 25%,\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) 37%,\n var(--eg-color-border-cell, var(--eg-color-border-cell-default)) 63%\n );\n background-size: 400% 100%;\n animation: eg-skeleton-shimmer 1.4s ease infinite;\n}\n@keyframes eg-skeleton-shimmer {\n 0% { background-position: 100% 50%; }\n 100% { background-position: 0 50%; }\n}\n@media (prefers-reduced-motion: reduce) {\n .eg-skeleton-block, .eg-loading-spinner { animation: none; }\n}\n\n/* ===== READONLY CELL ===== */\n/* !important throughout: same-specificity as the base .eg-cell / .eg-cell-active /\n .eg-cell-selected rules, so which one wins would otherwise depend on source\n order — fragile against any CSS minifier/optimizer in a host build pipeline\n that reorders equal-specificity rules (observed in practice: a host project's\n build silently dropped these declarations outside the active-cell state). */\n.eg-cell-readonly {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)) !important;\n /* text-secondary, not text-muted: muted is tuned for placeholders/disabled\n captions (very low contrast by design in every theme — e.g. antd's is\n rgba(0,0,0,0.25)) and read poorly as actual cell values, especially\n layered over the diagonal readonly pattern above. text-secondary is the\n same \"readable but visually de-emphasized\" tier already used for row\n numbers/toolbar icons/import messages — legible while still reading as\n distinct from a normal editable cell's full-strength text. */\n color: var(--eg-color-text-secondary, var(--eg-color-text-secondary-default)) !important;\n cursor: default;\n}\n.eg-cell-readonly.eg-cell-selected {\n background:\n repeating-linear-gradient(\n 135deg,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 0px,\n var(--eg-color-cell-readonly-pattern, var(--eg-color-cell-readonly-pattern-default)) 1px,\n transparent 1px,\n transparent 7px\n ),\n var(--eg-color-cell-readonly-selected, var(--eg-color-cell-readonly-selected-default)) !important;\n}\n.eg-cell-readonly.eg-cell-active {\n outline-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)) !important;\n}\n\n/* ===== SELECT DROPDOWN ===== */\n.eg-cell-select {\n cursor: pointer;\n}\n/* The caret (::after below) is an absolutely-positioned overlay pinned to the cell's\n right edge — it claims no layout space of its own. With the default left alignment\n that's fine (text hugs the left edge, away from the caret), but col.align:'right' (or\n long centered text) pushes the label into the same few pixels the caret occupies,\n causing visible overlap. Reserve room for it at the cell level so text never reaches\n that zone, regardless of alignment. Skipped when a #suffix-{key} slot is present\n (eg-cell--has-suffix) — that path already reserves space for its own inline caret via\n flex layout (.eg-cell-select-trigger-wrap), so the fixed pseudo-element is hidden there\n (see .eg-cell--has-suffix.eg-cell-select::after below) and no extra padding is needed. */\n.eg-cell-select:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 14px);\n}\n.eg-cell-select::after {\n content: '▾';\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n font-size: 11px;\n pointer-events: none;\n line-height: 1;\n}\n\n/* ===== DATE CELL ICON ===== */\n/* Same reasoning/placement as the select caret above — reserve room at the\n cell level so col.align:'right'/centered text never reaches the icon's\n zone, regardless of alignment. mask-image (not content:url()/an emoji) so\n the icon is a single flat color that follows --eg-color-text-muted in\n every theme, light and dark alike, instead of a fixed-color raster/emoji\n glyph that wouldn't match. */\n.eg-cell-date:not(.eg-cell--has-suffix) {\n padding-right: calc(var(--eg-cell-padding-x, var(--eg-cell-padding-x-default)) + 16px);\n}\n.eg-cell--has-suffix.eg-cell-date::after { display: none; }\n.eg-cell-date::after {\n content: '';\n position: absolute;\n right: 5px;\n top: 50%;\n width: 12px;\n height: 12px;\n transform: translateY(-50%);\n background-color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n pointer-events: none;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Crect x='1.5' y='3' width='13' height='11' rx='1.5'/%3E%3Cline x1='1.5' y1='6.3' x2='14.5' y2='6.3'/%3E%3Cline x1='4.5' y1='1.2' x2='4.5' y2='4.2'/%3E%3Cline x1='11.5' y1='1.2' x2='11.5' y2='4.2'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n/* ===== CHECKBOX CELL ===== */\n.eg-cell-checkbox {\n justify-content: center;\n padding: 0 2px;\n}\n\n/* ===== ACTION CELL ===== */\n.eg-cell-action-wrap {\n display: flex;\n align-items: center;\n height: 100%;\n width: 100%;\n cursor: pointer;\n}\n/* The wrap fills the cell (100% width) so the whole cell area stays clickable —\n .eg-cell's own justify-content (from col.align) has no leftover space to act\n on, so col.align is re-applied here to position the slot content itself. */\n.eg-cell-align-right .eg-cell-action-wrap { justify-content: flex-end; }\n.eg-cell-align-center .eg-cell-action-wrap { justify-content: center; }\n.eg-validation-tooltip {\n background: var(--eg-color-error-soft, var(--eg-color-error-soft-default));\n border: 1px solid var(--eg-color-error, var(--eg-color-error-default));\n color: var(--eg-color-error-dark, var(--eg-color-error-dark-default));\n font-size: 12px;\n padding: 4px 8px;\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n white-space: nowrap;\n pointer-events: none;\n}\n\n/* ===== ROW STRIPE & HOVER ===== */\n.eg-row--even .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-stripe, var(--eg-color-row-stripe-default));\n}\n.eg-row:hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview),\n.eg-row.eg-row--hover .eg-cell:not(.eg-cell-selected):not(.eg-cell-active):not(.eg-cell-fill-preview) {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n.eg-row-header-cell:hover,\n.eg-row-header-cell.eg-row--hover {\n background: var(--eg-color-row-hover, var(--eg-color-row-hover-default));\n}\n\n/* ===== SIZE VARIANTS ===== */\n\n/* small */\n.eg-size-small .eg-cell {\n font-size: 12px;\n padding: 0 3px;\n}\n.eg-size-small .eg-cell-input {\n font-size: 12px;\n}\n.eg-size-small .eg-header-cell {\n font-size: 11px;\n}\n.eg-size-small .eg-footer-cell {\n font-size: 12px;\n}\n.eg-size-small .eg-row-header-cell {\n font-size: 10px;\n}\n.eg-size-small .eg-context-menu,\n.eg-size-small .eg-context-item {\n font-size: 12px;\n}\n.eg-size-small .eg-context-item {\n padding: 4px 14px;\n}\n.eg-size-small .eg-sort-menu {\n min-width: 90px;\n}\n.eg-size-small .eg-lock-flash-icon {\n width: 72px;\n height: 72px;\n}\n.eg-size-small .eg-filter-input,\n.eg-size-small .eg-filter-select {\n font-size: 11px;\n}\n.eg-size-small .eg-sort-icon {\n width: 9px;\n height: 9px;\n}\n\n/* large */\n.eg-size-large .eg-cell {\n font-size: 14px;\n padding: 0 6px;\n}\n.eg-size-large .eg-cell-input {\n font-size: 14px;\n}\n.eg-size-large .eg-header-cell {\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n}\n.eg-size-large .eg-footer-cell {\n font-size: 14px;\n}\n.eg-size-large .eg-row-header-cell {\n font-size: 12px;\n}\n.eg-size-large .eg-context-menu,\n.eg-size-large .eg-context-item {\n font-size: 14px;\n}\n.eg-size-large .eg-context-item {\n padding: 8px 18px;\n}\n.eg-size-large .eg-sort-menu {\n min-width: 130px;\n}\n.eg-size-large .eg-lock-flash-icon {\n width: 120px;\n height: 120px;\n}\n.eg-size-large .eg-filter-input,\n.eg-size-large .eg-filter-select {\n font-size: 13px;\n}\n.eg-size-large .eg-sort-icon {\n width: 13px;\n height: 13px;\n}\n</style>\n\n<style>\n/* ── EgSelect / EgTreeSelect dropdown ──\n Deliberate duplicate of EgSelect.vue's own (also global) <style>, not\n dead copy-paste: Vue only injects a component's <style> the first time\n that component is actually instantiated (vue-runtime-helpers' inject-style\n runs from a beforeCreate hook), so a grid that only ever uses\n type:'treeSelect' columns (never type:'select') would never trigger\n EgSelect.vue's own style injection at all — EgTreeSelect.vue reuses\n EgSelect's dropdown/option classes but doesn't redefine them itself. This\n copy, living in ExcelGrid.vue (always instantiated first), guarantees the\n classes are styled regardless of which sibling component ends up\n rendering them. Keep every token here identical to EgSelect.vue's copy —\n a past mismatch (this file used --eg-color-border-cell for\n .eg-select-dropdown's border where EgSelect.vue's own rule used\n --eg-color-border) made the two equally-specific global rules visibly\n disagree depending on which one happened to load last, reported as the\n dropdown's border looking wrong / DevTools showing two conflicting rules\n for .eg-select-dropdown (fixed in v1.1.113). */\n.eg-select-wrapper {\n position: relative;\n display: inline-block;\n min-width: 120px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n font-family: inherit;\n}\n.eg-select-trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n min-height: 30px;\n padding: 4px 8px;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n cursor: pointer;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n transition: border-color .15s ease, box-shadow .15s ease;\n}\n.eg-select-trigger:hover { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-trigger--open {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-trigger--disabled { background: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); cursor: default; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-trigger-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.eg-select-placeholder { color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); }\n.eg-select-caret { flex-shrink: 0; color: var(--eg-color-text-muted, var(--eg-color-text-muted-default)); font-size: 12px; }\n.eg-select-dropdown {\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-dropdown, var(--eg-shadow-dropdown-default));\n overflow-y: auto;\n max-height: 320px;\n padding: 4px 0;\n}\n.eg-select-dropdown--floating {\n position: absolute;\n top: 100%;\n left: 0;\n min-width: 100%;\n z-index: 999;\n margin-top: 2px;\n}\n.eg-select-search {\n padding: 6px 8px;\n border-bottom: 1px solid var(--eg-color-border-cell, var(--eg-color-border-cell-default));\n position: sticky;\n top: 0;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n z-index: 1;\n}\n.eg-select-search-input {\n width: 100%;\n box-sizing: border-box;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 8px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n outline: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n font-family: inherit;\n}\n.eg-select-search-input:focus {\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n box-shadow: 0 0 0 2px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default));\n}\n.eg-select-option {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n padding: 6px 12px;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n cursor: pointer;\n white-space: normal;\n word-break: break-word;\n user-select: none;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-option:hover,\n.eg-select-option--highlighted { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-option--active { font-weight: 600; color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-select-option--active.eg-select-option--highlighted,\n.eg-select-option--active:hover { background: var(--eg-color-option-active-hover-bg, var(--eg-color-option-active-hover-bg-default)); }\n.eg-select-empty {\n padding: 14px 12px;\n text-align: center;\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n user-select: none;\n}\n.eg-option-tooltip {\n position: fixed;\n max-width: 280px;\n background: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n box-shadow: var(--eg-shadow-tooltip, var(--eg-shadow-tooltip-default));\n color: var(--eg-color-text, var(--eg-color-text-default));\n font-size: var(--eg-font-size, var(--eg-font-size-default));\n padding: 6px 10px;\n pointer-events: none;\n white-space: normal;\n word-break: break-word;\n z-index: 10000;\n}\n.eg-select-footer {\n display: flex;\n justify-content: flex-end;\n padding: 4px 6px 6px;\n border-top: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n}\n.eg-select-btn {\n background: none;\n border: 1px solid var(--eg-color-border, var(--eg-color-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n padding: 4px 10px;\n cursor: pointer;\n font-size: 12px;\n color: var(--eg-color-text, var(--eg-color-text-default));\n}\n.eg-select-btn:hover { background: var(--eg-color-option-hover-bg, var(--eg-color-option-hover-bg-default)); }\n.eg-select-btn--clear { color: var(--eg-color-danger, #c62828); border-color: var(--eg-color-danger, #f1b0b0); }\n.eg-select-btn--clear:hover { background: #fff0f0; }\n/* Grid `size` overrides — must live in this *unscoped* style block, not the\n scoped one above: .eg-select-option is rendered by the separate EgSelect /\n EgTreeSelect components, so Vue's scoped-CSS attribute (added only to this\n SFC's own template elements) never lands on it — a scoped rule targeting\n it can never match. */\n.eg-size-small .eg-select-option {\n font-size: 12px;\n padding: 4px 10px;\n}\n.eg-size-large .eg-select-option {\n font-size: 14px;\n padding: 8px 14px;\n}\n.eg-select-dot {\n width: 9px;\n height: 9px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n margin-top: 3px;\n}\n/* Tree toggle */\n.eg-tree-toggle {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex-shrink: 0;\n font-size: 10px;\n color: var(--eg-color-text-muted, var(--eg-color-text-muted-default));\n margin-top: 1px;\n}\n.eg-tree-toggle--leaf { visibility: hidden; }\n/* EgCheckbox */\n.eg-checkbox {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n cursor: pointer;\n}\n.eg-checkbox-input {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n cursor: pointer;\n}\n.eg-checkbox-box {\n position: relative;\n display: block;\n width: 19px;\n height: 19px;\n box-sizing: border-box;\n background-color: var(--eg-color-cell-bg, var(--eg-color-cell-bg-default));\n background-repeat: no-repeat;\n background-position: center;\n background-size: 0 0;\n border: 1.5px solid var(--eg-color-checkbox-border, var(--eg-color-checkbox-border-default));\n border-radius: var(--eg-radius, var(--eg-radius-default));\n transition: background-color .12s ease, border-color .12s ease, background-size .12s ease;\n}\n.eg-checkbox:hover .eg-checkbox-box { border-color: var(--eg-color-accent, var(--eg-color-accent-default)); }\n.eg-checkbox-input:checked + .eg-checkbox-box {\n background-color: var(--eg-color-accent, var(--eg-color-accent-default));\n background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZD0iTTMuNSA4LjNsMyAzIDYtNi42IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==\");\n background-size: 70% 70%;\n border-color: var(--eg-color-accent, var(--eg-color-accent-default));\n}\n.eg-checkbox-input:focus-visible + .eg-checkbox-box { box-shadow: 0 0 0 3px var(--eg-color-accent-soft, var(--eg-color-accent-soft-default)); }\n.eg-checkbox--disabled,\n.eg-checkbox--disabled .eg-checkbox-input { cursor: default; }\n.eg-checkbox-input:disabled + .eg-checkbox-box { background-color: var(--eg-color-cell-readonly, var(--eg-color-cell-readonly-default)); border-color: var(--eg-color-border-cell, var(--eg-color-border-cell-default)); }\n.eg-locked .eg-checkbox,\n.eg-locked .eg-checkbox-input { cursor: not-allowed; }\n.eg-checkbox-input:disabled:checked + .eg-checkbox-box { background-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); border-color: var(--eg-color-checkbox-disabled-checked, var(--eg-color-checkbox-disabled-checked-default)); }\n</style>\n"]}, media: undefined });
19804
19850
 
19805
19851
  };
19806
19852
  /* scoped */
19807
- const __vue_scope_id__ = "data-v-5dda8f14";
19853
+ const __vue_scope_id__ = "data-v-33b216ce";
19808
19854
  /* module identifier */
19809
19855
  const __vue_module_identifier__ = undefined;
19810
19856
  /* functional template */