@syncfusion/ej2-navigations 34.1.30 → 34.2.2

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.
@@ -4,6 +4,7 @@ import { getScrollableParent, Popup, getZindexPartial, fit, isCollide, calculate
4
4
  import { Button, createCheckBox, rippleMouseHandler } from '@syncfusion/ej2-buttons';
5
5
  import { DataManager, Query } from '@syncfusion/ej2-data';
6
6
  import { Input } from '@syncfusion/ej2-inputs';
7
+ import { Skeleton } from '@syncfusion/ej2-notifications';
7
8
 
8
9
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
9
10
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -10563,6 +10564,9 @@ const CHILD = 'e-has-child';
10563
10564
  const ITEM_ANIMATION_ACTIVE = 'e-animation-active';
10564
10565
  const DISABLED$1 = 'e-disabled';
10565
10566
  const PREVENTSELECT = 'e-prevent';
10567
+ const VIRTUALIZATION = 'e-virtualization';
10568
+ const VIRTUAL_MASK_PARENT = 'e-mask-parent';
10569
+ const VIRTUAL_MASK_ITEM = 'e-mask-item';
10566
10570
  const treeAriaAttr = {
10567
10571
  treeRole: 'group',
10568
10572
  itemRole: 'treeitem',
@@ -10686,6 +10690,55 @@ let TreeView = TreeView_1 = class TreeView extends Component {
10686
10690
  this.isDropIn = false;
10687
10691
  this.OldCheckedData = [];
10688
10692
  this.isHiddenItem = false;
10693
+ // ── Virtualization state ──────────────────────────────────────────────────────────────
10694
+ /** Flat ordered list of all currently-visible (non-collapsed) tree nodes. */
10695
+ this.virtualFlatNodes = [];
10696
+ /** Maps node id → index in virtualFlatNodes for O(1) lookups. */
10697
+ this.virtualNodeMap = new Map();
10698
+ /** Set of expanded node IDs for O(1) lookup performance during flattening (avoids array indexOf). */
10699
+ this.virtualExpandedNodesSet = new Set();
10700
+ /** Set of checked node IDs for O(1) lookup performance during flattening. */
10701
+ this.virtualCheckedNodesSet = new Set();
10702
+ /** Set of indeterminate node IDs — persists across flattenVisibleNodes() rebuilds. */
10703
+ this.virtualIndeterminateNodesSet = new Set();
10704
+ /** Set of selected node IDs for O(1) lookup performance during flattening. */
10705
+ this.virtualSelectedNodesSet = new Set();
10706
+ /** Row height in pixels, measured from the first rendered item or set to 40 as a default. */
10707
+ this.virtualItemHeight = 40;
10708
+ /** Height of the scrollable container in pixels. */
10709
+ this.virtualViewportHeight = 0;
10710
+ /** scrollTop captured during the previous scroll handler invocation. */
10711
+ this.virtualPrevScrollTop = 0;
10712
+ /** Index of the first rendered row in the current window. */
10713
+ this.virtualStartIndex = 0;
10714
+ /** Index one past the last rendered row in the current window. */
10715
+ this.virtualEndIndex = 0;
10716
+ /** Extra rows rendered above and below the visible window to smooth scrolling. */
10717
+ this.virtualOverscanCount = 3;
10718
+ /** RequestAnimationFrame handle used for throttling scroll events. */
10719
+ this.virtualScrollRAF = null;
10720
+ /** The `.e-mask-parent` overlay element shown during fast scroll. */
10721
+ this.virtualMaskElement = null;
10722
+ /** Bound scroll handler stored for later removeEventListener. */
10723
+ this.virtualScrollBound = null;
10724
+ /** Skeleton component instances inside the mask overlay. */
10725
+ this.skeletonInstances = [];
10726
+ /** Set to true whenever the flat node list must be rebuilt on next render. */
10727
+ this.virtualTreeDirty = false;
10728
+ /** The data-uid of the currently keyboard-focused node in virtual mode (persists across re-renders). */
10729
+ this.virtualFocusedId = null;
10730
+ /**
10731
+ * Set to true by the document-level `keydown` handler when the user presses
10732
+ * the Tab key. The browser will auto-scroll the focused element into view
10733
+ * immediately after focus, which fires a `scroll` event on the TreeView
10734
+ * container. We want to skip the re-render that the virtual scroll handler
10735
+ * would otherwise trigger, because the element being focused is already
10736
+ * rendered (it is the single `<li tabindex="0">` of the current window) and
10737
+ * re-rendering would tear down the DOM element that is currently holding
10738
+ * focus. The flag is consumed (reset to `false`) inside `onVirtualScroll`
10739
+ * after the auto-scroll has been handled.
10740
+ */
10741
+ this.virtualSkipNextScrollRender = false;
10689
10742
  }
10690
10743
  /**
10691
10744
  * Get component name.
@@ -10981,6 +11034,24 @@ let TreeView = TreeView_1 = class TreeView extends Component {
10981
11034
  }
10982
11035
  }
10983
11036
  renderItems(isSorted) {
11037
+ // In virtual mode (with a fixed height) create only the container <ul> —
11038
+ // initVirtualization will populate the items directly, avoiding the O(n)
11039
+ // ListBase.createList cost.
11040
+ // updateCheckedStateFromDS / updateParentCheckState are intentionally skipped here:
11041
+ // they call getCheckedNodeDetails which traverses the DOM (no DOM exists yet in
11042
+ // virtual mode) and would throw on empty recursion results. The initial checked
11043
+ // state is derived from the raw data inside flattenNodeRecursive instead.
11044
+ //
11045
+ // When enableVirtualization is true but height is NOT provided, we fall through
11046
+ // to the normal (non-virtualized) rendering path so the tree is still visible.
11047
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
11048
+ this.ulElement = this.createElement('ul', {
11049
+ className: PARENTITEM + ' ' + 'e-ul',
11050
+ attrs: { role: 'group' }
11051
+ });
11052
+ this.element.appendChild(this.ulElement);
11053
+ return;
11054
+ }
10984
11055
  this.listBaseOption.ariaAttributes.level = 1;
10985
11056
  const sortedData = this.getSortedData(this.rootData);
10986
11057
  this.ulElement = ListBase.createList(this.createElement, isSorted ? this.rootData : sortedData, this.listBaseOption);
@@ -12016,7 +12087,7 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12016
12087
  else if (state === 'indeterminate' && this.autoCheck) {
12017
12088
  frameSpan.classList.remove(CHECK);
12018
12089
  frameSpan.classList.add(INDETERMINATE);
12019
- this.removeCheck(currLi);
12090
+ this.removeCheck(currLi, true);
12020
12091
  ariaState = 'mixed';
12021
12092
  }
12022
12093
  ariaState = state === 'check' ? 'true' : state === 'uncheck' ? 'false' : ariaState;
@@ -12047,12 +12118,110 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12047
12118
  if (!isNullOrUndefined(id) && this.checkedNodes.indexOf(id) === -1) {
12048
12119
  this.checkDisabledState(id);
12049
12120
  }
12121
+ if (this.enableVirtualization) {
12122
+ // Clear any indeterminate state — node is now fully checked
12123
+ this.virtualIndeterminateNodesSet.delete(id);
12124
+ const actualIndex = this.virtualNodeMap.get(id);
12125
+ if (!isNullOrUndefined(actualIndex) && actualIndex > -1) {
12126
+ this.virtualFlatNodes[parseInt(actualIndex.toString(), 10)].isChecked = true;
12127
+ if (this.autoCheck && this.virtualFlatNodes[parseInt(actualIndex.toString(), 10)].hasChildren) {
12128
+ this.setVirtualChildCheckState(id, true);
12129
+ }
12130
+ }
12131
+ }
12050
12132
  }
12051
- removeCheck(liEle) {
12052
- const index = this.checkedNodes.indexOf(liEle.getAttribute('data-uid'));
12133
+ removeCheck(liEle, isIndeterminate) {
12134
+ const id = liEle.getAttribute('data-uid');
12135
+ const index = this.checkedNodes.indexOf(id);
12053
12136
  if (index > -1) {
12054
12137
  this.checkedNodes.splice(index, 1);
12055
12138
  }
12139
+ if (this.enableVirtualization) {
12140
+ const actualIndex = this.virtualNodeMap.get(id);
12141
+ if (!isNullOrUndefined(actualIndex) && actualIndex > -1) {
12142
+ if (isIndeterminate) {
12143
+ // Parent is transitioning to indeterminate — preserve children states
12144
+ // and mark this node as 'indeterminate' so scroll/collapse re-renders correctly.
12145
+ this.virtualFlatNodes[parseInt(actualIndex.toString(), 10)].isChecked = 'indeterminate';
12146
+ // Also record it in the persistent indeterminate set so flattenVisibleNodes()
12147
+ // can restore this state after a full rebuild (e.g. on collapse).
12148
+ this.virtualIndeterminateNodesSet.add(id);
12149
+ }
12150
+ else {
12151
+ // Node is being fully unchecked — clear indeterminate tracking too
12152
+ this.virtualIndeterminateNodesSet.delete(id);
12153
+ this.virtualCheckedNodesSet.delete(id);
12154
+ this.virtualFlatNodes[parseInt(actualIndex.toString(), 10)].isChecked = false;
12155
+ this.treeData[parseInt(id.toString(), 10) - 1].isChecked = false;
12156
+ if (this.autoCheck && this.virtualFlatNodes[parseInt(actualIndex.toString(), 10)].hasChildren) {
12157
+ this.setVirtualChildCheckState(id, false);
12158
+ }
12159
+ }
12160
+ }
12161
+ }
12162
+ }
12163
+ /**
12164
+ * Recursively sets the `isChecked` state of all descendants of the node with
12165
+ * `parentId` in `virtualFlatNodes`, keeping `checkedNodes` and
12166
+ * `virtualCheckedNodesSet` in sync. Used in virtual mode to honor the
12167
+ * `autoCheck` cascade when a parent checkbox is toggled.
12168
+ *
12169
+ * @param {string} parentId - The `data-uid` of the parent whose descendants should be updated.
12170
+ * @param {boolean} isChecked - The new state to apply to every descendant.
12171
+ * @returns {void}
12172
+ * @private
12173
+ */
12174
+ setVirtualChildCheckState(parentId, isChecked) {
12175
+ if (!this.virtualFlatNodes || this.virtualFlatNodes.length === 0) {
12176
+ return;
12177
+ }
12178
+ for (let i = 0; i < this.virtualFlatNodes.length; i++) {
12179
+ const node = this.virtualFlatNodes[parseInt(i.toString(), 10)];
12180
+ if (node.parentId === parentId) {
12181
+ node.isChecked = isChecked;
12182
+ if (isChecked) {
12183
+ this.virtualCheckedNodesSet.add(node.id);
12184
+ if (this.checkedNodes && this.checkedNodes.indexOf(node.id) === -1) {
12185
+ this.checkedNodes.push(node.id);
12186
+ }
12187
+ }
12188
+ else {
12189
+ this.virtualCheckedNodesSet.delete(node.id);
12190
+ const idx = this.checkedNodes ? this.checkedNodes.indexOf(node.id) : -1;
12191
+ if (idx !== -1) {
12192
+ this.checkedNodes.splice(idx, 1);
12193
+ }
12194
+ }
12195
+ if (node.hasChildren) {
12196
+ this.setVirtualChildCheckState(node.id, isChecked);
12197
+ }
12198
+ }
12199
+ }
12200
+ // Collapsed children are not present in virtualFlatNodes, so walk the raw data
12201
+ // to keep this.treeData and checkedNodes in sync for hidden descendants too.
12202
+ const collapsedChildren = this.getChildNodes(this.treeData, parentId);
12203
+ if (collapsedChildren) {
12204
+ for (let i = 0; i < collapsedChildren.length; i++) {
12205
+ const childId = collapsedChildren[parseInt(i.toString(), 10)][this.fields.id]
12206
+ ? collapsedChildren[parseInt(i.toString(), 10)][this.fields.id].toString()
12207
+ : null;
12208
+ if (childId && (!this.virtualNodeMap || !this.virtualNodeMap.has(childId))) {
12209
+ this.updateField(this.treeData, this.fields, childId, this.fields.isChecked, isChecked);
12210
+ if (isChecked) {
12211
+ if (this.checkedNodes && this.checkedNodes.indexOf(childId) === -1) {
12212
+ this.checkedNodes.push(childId);
12213
+ }
12214
+ }
12215
+ else {
12216
+ const idx = this.checkedNodes ? this.checkedNodes.indexOf(childId) : -1;
12217
+ if (idx !== -1) {
12218
+ this.checkedNodes.splice(idx, 1);
12219
+ }
12220
+ }
12221
+ this.setVirtualChildCheckState(childId, isChecked);
12222
+ }
12223
+ }
12224
+ }
12056
12225
  }
12057
12226
  getCheckEvent(currLi, action, e) {
12058
12227
  this.checkActionNodes.push(this.getNodeData(currLi));
@@ -12084,6 +12253,9 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12084
12253
  return;
12085
12254
  }
12086
12255
  const liEle = ulEle ? selectAll('.' + LISTITEM, ulEle) : this.liList;
12256
+ if (!liEle) {
12257
+ return;
12258
+ }
12087
12259
  const length = liEle.length;
12088
12260
  for (let i = 0; i < length; i++) {
12089
12261
  this.calculateWrap(liEle[parseInt(i.toString(), 10)]);
@@ -12170,6 +12342,10 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12170
12342
  }
12171
12343
  this.isLoaded = true;
12172
12344
  this.isNodeDropped = false;
12345
+ // Initialise (or re-initialise) virtual mode after data binding has settled
12346
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
12347
+ this.initVirtualization();
12348
+ }
12173
12349
  }
12174
12350
  doSelectionAction() {
12175
12351
  const sNodes = selectAll('.' + SELECTED$1, this.element);
@@ -12303,7 +12479,17 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12303
12479
  };
12304
12480
  this.trigger('nodeClicked', eventArgs);
12305
12481
  }
12306
- expandNode(currLi, icon, loaded) {
12482
+ expandNode(currLi, icon, loaded, isKeyboardNav) {
12483
+ // Virtual mode fast-path: rebuild the flat list and re-render the window.
12484
+ // Only engage when height is also set; without a fixed height the tree falls
12485
+ // back to normal rendering and the standard expand path must be used.
12486
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
12487
+ const id = currLi ? currLi.getAttribute('data-uid') : null;
12488
+ if (id) {
12489
+ this.handleVirtualExpand(id, isKeyboardNav);
12490
+ }
12491
+ return;
12492
+ }
12307
12493
  this.renderReactTemplates();
12308
12494
  if (icon.classList.contains(LOAD)) {
12309
12495
  this.hideSpinner(icon);
@@ -12410,15 +12596,29 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12410
12596
  removeClass([icon], PROCESS);
12411
12597
  }
12412
12598
  else {
12413
- this.nodeCollapseAction(currLi, icon, observedArgs);
12599
+ // Detect if event is keyboard navigation by checking for 'action' property
12600
+ const isKeyboardNav = e && e.action !== undefined;
12601
+ this.nodeCollapseAction(currLi, icon, observedArgs, isKeyboardNav);
12414
12602
  }
12415
12603
  });
12416
12604
  }
12417
12605
  else {
12418
- this.nodeCollapseAction(currLi, icon, colArgs);
12606
+ // Detect if event is keyboard navigation by checking for 'action' property
12607
+ const isKeyboardNav = e && e.action !== undefined;
12608
+ this.nodeCollapseAction(currLi, icon, colArgs, isKeyboardNav);
12419
12609
  }
12420
12610
  }
12421
- nodeCollapseAction(currLi, icon, colArgs) {
12611
+ nodeCollapseAction(currLi, icon, colArgs, isKeyboardNav) {
12612
+ // Virtual mode fast-path: rebuild the flat list and re-render the window.
12613
+ // Only engage when height is also set; without a fixed height the tree falls
12614
+ // back to normal rendering and the standard collapse path must be used.
12615
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
12616
+ const id = currLi ? currLi.getAttribute('data-uid') : null;
12617
+ if (id) {
12618
+ this.handleVirtualCollapse(id, isKeyboardNav);
12619
+ }
12620
+ return;
12621
+ }
12422
12622
  removeClass([icon], COLLAPSIBLE);
12423
12623
  addClass([icon], EXPANDABLE);
12424
12624
  let start = 0;
@@ -12778,7 +12978,9 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12778
12978
  }
12779
12979
  selectNode(li, e, multiSelect) {
12780
12980
  if (isNullOrUndefined(li) || (!this.allowMultiSelection && this.isActive(li) && !isNullOrUndefined(e))) {
12781
- if (this.checkOnClick) {
12981
+ const isDropDownTree = this.element.closest('.e-ddt');
12982
+ const handleCheckOnClick = isDropDownTree ? (this.checkOnClick && this.showCheckBox) : this.checkOnClick;
12983
+ if (handleCheckOnClick) {
12782
12984
  const checkboxElement = select(' .' + CHECKBOXFRAME, li);
12783
12985
  if (!isNullOrUndefined(checkboxElement) && checkboxElement.classList.contains(CHECK)) {
12784
12986
  addClass([li], ACTIVE);
@@ -12870,6 +13072,10 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12870
13072
  li.setAttribute('tabindex', '0');
12871
13073
  EventHandler.add(li, 'blur', this.focusOut, this);
12872
13074
  this.updateIdAttr(focusedNode, li);
13075
+ // Track focused id for virtual mode re-render restoration
13076
+ if (this.enableVirtualization) {
13077
+ this.virtualFocusedId = li.getAttribute('data-uid');
13078
+ }
12873
13079
  }
12874
13080
  }
12875
13081
  addSelect(liEle) {
@@ -12927,6 +13133,9 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12927
13133
  }
12928
13134
  }
12929
13135
  expandCollapseAction(currLi, e) {
13136
+ if (this.enableVirtualization && !isNullOrUndefined(this.height) && currLi.classList.contains(EDITING)) {
13137
+ return;
13138
+ }
12930
13139
  const icon = select('div.' + ICON, currLi);
12931
13140
  if (!icon || icon.classList.contains(PROCESS)) {
12932
13141
  return;
@@ -12956,15 +13165,29 @@ let TreeView = TreeView_1 = class TreeView extends Component {
12956
13165
  removeClass([icon], PROCESS);
12957
13166
  }
12958
13167
  else {
12959
- this.nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll);
13168
+ // Detect if event is keyboard navigation by checking for 'action' property
13169
+ const isKeyboardNav = e && e.action !== undefined;
13170
+ this.nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll, isKeyboardNav);
12960
13171
  }
12961
13172
  });
12962
13173
  }
12963
13174
  else {
12964
- this.nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll);
13175
+ // Detect if event is keyboard navigation by checking for 'action' property
13176
+ const isKeyboardNav = e && e.action !== undefined;
13177
+ this.nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll, isKeyboardNav);
13178
+ }
13179
+ }
13180
+ nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll, isKeyboardNav) {
13181
+ // In virtual mode (with a fixed height) the flat <li> never has a child <ul>
13182
+ // in the DOM, so the PARENTITEM check below would always fail and fall into
13183
+ // renderChildNodes(), which corrupts the virtual DOM.
13184
+ // Delegate directly to expandNode() which has the handleVirtualExpand fast-path.
13185
+ // When height is not provided, fall through to normal expand logic below since
13186
+ // the tree is rendered with a full nested DOM structure.
13187
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
13188
+ this.expandNode(currLi, icon, undefined, isKeyboardNav);
13189
+ return;
12965
13190
  }
12966
- }
12967
- nodeExpandAction(currLi, icon, expandChild, callback, isFromExpandAll) {
12968
13191
  const ul = select('.' + PARENTITEM, currLi);
12969
13192
  if (ul && ul.nodeName === 'UL') {
12970
13193
  this.expandNode(currLi, icon);
@@ -13077,6 +13300,19 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13077
13300
  });
13078
13301
  }
13079
13302
  navigateToFocus(isUp) {
13303
+ // In virtual mode (with a fixed height) the element itself is the scroll
13304
+ // container; use ensureVirtualNodeVisible. When height is not provided,
13305
+ // the tree uses normal rendering, so fall through to the standard scroll logic.
13306
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
13307
+ const focusedLi = this.getFocusedNode();
13308
+ if (focusedLi) {
13309
+ const id = focusedLi.getAttribute('data-uid');
13310
+ if (id) {
13311
+ this.ensureVirtualNodeVisible(id);
13312
+ }
13313
+ }
13314
+ return;
13315
+ }
13080
13316
  const focusNode = this.getFocusedNode().querySelector('.' + TEXTWRAP);
13081
13317
  const pos = focusNode.getBoundingClientRect();
13082
13318
  const parent = this.getScrollParent(this.element);
@@ -13495,6 +13731,46 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13495
13731
  return isNullOrUndefined(fNode) ? (isNullOrUndefined(selectedItem) ? this.element.firstElementChild : selectedItem) : fNode;
13496
13732
  }
13497
13733
  focusNextNode(li, isTowards) {
13734
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
13735
+ // Determine the id of the node one step in the requested direction
13736
+ const currentId = li ? li.getAttribute('data-uid') : null;
13737
+ let currentIdx = -1;
13738
+ if (!isNullOrUndefined(currentId)) {
13739
+ const idx = this.virtualNodeMap.get(currentId);
13740
+ currentIdx = !isNullOrUndefined(idx) ? idx : -1;
13741
+ }
13742
+ const targetIdx = isTowards ? currentIdx + 1 : currentIdx - 1;
13743
+ if (targetIdx < 0 || targetIdx >= this.virtualFlatNodes.length) {
13744
+ return;
13745
+ }
13746
+ const targetId = this.virtualFlatNodes[parseInt(targetIdx.toString(), 10)].id;
13747
+ // ── Fix 1: set virtualFocusedId BEFORE calling ensureVirtualNodeVisible ──
13748
+ // renderVirtualItems() is called synchronously inside ensureVirtualNodeVisible
13749
+ // when a re-render is needed. If virtualFocusedId is still the old value at
13750
+ // that point, focus restoration falls back to the first item. Setting it here
13751
+ // ensures renderVirtualItems always restores focus to the correct target node.
13752
+ this.virtualFocusedId = targetId;
13753
+ // ── Fix 2: clear focus classes from ALL nested list items, not just top-level ones ──
13754
+ // With hierarchical virtual DOM, the currently focused <li> may be nested inside
13755
+ // a child <ul>. Using ':scope > li' only cleans up direct children and leaves
13756
+ // stale e-focused / e-node-focus classes on nested nodes across re-renders.
13757
+ const allLi = this.element.querySelectorAll('.' + LISTITEM + '.' + FOCUS);
13758
+ for (let k = 0; k < allLi.length; k++) {
13759
+ allLi[parseInt(k.toString(), 10)].classList.remove(FOCUS);
13760
+ allLi[parseInt(k.toString(), 10)].classList.remove(FOCUSED$1);
13761
+ allLi[parseInt(k.toString(), 10)].setAttribute('tabindex', '-1');
13762
+ }
13763
+ // Ensure the target <li> is in the DOM window (may trigger a re-render).
13764
+ // virtualFocusedId is already set above so renderVirtualItems restores focus correctly.
13765
+ this.ensureVirtualNodeVisible(targetId);
13766
+ // Re-query both nodes after the potential re-render so setFocus gets live DOM elements
13767
+ const prevNode = currentId ? this.getElement(currentId) : li;
13768
+ const nextNode = this.getElement(targetId);
13769
+ if (!isNullOrUndefined(nextNode)) {
13770
+ this.setFocus(isNullOrUndefined(prevNode) ? nextNode : prevNode, nextNode);
13771
+ }
13772
+ return;
13773
+ }
13498
13774
  const nextNode = isTowards ? this.getNextNode(li) : this.getPrevNode(li);
13499
13775
  this.setFocus(li, nextNode);
13500
13776
  this.navigateToFocus(!isTowards);
@@ -13564,6 +13840,12 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13564
13840
  addClass([nextNode], FOCUS);
13565
13841
  addClass([nextNode], FOCUSED$1);
13566
13842
  nextNode.setAttribute('tabindex', '0');
13843
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
13844
+ nextNode.focus({ preventScroll: true });
13845
+ }
13846
+ else {
13847
+ nextNode.focus();
13848
+ }
13567
13849
  nextNode.focus();
13568
13850
  EventHandler.add(nextNode, 'blur', this.focusOut, this);
13569
13851
  this.updateIdAttr(preNode, nextNode);
@@ -13582,6 +13864,30 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13582
13864
  if (!focusedElement) {
13583
13865
  return;
13584
13866
  }
13867
+ // Virtual-mode guard: when the browser moves focus into the TreeView (e.g. via
13868
+ // the Tab key) it always lands on the single `<li tabindex="0">`. In that
13869
+ // case the element is already the focusable one, FOCUS is already applied
13870
+ // and the only thing left to do is the keyboard focus styling. Skip the
13871
+ // redundant setAttribute/EventHandler.add work so we never re-render the
13872
+ // virtual window as a side effect of focusing the existing `tabindex="0"`
13873
+ // item.
13874
+ // ── Fix: Use document.activeElement to detect browser-focused node ──
13875
+ // After renderVirtualItems() restores focus, the browser-focused element is
13876
+ // the authoritative source, not getFocusedNode(). If they don't match, it means
13877
+ // renderVirtualItems already correctly set up the element, so we must not
13878
+ // override its setup by calling setFocus again.
13879
+ const actuallyFocusedElement = document.activeElement;
13880
+ const isAlreadyFocusedInVirtualMode = this.enableVirtualization
13881
+ && actuallyFocusedElement === focusedElement
13882
+ && actuallyFocusedElement.getAttribute('tabindex') === '0'
13883
+ && actuallyFocusedElement.classList.contains(FOCUS);
13884
+ if (isAlreadyFocusedInVirtualMode) {
13885
+ if (this.isKeyboardFocus && !actuallyFocusedElement.classList.contains(FOCUSED$1)) {
13886
+ addClass([actuallyFocusedElement], FOCUSED$1);
13887
+ }
13888
+ this.isKeyboardFocus = false;
13889
+ return;
13890
+ }
13585
13891
  if (!this.mouseDownStatus) {
13586
13892
  if (focusedElement.classList.contains('e-disable')) {
13587
13893
  focusedElement.setAttribute('tabindex', '-1');
@@ -13608,6 +13914,9 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13608
13914
  removeClass([focusedElement], FOCUS);
13609
13915
  removeClass([focusedElement], FOCUSED$1);
13610
13916
  EventHandler.remove(focusedElement, 'blur', this.focusOut);
13917
+ if (this.enableVirtualization) {
13918
+ this.virtualFocusedId = null;
13919
+ }
13611
13920
  }
13612
13921
  }
13613
13922
  onMouseOver(e) {
@@ -13819,6 +14128,19 @@ let TreeView = TreeView_1 = class TreeView extends Component {
13819
14128
  else {
13820
14129
  txtEle[ListBase.getDomSetter(this.listBaseOption)] = ListBase.getSanitizedText(newText, this.listBaseOption);
13821
14130
  }
14131
+ // ── Virtual mode: sync the flat-node snapshot so re-renders show updated text ──
14132
+ // virtualFlatNodes is rebuilt from this.treeData (via flattenVisibleNodes) only on
14133
+ // expand/collapse/scroll. Between those events the snapshot is the single source of
14134
+ // truth used by createVirtualListItem. setValue() above updates this.editData (which
14135
+ // IS the treeData object reference), but virtualFlatNodes[idx].text still holds the
14136
+ // old value. Update it here so the next renderVirtualItems() call shows the new text.
14137
+ if (this.enableVirtualization) {
14138
+ const id = liEle.getAttribute('data-uid');
14139
+ const idx = this.virtualNodeMap ? this.virtualNodeMap.get(id) : undefined;
14140
+ if (!isNullOrUndefined(idx) && idx > -1) {
14141
+ this.virtualFlatNodes[parseInt(idx.toString(), 10)].text = newText;
14142
+ }
14143
+ }
13822
14144
  if (isInput) {
13823
14145
  removeClass([liEle], EDITING);
13824
14146
  liEle.focus();
@@ -15188,6 +15510,31 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15188
15510
  keyDownHandler(e) {
15189
15511
  if (e.key === 'Tab' || e.keyCode === 9) {
15190
15512
  this.isKeyboardFocus = true;
15513
+ // Mark the upcoming scroll event (if any) as Tab-induced so the
15514
+ // virtual scroll handler skips its re-render path. The browser
15515
+ // calls `Element.scrollIntoView` semantics on focus, which scrolls
15516
+ // a `<li tabindex="0">` in the middle of the tree into view and
15517
+ // therefore fires `scroll` on the container. Without this guard
15518
+ // the scroll handler would tear down the focused `<li>` and move
15519
+ // focus elsewhere.
15520
+ if (this.enableVirtualization) {
15521
+ this.virtualSkipNextScrollRender = true;
15522
+ }
15523
+ }
15524
+ // ── Fix: Also skip scroll render for arrow key navigation ──────────────────
15525
+ // Arrow Down/Up calls focusNextNode() which calls ensureVirtualNodeVisible()
15526
+ // to scroll the target node into view. Without this guard, the scroll event
15527
+ // would trigger onVirtualScroll() which re-renders the window and resets the
15528
+ // focus that focusNextNode() just set. This manifests as tabindex resetting
15529
+ // to the first item instead of staying on the arrow-navigated target.
15530
+ // Arrow Right/Left (expand/collapse) also calls renderVirtualItems() which
15531
+ // calls li.focus() in branch 2; that can trigger a scroll on an edge node,
15532
+ // so we consume it here to prevent a stale RAF re-render.
15533
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.keyCode === 40 || e.keyCode === 38
15534
+ || e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.keyCode === 39 || e.keyCode === 37) {
15535
+ if (this.enableVirtualization) {
15536
+ this.virtualSkipNextScrollRender = true;
15537
+ }
15191
15538
  }
15192
15539
  }
15193
15540
  preventContextMenu(e) {
@@ -15541,6 +15888,18 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15541
15888
  if (this.dataType === 1) {
15542
15889
  this.groupedData = this.getGroupedData(this.treeData, this.fields.parentID);
15543
15890
  }
15891
+ // In virtual mode (with a fixed height), rebuild the flat node list and re-render
15892
+ // after deletion so the removed nodes are correctly reflected in the hierarchical
15893
+ // virtual DOM. When height is not provided, the tree uses normal rendering and
15894
+ // the DOM is already updated by removeNode() above.
15895
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
15896
+ this.virtualFlatNodes = this.flattenVisibleNodes();
15897
+ this.rebuildVirtualNodeMap();
15898
+ const win = this.getVirtualWindow();
15899
+ this.virtualStartIndex = win.startIndex;
15900
+ this.virtualEndIndex = win.endIndex;
15901
+ this.renderVirtualItems();
15902
+ }
15544
15903
  this.triggerEvent('removeNode', nodeData);
15545
15904
  }
15546
15905
  editSucess(target, newText, prevent) {
@@ -15595,6 +15954,1030 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15595
15954
  }
15596
15955
  return parentsID;
15597
15956
  }
15957
+ // ═══════════════════════════════════════════════════════════════════════════════
15958
+ // VIRTUALIZATION — private helpers
15959
+ // ═══════════════════════════════════════════════════════════════════════════════
15960
+ /**
15961
+ * Parses the `height` property (string like '400px' or number) to a pixel value.
15962
+ * Returns 0 if the value cannot be determined.
15963
+ *
15964
+ * @returns {number} Pixel height.
15965
+ * @private
15966
+ */
15967
+ parseHeightToPixels() {
15968
+ const h = this.height;
15969
+ if (isNullOrUndefined(h)) {
15970
+ return 0;
15971
+ }
15972
+ if (typeof h === 'number') {
15973
+ return h;
15974
+ }
15975
+ const parsed = parseFloat(h);
15976
+ return isNaN(parsed) ? 0 : parsed;
15977
+ }
15978
+ /**
15979
+ * Measures the height of the first rendered list item so subsequent calculations
15980
+ * use real layout values. Falls back to the stored `virtualItemHeight` default (40).
15981
+ *
15982
+ * @returns {number} Row height in pixels.
15983
+ * @private
15984
+ */
15985
+ measureVirtualItemHeight() {
15986
+ const firstLi = select('.' + LISTITEM, this.element);
15987
+ if (firstLi && firstLi.offsetHeight > 0) {
15988
+ return firstLi.offsetHeight;
15989
+ }
15990
+ return this.virtualItemHeight;
15991
+ }
15992
+ /**
15993
+ * Rebuilds the cached Sets for checked/selected nodes to enable O(1) lookup
15994
+ * during the flatten operation instead of O(n) array indexOf calls.
15995
+ *
15996
+ * @returns {void}
15997
+ * @private
15998
+ */
15999
+ rebuildVirtualCheckCache() {
16000
+ this.virtualExpandedNodesSet.clear();
16001
+ this.virtualCheckedNodesSet.clear();
16002
+ this.virtualSelectedNodesSet.clear();
16003
+ if (this.expandedNodes && Array.isArray(this.expandedNodes)) {
16004
+ for (let i = 0; i < this.expandedNodes.length; i++) {
16005
+ this.virtualExpandedNodesSet.add(this.expandedNodes[i]);
16006
+ }
16007
+ }
16008
+ if (this.checkedNodes && Array.isArray(this.checkedNodes)) {
16009
+ for (let i = 0; i < this.checkedNodes.length; i++) {
16010
+ this.virtualCheckedNodesSet.add(this.checkedNodes[i]);
16011
+ }
16012
+ }
16013
+ if (this.selectedNodes && Array.isArray(this.selectedNodes)) {
16014
+ for (let i = 0; i < this.selectedNodes.length; i++) {
16015
+ this.virtualSelectedNodesSet.add(this.selectedNodes[i]);
16016
+ }
16017
+ }
16018
+ }
16019
+ /**
16020
+ * Recursively walks the treeData (or groupedData for flat lists) and builds
16021
+ * `virtualFlatNodes` — an ordered list of every node that is currently visible
16022
+ * (i.e. its ancestors are all expanded). Uses cached Sets for O(1) state lookups.
16023
+ *
16024
+ * @returns {VirtualNodeData[]} Ordered flat list of visible nodes.
16025
+ * @private
16026
+ */
16027
+ flattenVisibleNodes() {
16028
+ // Build cached Sets for fast lookups
16029
+ this.rebuildVirtualCheckCache();
16030
+ const result = [];
16031
+ if (this.dataType === 1) {
16032
+ // Flat data with parentID field — use getChildGroup to find root nodes
16033
+ let rootNodes = [];
16034
+ if (this.groupedData) {
16035
+ rootNodes = this.getChildGroup(this.groupedData, null, true);
16036
+ }
16037
+ if (!rootNodes || rootNodes.length === 0) {
16038
+ rootNodes = this.rootData;
16039
+ }
16040
+ // Apply sortOrder to root-level nodes for flat data (same as hierarchical path below)
16041
+ rootNodes = this.getSortedData(rootNodes);
16042
+ this.flattenNodeRecursive(rootNodes, 1, null, result);
16043
+ }
16044
+ else {
16045
+ // Hierarchical data
16046
+ const sorted = this.getSortedData(this.rootData);
16047
+ this.flattenNodeRecursive(sorted, 1, null, result);
16048
+ }
16049
+ return result;
16050
+ }
16051
+ /**
16052
+ * Recursive helper for `flattenVisibleNodes`.
16053
+ *
16054
+ * @param {Object[]} nodes - Array of raw data objects at this level.
16055
+ * @param {number} level - 1-based depth level.
16056
+ * @param {string | null} parentId - The parent node id, or null for root.
16057
+ * @param {VirtualNodeData[]} result - Accumulator array.
16058
+ * @returns {void}
16059
+ * @private
16060
+ */
16061
+ flattenNodeRecursive(nodes, level, parentId, result) {
16062
+ if (!nodes || nodes.length === 0) {
16063
+ return;
16064
+ }
16065
+ // Cache field names and settings outside the loop to avoid repeated conversions
16066
+ const childFieldName = this.fields.child.toString();
16067
+ const expandedFieldName = this.fields.expanded;
16068
+ const isCheckedFieldName = this.fields.isChecked;
16069
+ const iconCssFieldName = this.fields.iconCss;
16070
+ const imageUrlFieldName = this.fields.imageUrl;
16071
+ const idFieldName = this.fields.id;
16072
+ const textFieldName = this.fields.text;
16073
+ const needsSorting = this.sortOrder !== 'None';
16074
+ const isDataTypeFlat = this.dataType === 1;
16075
+ const enableVirtualization = this.enableVirtualization;
16076
+ for (let i = 0; i < nodes.length; i++) {
16077
+ const raw = nodes[i];
16078
+ const id = isNullOrUndefined(raw[idFieldName]) ? '' : raw[idFieldName].toString();
16079
+ const text = isNullOrUndefined(raw[textFieldName]) ? '' : raw[textFieldName].toString();
16080
+ // Use expandedNodes as the single source of truth.
16081
+ const inExpandedNodes = this.virtualExpandedNodesSet.has(id);
16082
+ const rawExpanded = !inExpandedNodes && getValue(expandedFieldName, raw) === true;
16083
+ const isExpanded = inExpandedNodes || rawExpanded;
16084
+ if (rawExpanded && id) {
16085
+ this.expandedNodes.push(id);
16086
+ this.virtualExpandedNodesSet.add(id);
16087
+ }
16088
+ // Inline hasChildRecords check to avoid function call (optimization)
16089
+ let hasChildren = false;
16090
+ if (isDataTypeFlat) {
16091
+ if (this.groupedData) {
16092
+ const groupedChild = this.getChildGroup(this.groupedData, id, false);
16093
+ hasChildren = !isNullOrUndefined(groupedChild) && groupedChild.length > 0;
16094
+ }
16095
+ }
16096
+ else {
16097
+ const childrenObj = getValue(childFieldName, raw);
16098
+ hasChildren = !isNullOrUndefined(childrenObj) && Array.isArray(childrenObj) && childrenObj.length > 0;
16099
+ }
16100
+ // Virtual-mode only: honour fields.isChecked === true on initial render
16101
+ if (enableVirtualization && id && isCheckedFieldName && !this.virtualCheckedNodesSet.has(id)) {
16102
+ const rawChecked = getValue(isCheckedFieldName, raw);
16103
+ if (!isNullOrUndefined(rawChecked) && rawChecked.toString() === 'true') {
16104
+ this.virtualCheckedNodesSet.add(id);
16105
+ if (this.checkedNodes && this.checkedNodes.indexOf(id) === -1) {
16106
+ this.checkedNodes.push(id);
16107
+ }
16108
+ }
16109
+ }
16110
+ // Use cached Sets for O(1) lookup
16111
+ const isSelected = this.virtualSelectedNodesSet.has(id);
16112
+ // Resolve checkbox state: indeterminate takes precedence over the checked set,
16113
+ // because a node can be in the indeterminate state without being in checkedNodes.
16114
+ const isChecked = (enableVirtualization && this.virtualIndeterminateNodesSet.has(id))
16115
+ ? 'indeterminate'
16116
+ : this.virtualCheckedNodesSet.has(id);
16117
+ const isDisabled = false; // TODO: Check if any per-node disabled field exists
16118
+ const iconCssVal = raw[iconCssFieldName];
16119
+ const iconCss = isNullOrUndefined(iconCssVal) ? '' : iconCssVal.toString();
16120
+ const imageUrlVal = raw[imageUrlFieldName];
16121
+ const imageUrl = isNullOrUndefined(imageUrlVal) ? '' : imageUrlVal.toString();
16122
+ const node = {
16123
+ id, text, level, parentId, hasChildren, isExpanded,
16124
+ isSelected, isChecked, isDisabled, iconCss, imageUrl, rawData: raw
16125
+ };
16126
+ result.push(node);
16127
+ if (isExpanded && hasChildren) {
16128
+ // Determine children list
16129
+ let children = [];
16130
+ if (isDataTypeFlat) {
16131
+ if (this.groupedData) {
16132
+ children = this.getChildGroup(this.groupedData, id, false);
16133
+ // Apply sortOrder to flat-data children (same as hierarchical path)
16134
+ if (needsSorting) {
16135
+ children = ListBase.getDataSource(children, ListBase.addSorting(this.sortOrder, this.fields.text));
16136
+ }
16137
+ }
16138
+ }
16139
+ else {
16140
+ const rawChildren = getValue(childFieldName, raw);
16141
+ if (!isNullOrUndefined(rawChildren) && Array.isArray(rawChildren)) {
16142
+ children = rawChildren;
16143
+ // Only sort if needed
16144
+ if (needsSorting) {
16145
+ children = ListBase.getDataSource(children, ListBase.addSorting(this.sortOrder, this.fields.text));
16146
+ }
16147
+ }
16148
+ }
16149
+ this.flattenNodeRecursive(children, level + 1, id, result);
16150
+ }
16151
+ }
16152
+ }
16153
+ /**
16154
+ * Checks whether a raw datasource node has child records.
16155
+ *
16156
+ * @param {Object} raw - Raw datasource object.
16157
+ * @returns {boolean} True if children exist.
16158
+ * @private
16159
+ */
16160
+ hasChildRecords(raw) {
16161
+ if (this.dataType === 1) {
16162
+ if (!this.groupedData) {
16163
+ return false;
16164
+ }
16165
+ const rawId = raw[this.fields.id];
16166
+ if (isNullOrUndefined(rawId)) {
16167
+ return false;
16168
+ }
16169
+ const groupedChild = this.getChildGroup(this.groupedData, rawId.toString(), false);
16170
+ return !isNullOrUndefined(groupedChild) && groupedChild.length > 0;
16171
+ }
16172
+ const childField = this.fields.child.toString();
16173
+ const children = getValue(childField, raw);
16174
+ return !isNullOrUndefined(children) && Array.isArray(children) && children.length > 0;
16175
+ }
16176
+ /**
16177
+ * Rebuilds `virtualNodeMap` (id → index) from the current `virtualFlatNodes`.
16178
+ *
16179
+ * @returns {void}
16180
+ * @private
16181
+ */
16182
+ rebuildVirtualNodeMap() {
16183
+ this.virtualNodeMap.clear();
16184
+ for (let i = 0; i < this.virtualFlatNodes.length; i++) {
16185
+ this.virtualNodeMap.set(this.virtualFlatNodes[parseInt(i.toString(), 10)].id, i);
16186
+ }
16187
+ }
16188
+ getVirtualWindow(scrollTop) {
16189
+ const total = this.virtualFlatNodes.length;
16190
+ if (total === 0) {
16191
+ return { startIndex: 0, endIndex: 0 };
16192
+ }
16193
+ const st = scrollTop !== undefined ? scrollTop : this.element.scrollTop;
16194
+ const itemH = this.virtualItemHeight;
16195
+ const vpH = this.virtualViewportHeight;
16196
+ const rawStart = Math.floor(st / itemH) - this.virtualOverscanCount;
16197
+ const rawEnd = Math.ceil((st + vpH) / itemH) + this.virtualOverscanCount;
16198
+ const startIndex = Math.max(0, rawStart);
16199
+ const endIndex = Math.min(total, rawEnd);
16200
+ return { startIndex, endIndex };
16201
+ }
16202
+ getVirtualWindowAt(scrollTop) {
16203
+ return this.getVirtualWindow(scrollTop);
16204
+ }
16205
+ getVirtualPadding() {
16206
+ const total = this.virtualFlatNodes.length;
16207
+ const itemH = this.virtualItemHeight;
16208
+ const start = this.virtualStartIndex;
16209
+ const end = this.virtualEndIndex;
16210
+ const paddingTop = start * itemH;
16211
+ const paddingBottom = Math.max(0, (total - end) * itemH);
16212
+ return { paddingTop, paddingBottom };
16213
+ }
16214
+ /**
16215
+ * Renders the virtual window into a **nested** `<ul>/<li>` hierarchy identical to
16216
+ * the non-virtualized DOM structure.
16217
+ *
16218
+ * ## Scroll math
16219
+ * The flat-index ↔ pixel mapping stays unchanged: every entry in
16220
+ * `virtualFlatNodes` represents exactly one `itemHeight` row.
16221
+ * `paddingTop = startIndex * itemHeight` and
16222
+ * `paddingBottom = (total - endIndex) * itemHeight` keep the scrollbar
16223
+ * thumb in the correct position.
16224
+ *
16225
+ * ## Ancestor injection
16226
+ * When `startIndex` points at a child node whose ancestor(s) have already
16227
+ * scrolled above the viewport (their flat indices are < startIndex), the
16228
+ * ancestors would normally be missing from the DOM, forcing the children to
16229
+ * fall back to the root `<ul>` as flat siblings — producing wrong HTML.
16230
+ *
16231
+ * To fix this, before rendering the window we walk the ancestor chain of the
16232
+ * first visible node and collect every ancestor that lies **outside** the
16233
+ * window. Those ancestors are injected as real `<li>` containers at the
16234
+ * very top of the root `<ul>`. To compensate for their presence, `paddingTop`
16235
+ * is reduced by `injectedCount * itemHeight` so the total scroll height
16236
+ * remains the same.
16237
+ *
16238
+ * Once the ancestors are in the DOM they are registered in `renderedParentMap`
16239
+ * so the normal window-rendering loop can nest children inside them exactly
16240
+ * as it does when both parent and child are in the same window.
16241
+ *
16242
+ * @returns {void}
16243
+ * @private
16244
+ */
16245
+ renderVirtualItems() {
16246
+ if (!this.ulElement) {
16247
+ return;
16248
+ }
16249
+ const ul = this.ulElement;
16250
+ // ── Snapshot focused ID BEFORE clearing the DOM ───────────────────────────
16251
+ // Removing a focused <li> from the DOM triggers a synchronous `blur` event,
16252
+ // which calls focusOut() and sets virtualFocusedId = null. Capturing the id
16253
+ // here (before any removeChild) ensures focus is correctly restored to the
16254
+ // same node after re-render even when keyboard navigation was in progress.
16255
+ const focusedUid = this.virtualFocusedId || null;
16256
+ // ── Clear previous render (direct children only) ─────────────────────────
16257
+ const existingItems = ul.querySelectorAll(':scope > li');
16258
+ for (let i = existingItems.length - 1; i >= 0; i--) {
16259
+ ul.removeChild(existingItems[parseInt(i.toString(), 10)]);
16260
+ }
16261
+ const renderedParentMap = new Map();
16262
+ const itemH = this.virtualItemHeight;
16263
+ let injectedCount = 0;
16264
+ if (this.virtualStartIndex < this.virtualFlatNodes.length) {
16265
+ const firstNode = this.virtualFlatNodes[this.virtualStartIndex];
16266
+ if (firstNode.parentId) {
16267
+ // Build the ancestor chain from root down to the immediate parent
16268
+ const ancestors = [];
16269
+ let searchId = firstNode.parentId;
16270
+ while (searchId) {
16271
+ const ancestorIdx = this.virtualNodeMap.get(searchId);
16272
+ if (!isNullOrUndefined(ancestorIdx) && ancestorIdx < this.virtualStartIndex) {
16273
+ const ancestor = this.virtualFlatNodes[parseInt(ancestorIdx.toString(), 10)];
16274
+ ancestors.unshift(ancestor); // prepend so root comes first
16275
+ searchId = ancestor.parentId;
16276
+ }
16277
+ else {
16278
+ break;
16279
+ }
16280
+ }
16281
+ // Inject each ancestor as a real <li> into the root <ul>
16282
+ // and register it in renderedParentMap for child-nesting below.
16283
+ for (let a = 0; a < ancestors.length; a++) {
16284
+ const ancestor = ancestors[parseInt(a.toString(), 10)];
16285
+ const ancestorLi = this.createVirtualListItem(ancestor, false);
16286
+ // Find the correct container: root or a previously injected ancestor's <ul>
16287
+ let targetUl = ul;
16288
+ if (ancestor.parentId && renderedParentMap.has(ancestor.parentId)) {
16289
+ const pe = renderedParentMap.get(ancestor.parentId);
16290
+ if (!pe.childUl) {
16291
+ pe.childUl = this.createElement('ul', {
16292
+ className: PARENTITEM + ' e-ul',
16293
+ attrs: { role: 'group' }
16294
+ });
16295
+ pe.li.appendChild(pe.childUl);
16296
+ }
16297
+ targetUl = pe.childUl;
16298
+ }
16299
+ targetUl.appendChild(ancestorLi);
16300
+ renderedParentMap.set(ancestor.id, { li: ancestorLi, childUl: null });
16301
+ injectedCount++;
16302
+ }
16303
+ }
16304
+ }
16305
+ // ── Scroll-space padding (adjusted for injected ancestors) ────────────────
16306
+ const total = this.virtualFlatNodes.length;
16307
+ const start = this.virtualStartIndex;
16308
+ const end = this.virtualEndIndex;
16309
+ const rawPaddingTop = start * itemH;
16310
+ // Subtract the height of injected ancestors so the scroll position is
16311
+ // exact: those ancestor rows are now real DOM elements, not hidden space.
16312
+ const adjustedPaddingTop = Math.max(0, rawPaddingTop - injectedCount * itemH);
16313
+ ul.style.paddingTop = adjustedPaddingTop + 'px';
16314
+ ul.style.paddingBottom = Math.max(0, (total - end) * itemH) + 'px';
16315
+ // ── Render window items (nested via renderedParentMap) ────────────────────
16316
+ const liListBuf = [];
16317
+ for (let i = start; i < end; i++) {
16318
+ const node = this.virtualFlatNodes[parseInt(i.toString(), 10)];
16319
+ const li = this.createVirtualListItem(node, i === start);
16320
+ // Find the correct parent container
16321
+ let targetUl = ul;
16322
+ if (node.parentId && renderedParentMap.has(node.parentId)) {
16323
+ const parentEntry = renderedParentMap.get(node.parentId);
16324
+ // Lazily create the child <ul> the first time a child needs it
16325
+ if (!parentEntry.childUl) {
16326
+ parentEntry.childUl = this.createElement('ul', {
16327
+ className: PARENTITEM + ' e-ul',
16328
+ attrs: { role: 'group' }
16329
+ });
16330
+ parentEntry.li.appendChild(parentEntry.childUl);
16331
+ }
16332
+ targetUl = parentEntry.childUl;
16333
+ }
16334
+ targetUl.appendChild(li);
16335
+ liListBuf.push(li);
16336
+ // Register this node so its children in the same window can nest under it
16337
+ if (node.isExpanded && node.hasChildren) {
16338
+ renderedParentMap.set(node.id, { li, childUl: null });
16339
+ }
16340
+ }
16341
+ // ── Restore focus / tabindex ──────────────────────────────────────────────
16342
+ // In virtual mode only the li that carries the 'e-active' class should be
16343
+ // focusable (tabindex='0'). All other rendered li elements must be set
16344
+ // to tabindex='-1' so the tab order remains unambiguous as the window
16345
+ // changes during scrolling.
16346
+ let focusRestored = false;
16347
+ // 1. Prefer the currently active node if it is in the rendered window.
16348
+ for (let k = 0; k < liListBuf.length; k++) {
16349
+ const li = liListBuf[parseInt(k.toString(), 10)];
16350
+ if (li.classList.contains(ACTIVE)) {
16351
+ li.setAttribute('tabindex', '0');
16352
+ // ── Fix: ensure FOCUS class set on active node too ──
16353
+ // This ensures focusIn() guard condition is met: tabindex='0' && FOCUS class
16354
+ addClass([li], FOCUS);
16355
+ focusRestored = true;
16356
+ break;
16357
+ }
16358
+ }
16359
+ // 2. Otherwise fall back to the previously focused node (if it is in window).
16360
+ if (!focusRestored && focusedUid) {
16361
+ for (let k = 0; k < liListBuf.length; k++) {
16362
+ const li = liListBuf[parseInt(k.toString(), 10)];
16363
+ if (li.getAttribute('data-uid') === focusedUid) {
16364
+ li.setAttribute('tabindex', '0');
16365
+ addClass([li], FOCUS);
16366
+ addClass([li], FOCUSED$1);
16367
+ // ── Fix: restore actual browser focus so keyboard events keep working ──
16368
+ // After renderVirtualItems() replaces all <li> elements the old element
16369
+ // is gone from the DOM and document.activeElement falls back to
16370
+ // document.body. Setting tabindex + classes is not enough — the new
16371
+ // element also needs a programmatic .focus() call so subsequent keyboard
16372
+ // events (Arrow keys, Home, End, etc.) are delivered to the TreeView.
16373
+ // Re-attach the blur→focusOut handler so virtualFocusedId is cleared
16374
+ // correctly when the user tabs away.
16375
+ // ── Use preventScroll:true ────────────────────────────────────────────
16376
+ // Without preventScroll the browser scrolls the container to bring this
16377
+ // node into view during the re-render, changing scrollTop AFTER the
16378
+ // correct paddingTop has been applied. This is the root cause of the
16379
+ // KBN expand position-jump bug: the render is correct but the browser
16380
+ // silently scrolls the viewport, making the parent node appear to jump.
16381
+ // ensureVirtualNodeVisible() handles any legitimate scroll-into-view
16382
+ // needs before renderVirtualItems() is called, so scroll here is wrong.
16383
+ EventHandler.add(li, 'blur', this.focusOut, this);
16384
+ li.focus({ preventScroll: true });
16385
+ focusRestored = true;
16386
+ break;
16387
+ }
16388
+ }
16389
+ }
16390
+ // 3. Finally default to the first item in the window.
16391
+ if (!focusRestored && liListBuf.length > 0) {
16392
+ const firstLi = liListBuf[0];
16393
+ firstLi.setAttribute('tabindex', '0');
16394
+ }
16395
+ // ── Fix: Ensure all other rendered nodes have tabindex="-1" ──
16396
+ // This prevents stale tabindex="0" on multiple nodes which breaks Tab order
16397
+ for (let k = 0; k < liListBuf.length; k++) {
16398
+ const li = liListBuf[parseInt(k.toString(), 10)];
16399
+ if (li.getAttribute('tabindex') !== '0') {
16400
+ li.setAttribute('tabindex', '-1');
16401
+ }
16402
+ }
16403
+ // Keep liList in sync so keyboard-navigation helpers stay functional.
16404
+ this.liList = liListBuf;
16405
+ }
16406
+ /**
16407
+ * Creates a single `<li>` element for the virtual window representing `node`.
16408
+ *
16409
+ * @param {VirtualNodeData} node - The node data.
16410
+ * @param {boolean} _isFirst - True if this is the first rendered item (unused here but kept for potential override).
16411
+ * @returns {HTMLElement} The constructed `<li>` element.
16412
+ * @private
16413
+ */
16414
+ createVirtualListItem(node, _isFirst) {
16415
+ const li = this.createElement('li', { className: LISTITEM });
16416
+ // Level class for CSS indentation
16417
+ li.classList.add('e-level-' + node.level);
16418
+ li.setAttribute('data-uid', node.id);
16419
+ li.setAttribute('aria-level', node.level.toString());
16420
+ li.setAttribute('role', 'treeitem');
16421
+ li.setAttribute('tabindex', '-1');
16422
+ // Full-row overlay (required for fullRowSelect mode)
16423
+ if (this.fullRowSelect) {
16424
+ const fullRow = this.createElement('div', { className: FULLROW });
16425
+ li.appendChild(fullRow);
16426
+ }
16427
+ // Text/icon wrapper
16428
+ const textWrap = this.createElement('div', { className: TEXTWRAP });
16429
+ // Expand/collapse icon
16430
+ if (node.hasChildren) {
16431
+ const iconSpan = this.createElement('div', {
16432
+ className: ICON + ' ' + (node.isExpanded ? COLLAPSIBLE : EXPANDABLE)
16433
+ });
16434
+ textWrap.appendChild(iconSpan);
16435
+ }
16436
+ // Checkbox
16437
+ if (this.showCheckBox) {
16438
+ const checkWrap = createCheckBox(this.createElement, false, {
16439
+ checked: node.isChecked === true,
16440
+ cssClass: CHECKBOXWRAP
16441
+ });
16442
+ if (node.isChecked === 'indeterminate') {
16443
+ const frame = checkWrap.querySelector('.' + CHECKBOXFRAME);
16444
+ if (frame) {
16445
+ frame.classList.add(INDETERMINATE);
16446
+ }
16447
+ }
16448
+ textWrap.appendChild(checkWrap);
16449
+ }
16450
+ // Icon
16451
+ if (node.iconCss) {
16452
+ const iconEl = this.createElement('span', {
16453
+ className: ICON + ' ' + node.iconCss
16454
+ });
16455
+ textWrap.appendChild(iconEl);
16456
+ }
16457
+ // Image
16458
+ if (node.imageUrl) {
16459
+ const img = document.createElement('img');
16460
+ img.src = node.imageUrl;
16461
+ img.classList.add(IMAGE);
16462
+ textWrap.appendChild(img);
16463
+ }
16464
+ // Text anchor
16465
+ const anchor = this.createElement('span', {
16466
+ className: 'e-list-text',
16467
+ innerHTML: node.text
16468
+ });
16469
+ textWrap.appendChild(anchor);
16470
+ li.appendChild(textWrap);
16471
+ // State classes
16472
+ if (node.isExpanded) {
16473
+ li.classList.add(EXPANDED);
16474
+ li.setAttribute('aria-expanded', 'true');
16475
+ }
16476
+ else if (node.hasChildren) {
16477
+ li.classList.add(NODECOLLAPSED);
16478
+ li.setAttribute('aria-expanded', 'false');
16479
+ }
16480
+ if (node.isSelected) {
16481
+ li.classList.add(ACTIVE);
16482
+ li.setAttribute('aria-selected', 'true');
16483
+ }
16484
+ if (node.isDisabled) {
16485
+ li.classList.add(DISABLED$1);
16486
+ li.setAttribute('aria-disabled', 'true');
16487
+ }
16488
+ if (node.hasChildren) {
16489
+ li.classList.add(CHILD);
16490
+ }
16491
+ // Wire the focus event so `focusIn()` fires when the user Tabs into this
16492
+ // item. The non-virtualized path registers this in `beforeNodeCreate()`,
16493
+ // but virtual items never go through that callback, so we must register
16494
+ // it here to keep focus styling (e.g. `e-focused`) consistent between
16495
+ // virtual and non-virtual modes. `unWireEvents()` already removes this
16496
+ // handler during teardown by iterating over the rendered `<li>`s.
16497
+ EventHandler.add(li, 'focus', this.focusIn, this);
16498
+ return li;
16499
+ }
16500
+ /**
16501
+ * Initialises the virtual-scrolling layout:
16502
+ * 1. Adds the `e-virtualization` CSS class and applies a fixed `height` + `overflow-y`.
16503
+ * 2. Flattens all visible nodes into `virtualFlatNodes`.
16504
+ * 3. Measures item height and viewport height.
16505
+ * 4. Computes the initial window and renders it.
16506
+ * 5. Wires the scroll event handler.
16507
+ *
16508
+ * @returns {void}
16509
+ * @private
16510
+ */
16511
+ initVirtualization() {
16512
+ addClass([this.element], VIRTUALIZATION);
16513
+ // Apply container height
16514
+ const px = this.parseHeightToPixels();
16515
+ if (px > 0) {
16516
+ this.element.style.height = px + 'px';
16517
+ this.element.style.overflowY = 'auto';
16518
+ }
16519
+ // Ensure the inner UL is ready
16520
+ if (!this.ulElement) {
16521
+ this.ulElement = select('.' + PARENTITEM, this.element);
16522
+ }
16523
+ if (!this.ulElement) {
16524
+ return;
16525
+ }
16526
+ // Make the UL position-relative for the absolute spacers
16527
+ this.ulElement.style.position = 'relative';
16528
+ // Rebuild flat list
16529
+ this.virtualFlatNodes = this.flattenVisibleNodes();
16530
+ this.rebuildVirtualNodeMap();
16531
+ // Measure dimensions
16532
+ this.virtualViewportHeight = px > 0 ? px : this.element.offsetHeight;
16533
+ // Render a single probe item to measure the real item height before the full render
16534
+ if (this.virtualFlatNodes.length > 0) {
16535
+ const probe = this.createVirtualListItem(this.virtualFlatNodes[0], true);
16536
+ this.ulElement.appendChild(probe);
16537
+ this.virtualItemHeight = probe.offsetHeight > 0 ? probe.offsetHeight : this.virtualItemHeight;
16538
+ this.ulElement.removeChild(probe);
16539
+ }
16540
+ // Compute window and render all items in the initial viewport
16541
+ const win = this.getVirtualWindow();
16542
+ this.virtualStartIndex = win.startIndex;
16543
+ this.virtualEndIndex = win.endIndex;
16544
+ this.renderVirtualItems();
16545
+ // Wire scroll
16546
+ this.wireVirtualScrollEvents();
16547
+ }
16548
+ /**
16549
+ * Wires the scroll event listener on the TreeView element.
16550
+ *
16551
+ * @returns {void}
16552
+ * @private
16553
+ */
16554
+ wireVirtualScrollEvents() {
16555
+ this.unwireVirtualScrollEvents();
16556
+ this.virtualScrollBound = this.onVirtualScroll.bind(this);
16557
+ this.element.addEventListener('scroll', this.virtualScrollBound, { passive: true });
16558
+ }
16559
+ /**
16560
+ * Removes the scroll event listener previously wired by `wireVirtualScrollEvents`.
16561
+ *
16562
+ * @returns {void}
16563
+ * @private
16564
+ */
16565
+ unwireVirtualScrollEvents() {
16566
+ if (this.virtualScrollBound) {
16567
+ this.element.removeEventListener('scroll', this.virtualScrollBound);
16568
+ this.virtualScrollBound = null;
16569
+ }
16570
+ if (this.virtualScrollRAF !== null) {
16571
+ cancelAnimationFrame(this.virtualScrollRAF);
16572
+ this.virtualScrollRAF = null;
16573
+ }
16574
+ }
16575
+ /**
16576
+ * Scroll handler for the virtual TreeView. Throttled via `requestAnimationFrame`.
16577
+ * Shows the loading mask during fast scrolls, re-renders the window, then hides the mask.
16578
+ *
16579
+ * @returns {void}
16580
+ * @private
16581
+ */
16582
+ onVirtualScroll() {
16583
+ if (this.virtualScrollRAF !== null) {
16584
+ cancelAnimationFrame(this.virtualScrollRAF);
16585
+ }
16586
+ // ── Tab-induced focus guard ────────────────────────────────────────────────
16587
+ // When the user Tabs into a `<li tabindex="0">` that is in the middle of
16588
+ // the treeview, the browser auto-scrolls the container so the focused
16589
+ // element becomes visible. That fires a `scroll` event here, but the
16590
+ // virtual window already contains the focused element — re-rendering
16591
+ // would destroy the focused `<li>` and break focus. Detect this case
16592
+ // via the flag set in `keyDownHandler` and skip the re-render entirely.
16593
+ if (this.virtualSkipNextScrollRender) {
16594
+ this.virtualSkipNextScrollRender = false;
16595
+ // Update prev scrollTop so a subsequent user-driven scroll still
16596
+ // calculates the correct delta.
16597
+ this.virtualPrevScrollTop = this.element.scrollTop;
16598
+ return;
16599
+ }
16600
+ const currentScrollTop = this.element.scrollTop;
16601
+ const delta = Math.abs(currentScrollTop - this.virtualPrevScrollTop);
16602
+ const fastScrollThreshold = this.virtualViewportHeight * 2;
16603
+ if (delta > fastScrollThreshold) {
16604
+ this.showVirtualMask(currentScrollTop);
16605
+ }
16606
+ this.virtualScrollRAF = requestAnimationFrame(() => {
16607
+ this.virtualScrollRAF = null;
16608
+ this.virtualPrevScrollTop = this.element.scrollTop;
16609
+ const win = this.getVirtualWindow();
16610
+ this.virtualStartIndex = win.startIndex;
16611
+ this.virtualEndIndex = win.endIndex;
16612
+ this.renderVirtualItems();
16613
+ // Hide the skeleton mask after a short delay so it stays visible long enough
16614
+ // for the user to perceive the loading indicator even on short scrolls.
16615
+ setTimeout(() => {
16616
+ this.hideVirtualMask();
16617
+ // Keep the (now hidden) mask's coords fresh for the next show.
16618
+ this.updateMaskPosition();
16619
+ }, 200);
16620
+ });
16621
+ }
16622
+ /**
16623
+ * Creates the skeleton mask overlay used to indicate loading during fast scrolls.
16624
+ * Skeleton instances are stored in `skeletonInstances` for later disposal.
16625
+ *
16626
+ * @returns {void}
16627
+ * @private
16628
+ */
16629
+ createVirtualMask() {
16630
+ if (this.virtualMaskElement) {
16631
+ return;
16632
+ }
16633
+ // Use position:fixed so the mask is anchored to the visible viewport (not to the
16634
+ // scrolling container). Width/height/left/top are set/refreshed by
16635
+ // updateMaskPosition() so the mask always covers the TreeView's visible area
16636
+ // regardless of how the page itself is scrolled.
16637
+ const rect = this.element.getBoundingClientRect();
16638
+ const scrollBarWidth = this.element.offsetWidth - this.element.clientWidth;
16639
+ const maskParent = this.createElement('div', {
16640
+ className: VIRTUAL_MASK_PARENT,
16641
+ styles: `position:fixed;top:${rect.top}px;left:${rect.left}px;width:${rect.width - scrollBarWidth}px;height:${rect.height}px;z-index:10;overflow:hidden;background:#fff;pointer-events:none;display:none;`
16642
+ });
16643
+ const visibleRows = Math.ceil(this.virtualViewportHeight / this.virtualItemHeight) + 1;
16644
+ for (let r = 0; r < visibleRows; r++) {
16645
+ const maskItem = this.createElement('div', {
16646
+ className: VIRTUAL_MASK_ITEM,
16647
+ styles: `display:flex;align-items:center;height:${this.virtualItemHeight}px;padding:0 12px;gap:8px;`
16648
+ });
16649
+ // Skeleton circle for icon placeholder
16650
+ // const iconHolder: HTMLElement = this.createElement('span', {
16651
+ // styles: `width:20px;height:20px;`
16652
+ // });
16653
+ // maskItem.appendChild(iconHolder);
16654
+ // const iconSkel: Skeleton = new Skeleton({
16655
+ // shape: 'Circle',
16656
+ // width: '20px',
16657
+ // height: '20px',
16658
+ // cssClass: 'e-virtual-skel-icon'
16659
+ // });
16660
+ // iconSkel.appendTo(iconHolder);
16661
+ // this.skeletonInstances.push(iconSkel);
16662
+ // Skeleton text bar
16663
+ const textHolder = this.createElement('span');
16664
+ textHolder.style.flex = '1';
16665
+ textHolder.style.height = '14px';
16666
+ maskItem.appendChild(textHolder);
16667
+ const textSkel = new Skeleton({
16668
+ shape: 'Text',
16669
+ width: '80%',
16670
+ height: '14px',
16671
+ cssClass: 'e-virtual-skel-text'
16672
+ });
16673
+ textSkel.appendTo(textHolder);
16674
+ this.skeletonInstances.push(textSkel);
16675
+ maskParent.appendChild(maskItem);
16676
+ }
16677
+ this.virtualMaskElement = maskParent;
16678
+ this.element.appendChild(maskParent);
16679
+ }
16680
+ /**
16681
+ * Refreshes the mask position so it always overlays the TreeView's viewport.
16682
+ * Called on every show / scroll, since `position:fixed` is relative to the
16683
+ * page viewport, not to the TreeView scroll container.
16684
+ *
16685
+ * @returns {void}
16686
+ * @private
16687
+ */
16688
+ updateMaskPosition() {
16689
+ if (!this.virtualMaskElement) {
16690
+ return;
16691
+ }
16692
+ const rect = this.element.getBoundingClientRect();
16693
+ const scrollBarWidth = this.element.offsetWidth - this.element.clientWidth;
16694
+ this.virtualMaskElement.style.top = rect.top + 'px';
16695
+ this.virtualMaskElement.style.left = rect.left + 'px';
16696
+ this.virtualMaskElement.style.width = (rect.width - scrollBarWidth) + 'px';
16697
+ this.virtualMaskElement.style.height = rect.height + 'px';
16698
+ }
16699
+ /**
16700
+ * Destroys all skeleton instances and removes the mask overlay from the DOM.
16701
+ *
16702
+ * @returns {void}
16703
+ * @private
16704
+ */
16705
+ destroyVirtualMask() {
16706
+ for (let i = this.skeletonInstances.length - 1; i >= 0; i--) {
16707
+ this.skeletonInstances[parseInt(i.toString(), 10)].destroy();
16708
+ }
16709
+ this.skeletonInstances = [];
16710
+ if (this.virtualMaskElement) {
16711
+ if (this.virtualMaskElement.parentElement) {
16712
+ this.virtualMaskElement.parentElement.removeChild(this.virtualMaskElement);
16713
+ }
16714
+ this.virtualMaskElement = null;
16715
+ }
16716
+ }
16717
+ /**
16718
+ * Positions and shows the skeleton mask at the current `scrollTop` position.
16719
+ *
16720
+ * @param {number} _scrollTop - Current scrollTop of the container.
16721
+ * @returns {void}
16722
+ * @private
16723
+ */
16724
+ showVirtualMask(_scrollTop) {
16725
+ if (!this.virtualMaskElement) {
16726
+ this.createVirtualMask();
16727
+ }
16728
+ if (this.virtualMaskElement) {
16729
+ // Refresh the fixed-position coords in case the TreeView was moved/resized
16730
+ // since the last time the mask was shown.
16731
+ this.updateMaskPosition();
16732
+ this.virtualMaskElement.style.display = 'block';
16733
+ // Visually hide the real list while the skeleton is shown
16734
+ if (this.ulElement) {
16735
+ this.ulElement.style.visibility = 'hidden';
16736
+ }
16737
+ }
16738
+ }
16739
+ /**
16740
+ * Hides the skeleton mask overlay after the virtual window has been rendered.
16741
+ *
16742
+ * @returns {void}
16743
+ * @private
16744
+ */
16745
+ hideVirtualMask() {
16746
+ if (this.virtualMaskElement) {
16747
+ this.virtualMaskElement.style.display = 'none';
16748
+ // Restore real list visibility
16749
+ if (this.ulElement) {
16750
+ this.ulElement.style.visibility = '';
16751
+ }
16752
+ }
16753
+ }
16754
+ /**
16755
+ * Ensures the node identified by `id` is scrolled into view inside the virtual container.
16756
+ * If the node falls outside the rendered window, the container's `scrollTop` is adjusted so
16757
+ * that the node becomes visible, the window is re-computed and `renderVirtualItems` is called.
16758
+ *
16759
+ * @param {string} id - The `data-uid` of the target node.
16760
+ * @returns {void}
16761
+ * @private
16762
+ */
16763
+ ensureVirtualNodeVisible(id) {
16764
+ const idx = this.virtualNodeMap.get(id);
16765
+ if (isNullOrUndefined(idx)) {
16766
+ return;
16767
+ }
16768
+ const itemH = this.virtualItemHeight;
16769
+ const vpH = this.virtualViewportHeight;
16770
+ const nodeTop = idx * itemH;
16771
+ const nodeBottom = nodeTop + itemH;
16772
+ const currentTop = this.element.scrollTop;
16773
+ const currentBottom = currentTop + vpH;
16774
+ if (nodeTop < currentTop) {
16775
+ this.element.scrollTop = nodeTop;
16776
+ }
16777
+ else if (nodeBottom > currentBottom) {
16778
+ this.element.scrollTop = nodeBottom - vpH;
16779
+ }
16780
+ else {
16781
+ return; // already visible, no re-render needed
16782
+ }
16783
+ // Re-render the new window synchronously so the <li> is in the DOM
16784
+ const win = this.getVirtualWindow();
16785
+ this.virtualStartIndex = win.startIndex;
16786
+ this.virtualEndIndex = win.endIndex;
16787
+ this.renderVirtualItems();
16788
+ }
16789
+ handleVirtualExpand(id, isKeyboardNav) {
16790
+ // ── Cancel any pending scroll RAF ─────────────────────────────────────────
16791
+ // A scroll RAF queued before the expand click would run asynchronously after
16792
+ // this synchronous expand completes, re-rendering the window from a stale
16793
+ // scrollTop and moving the parent node to the wrong position. Cancel it now
16794
+ // so the expand render is authoritative.
16795
+ if (this.virtualScrollRAF !== null) {
16796
+ cancelAnimationFrame(this.virtualScrollRAF);
16797
+ this.virtualScrollRAF = null;
16798
+ }
16799
+ if (this.expandedNodes.indexOf(id) === -1) {
16800
+ this.expandedNodes.push(id);
16801
+ }
16802
+ // Also update virtualExpandedNodesSet to keep it in sync
16803
+ if (this.virtualExpandedNodesSet) {
16804
+ this.virtualExpandedNodesSet.add(id);
16805
+ }
16806
+ if (this.rootData) {
16807
+ const acutalRootData = this.rootData.find((item) => item.id === parseInt(id, 10));
16808
+ acutalRootData.isExpanded = true;
16809
+ }
16810
+ // ── Fix: Pin focused ID to the expanding node for keyboard navigation ──
16811
+ // When keyboard-triggered, the expanded node IS definitively the focused node
16812
+ // (`id` comes from currLi.getAttribute('data-uid') in expandNode, where currLi
16813
+ // is getFocusedNode()). Set unconditionally — the old conditional guard
16814
+ // `!this.virtualFocusedId` could leave a stale or null value if a previous
16815
+ // RAF render (from arrow-key scroll) hadn't yet completed, causing branch 3
16816
+ // (first item) to fire in renderVirtualItems() instead of branch 2.
16817
+ // For mouse clicks (isKeyboardNav=false), clear virtualFocusedId so that
16818
+ // renderVirtualItems() Branch 2 does NOT apply the FOCUS/FOCUSED classes —
16819
+ // mouse-based expand/collapse should never add the e-focused class.
16820
+ if (isKeyboardNav) {
16821
+ this.virtualFocusedId = id;
16822
+ }
16823
+ else {
16824
+ this.virtualFocusedId = null;
16825
+ }
16826
+ this.virtualFlatNodes = this.flattenVisibleNodes();
16827
+ this.rebuildVirtualNodeMap();
16828
+ // ── Snapshot scrollTop before computing the window ────────────────────────
16829
+ // Read scrollTop once here and pass it into getVirtualWindow so that any
16830
+ // browser scroll-anchoring adjustment that happens when the padding values
16831
+ // change cannot affect which window we render.
16832
+ const scrollTopSnapshot = this.element.scrollTop;
16833
+ const win = this.getVirtualWindowAt(scrollTopSnapshot);
16834
+ this.virtualStartIndex = win.startIndex;
16835
+ this.virtualEndIndex = win.endIndex;
16836
+ // ── Block ALL scroll-triggered re-renders during this expand ─────────────
16837
+ // renderVirtualItems() calls li.focus() in branch 2 (keyboard focus restore).
16838
+ // The post-render focus() call below can also trigger browser scroll. Both
16839
+ // can fire the 'scroll' event and cause onVirtualScroll() to queue a RAF.
16840
+ // We set the skip flag ONCE here; it is consumed by the first scroll event.
16841
+ // To cover the second potential scroll (from the focus() call below) we use
16842
+ // preventScroll:true on that call so it never moves the container at all.
16843
+ this.virtualSkipNextScrollRender = true;
16844
+ this.renderVirtualItems();
16845
+ // renderVirtualItems() replaces all <li> elements so the old icon element that
16846
+ // had the PROCESS class is gone. Remove PROCESS from the newly rendered icon
16847
+ // so the node can be collapsed/expanded again without being blocked.
16848
+ const newLi = this.getElement(id);
16849
+ if (newLi) {
16850
+ const icon = select('.' + ICON, newLi);
16851
+ if (icon) {
16852
+ removeClass([icon], PROCESS);
16853
+ }
16854
+ // ── Use preventScroll:true ────────────────────────────────────────────
16855
+ // Without preventScroll the browser scrolls the container to bring this
16856
+ // node into view, which fires a second 'scroll' event. The first scroll
16857
+ // was already consumed by virtualSkipNextScrollRender (set above), so the
16858
+ // second scroll would queue a new RAF that re-renders from the
16859
+ // focus-adjusted scrollTop — moving the parent node to the wrong position.
16860
+ newLi.focus({ preventScroll: true });
16861
+ }
16862
+ if (this.isLoaded) {
16863
+ const currLi = this.getElement(id);
16864
+ if (currLi) {
16865
+ const expandArgs = this.getExpandEvent(currLi, null);
16866
+ expandArgs.isInteracted = this.isInteracted;
16867
+ this.trigger('nodeExpanded', expandArgs);
16868
+ }
16869
+ }
16870
+ }
16871
+ handleVirtualCollapse(id, isKeyboardNav) {
16872
+ // ── Cancel any pending scroll RAF ─────────────────────────────────────────
16873
+ // Mirrors handleVirtualExpand: a queued RAF from a previous scroll event
16874
+ // must not override the collapse render with a stale window.
16875
+ if (this.virtualScrollRAF !== null) {
16876
+ cancelAnimationFrame(this.virtualScrollRAF);
16877
+ this.virtualScrollRAF = null;
16878
+ }
16879
+ const idx = this.expandedNodes.indexOf(id);
16880
+ if (idx !== -1) {
16881
+ this.expandedNodes.splice(idx, 1);
16882
+ }
16883
+ // Also update virtualExpandedNodesSet to keep it in sync
16884
+ if (this.virtualExpandedNodesSet) {
16885
+ this.virtualExpandedNodesSet.delete(id);
16886
+ }
16887
+ if (this.rootData) {
16888
+ const acutalRootData = this.rootData.find((item) => item.id === parseInt(id, 10));
16889
+ acutalRootData.isExpanded = false;
16890
+ }
16891
+ // ── Fix: Pin focused ID to the collapsing node for keyboard navigation ──
16892
+ // Same reasoning as handleVirtualExpand: when keyboard-triggered, `id` is
16893
+ // definitively the focused node. Set unconditionally to avoid the stale/null
16894
+ // state that could cause the RAF re-render to fall through to branch 3.
16895
+ // For mouse clicks (isKeyboardNav=false), clear virtualFocusedId so that
16896
+ // renderVirtualItems() Branch 2 does NOT apply the FOCUS/FOCUSED classes —
16897
+ // mouse-based expand/collapse should never add the e-focused class.
16898
+ if (isKeyboardNav) {
16899
+ this.virtualFocusedId = id;
16900
+ }
16901
+ else {
16902
+ this.virtualFocusedId = null;
16903
+ }
16904
+ this.virtualFlatNodes = this.flattenVisibleNodes();
16905
+ this.rebuildVirtualNodeMap();
16906
+ // ── Snapshot scrollTop before computing the window ────────────────────────
16907
+ // Same rationale as handleVirtualExpand: capture scrollTop before any focus()
16908
+ // call or padding change can cause the browser to auto-scroll.
16909
+ const scrollTopSnapshot = this.element.scrollTop;
16910
+ const win = this.getVirtualWindowAt(scrollTopSnapshot);
16911
+ this.virtualStartIndex = win.startIndex;
16912
+ this.virtualEndIndex = win.endIndex;
16913
+ // ── Block ALL scroll-triggered re-renders during this collapse ────────────
16914
+ // Same as handleVirtualExpand: use preventScroll:true on the focus() call
16915
+ // below so the second potential scroll event is never fired.
16916
+ this.virtualSkipNextScrollRender = true;
16917
+ this.renderVirtualItems();
16918
+ // renderVirtualItems() replaces all <li> elements so the old icon element that
16919
+ // had the PROCESS class is gone. Remove PROCESS from the newly rendered icon
16920
+ // so the node can be expanded/collapsed again without being blocked.
16921
+ const newLi = this.getElement(id);
16922
+ if (newLi) {
16923
+ const icon = select('.' + ICON, newLi);
16924
+ if (icon) {
16925
+ removeClass([icon], PROCESS);
16926
+ }
16927
+ // ── Use preventScroll:true to avoid consuming virtualSkipNextScrollRender ─
16928
+ newLi.focus({ preventScroll: true });
16929
+ }
16930
+ if (this.isLoaded) {
16931
+ const currLi = this.getElement(id);
16932
+ if (currLi) {
16933
+ const colArgs = this.getExpandEvent(currLi, null);
16934
+ colArgs.isInteracted = this.isInteracted;
16935
+ this.trigger('nodeCollapsed', colArgs);
16936
+ }
16937
+ }
16938
+ }
16939
+ /**
16940
+ * Updates the selected state of a node in `virtualFlatNodes` and re-renders the window.
16941
+ *
16942
+ * @param {string} id - The `data-uid` of the target node.
16943
+ * @param {boolean} isSelected - Whether the node is now selected.
16944
+ * @returns {void}
16945
+ * @private
16946
+ */
16947
+ updateVirtualNodeSelection(id, isSelected) {
16948
+ const idx = this.virtualNodeMap.get(id);
16949
+ if (!isNullOrUndefined(idx)) {
16950
+ this.virtualFlatNodes[parseInt(idx.toString(), 10)].isSelected = isSelected;
16951
+ this.renderVirtualItems();
16952
+ }
16953
+ }
16954
+ /**
16955
+ * Updates the checkbox state of a node in `virtualFlatNodes` and re-renders the window.
16956
+ *
16957
+ * @param {string} id - The `data-uid` of the target node.
16958
+ * @param {'check' | 'uncheck' | 'indeterminate'} state - The new check state.
16959
+ * @returns {void}
16960
+ * @private
16961
+ */
16962
+ updateVirtualNodeCheckState(id, state) {
16963
+ const idx = this.virtualNodeMap.get(id);
16964
+ if (!isNullOrUndefined(idx)) {
16965
+ this.virtualFlatNodes[parseInt(idx.toString(), 10)].isChecked =
16966
+ state === 'check' ? true : state === 'indeterminate' ? 'indeterminate' : false;
16967
+ // Keep the persistent indeterminate set in sync so flattenVisibleNodes()
16968
+ // correctly restores state after any full list rebuild (e.g. collapse).
16969
+ if (state === 'indeterminate') {
16970
+ this.virtualIndeterminateNodesSet.add(id);
16971
+ }
16972
+ else {
16973
+ this.virtualIndeterminateNodesSet.delete(id);
16974
+ }
16975
+ this.renderVirtualItems();
16976
+ }
16977
+ }
16978
+ // ═══════════════════════════════════════════════════════════════════════════════
16979
+ // END VIRTUALIZATION
16980
+ // ═══════════════════════════════════════════════════════════════════════════════
15598
16981
  /**
15599
16982
  * Called internally if any of the property value changed.
15600
16983
  *
@@ -15731,6 +17114,33 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15731
17114
  this.listBaseOption.itemNavigable = newProp.fullRowNavigable;
15732
17115
  this.reRenderNodes();
15733
17116
  break;
17117
+ case 'enableVirtualization':
17118
+ if (this.enableVirtualization) {
17119
+ if (!isNullOrUndefined(this.height)) {
17120
+ this.initVirtualization();
17121
+ }
17122
+ }
17123
+ else {
17124
+ // Tear down virtualization and do a full re-render
17125
+ this.unwireVirtualScrollEvents();
17126
+ this.destroyVirtualMask();
17127
+ removeClass([this.element], VIRTUALIZATION);
17128
+ this.element.style.height = '';
17129
+ this.element.style.overflowY = '';
17130
+ this.reRenderNodes();
17131
+ }
17132
+ break;
17133
+ case 'height':
17134
+ if (this.enableVirtualization) {
17135
+ // Re-initialise so the new height takes effect
17136
+ this.unwireVirtualScrollEvents();
17137
+ this.initVirtualization();
17138
+ }
17139
+ else {
17140
+ const px = this.parseHeightToPixels();
17141
+ this.element.style.height = px > 0 ? px + 'px' : '';
17142
+ }
17143
+ break;
15734
17144
  }
15735
17145
  }
15736
17146
  }
@@ -15740,6 +17150,13 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15740
17150
  * @returns {void}
15741
17151
  */
15742
17152
  destroy() {
17153
+ // Tear down virtualization before standard cleanup
17154
+ if (this.enableVirtualization) {
17155
+ this.unwireVirtualScrollEvents();
17156
+ this.destroyVirtualMask();
17157
+ this.virtualFlatNodes = [];
17158
+ this.virtualNodeMap.clear();
17159
+ }
15743
17160
  this.clearTemplate();
15744
17161
  this.element.removeAttribute('aria-activedescendant');
15745
17162
  this.unWireEvents();
@@ -15824,9 +17241,25 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15824
17241
  if (isNullOrUndefined(fNode)) {
15825
17242
  this.setNodeFocusable();
15826
17243
  }
15827
- this.updateCheckedStateFromDS();
15828
- if (this.showCheckBox && dropLi && this.autoCheck) {
15829
- this.ensureParentCheckState(dropLi);
17244
+ // In virtual mode (with a fixed height), rebuild the flat node list and re-render
17245
+ // the window so the newly added nodes appear in the correct hierarchical position.
17246
+ // When height is not provided, the tree uses normal rendering and the DOM is
17247
+ // already updated by addGivenNodes() above.
17248
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
17249
+ this.setRootData();
17250
+ this.rebuildVirtualCheckCache();
17251
+ this.virtualFlatNodes = this.flattenVisibleNodes();
17252
+ this.rebuildVirtualNodeMap();
17253
+ const win = this.getVirtualWindow();
17254
+ this.virtualStartIndex = win.startIndex;
17255
+ this.virtualEndIndex = win.endIndex;
17256
+ this.renderVirtualItems();
17257
+ }
17258
+ else {
17259
+ this.updateCheckedStateFromDS();
17260
+ if (this.showCheckBox && dropLi && this.autoCheck) {
17261
+ this.ensureParentCheckState(dropLi);
17262
+ }
15830
17263
  }
15831
17264
  if ((this.fields.dataSource instanceof DataManager === false)) {
15832
17265
  this.preventExpand = false;
@@ -15871,6 +17304,24 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15871
17304
  * @returns {void}
15872
17305
  */
15873
17306
  collapseAll(nodes, level, excludeHiddenNodes) {
17307
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
17308
+ this.virtualExpandedNodesSet.clear();
17309
+ this.setProperties({ expandedNodes: [] }, true);
17310
+ const setAllCollapsed = (nodes) => {
17311
+ if (!nodes) {
17312
+ return;
17313
+ }
17314
+ for (let i = 0; i < nodes.length; i++) {
17315
+ const node = nodes[parseInt(i.toString(), 10)];
17316
+ node['isExpanded'] = false;
17317
+ const children = getValue(this.fields.child.toString(), node);
17318
+ if (children && Array.isArray(children)) {
17319
+ setAllCollapsed(children);
17320
+ }
17321
+ }
17322
+ };
17323
+ setAllCollapsed(this.treeData);
17324
+ }
15874
17325
  if (!isNullOrUndefined(nodes)) {
15875
17326
  this.doGivenAction(nodes, COLLAPSIBLE, false);
15876
17327
  }
@@ -15948,6 +17399,33 @@ let TreeView = TreeView_1 = class TreeView extends Component {
15948
17399
  */
15949
17400
  expandAll(nodes, level, excludeHiddenNodes, preventAnimation) {
15950
17401
  this.isAnimate = !preventAnimation;
17402
+ if (this.enableVirtualization && !isNullOrUndefined(this.height)) {
17403
+ const allParentIds = [];
17404
+ const idFieldName = this.fields.id;
17405
+ const childFieldName = this.fields.child.toString();
17406
+ const isDataTypeFlat = this.dataType === 1;
17407
+ const processAllParents = (nodeList) => {
17408
+ if (!nodeList) {
17409
+ return;
17410
+ }
17411
+ for (let i = 0; i < nodeList.length; i++) {
17412
+ const node = nodeList[parseInt(i.toString(), 10)];
17413
+ const nodeId = getValue(idFieldName, node);
17414
+ if (node.hasChild) {
17415
+ allParentIds.push(nodeId);
17416
+ node.isExpanded = true;
17417
+ }
17418
+ }
17419
+ };
17420
+ processAllParents(this.treeData);
17421
+ for (let i = 0; i < allParentIds.length; i++) {
17422
+ const id = allParentIds[parseInt(i.toString(), 10)];
17423
+ if (!this.virtualExpandedNodesSet.has(id)) {
17424
+ this.virtualExpandedNodesSet.add(id);
17425
+ }
17426
+ }
17427
+ this.setProperties({ expandedNodes: allParentIds }, true);
17428
+ }
15951
17429
  if (!isNullOrUndefined(nodes)) {
15952
17430
  this.doGivenAction(nodes, EXPANDABLE, true);
15953
17431
  }
@@ -16249,6 +17727,12 @@ __decorate$8([
16249
17727
  __decorate$8([
16250
17728
  Property(false)
16251
17729
  ], TreeView.prototype, "allowTextWrap", void 0);
17730
+ __decorate$8([
17731
+ Property(false)
17732
+ ], TreeView.prototype, "enableVirtualization", void 0);
17733
+ __decorate$8([
17734
+ Property()
17735
+ ], TreeView.prototype, "height", void 0);
16252
17736
  __decorate$8([
16253
17737
  Complex({}, NodeAnimationSettings)
16254
17738
  ], TreeView.prototype, "animation", void 0);