UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

2,905 lines • 88.1 kB
/*!
 * Wunderbaum - wunderbaum_node
 * Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
 * @VERSION, @DATE (https://github.com/mar10/wunderbaum)
 */

import * as util from "./util";

import { Wunderbaum } from "./wunderbaum";
import {
  AddChildrenOptions,
  ApplyCommandOptions,
  ApplyCommandType,
  ChangeType,
  CheckboxOption,
  ColumnDefinition,
  ColumnEventInfoMap,
  ExpandAllOptions,
  IconOption,
  InsertNodeType,
  MakeVisibleOptions,
  MatcherCallback,
  NavigateOptions,
  NavigationType,
  NodeAnyCallback,
  NodeStatusType,
  NodeStringCallback,
  NodeToDictCallback,
  NodeVisitCallback,
  NodeVisitResponse,
  RenderOptions,
  ResetOrderOptions,
  ScrollIntoViewOptions,
  SetActiveOptions,
  SetExpandedOptions,
  SetSelectedOptions,
  SetStatusOptions,
  SortByPropertyOptions,
  SortCallback,
  SortOrderType,
  SourceType,
  TooltipOption,
  TristateType,
  WbNodeData,
} from "./types";

import {
  decompressSourceData,
  ICON_WIDTH,
  KEY_TO_NAVIGATION_MAP,
  makeNodeTitleMatcher,
  nodeTitleSorter,
  RESERVED_TREE_SOURCE_KEYS,
  TEST_IMG,
  TITLE_SPAN_PAD_Y,
} from "./common";
import { Deferred } from "./deferred";

/** WunderbaumNode properties that can be passed with source data.
 * (Any other source properties will be stored as `node.data.PROP`.)
 */
const NODE_PROPS = new Set<string>([
  "checkbox",
  "classes",
  "expanded",
  "icon",
  "iconTooltip",
  "key",
  "lazy",
  "_partsel",
  "radiogroup",
  "refKey",
  "selected",
  "statusNodeType",
  "title",
  "tooltip",
  "type",
  "unselectable",
]);
/** WunderbaumNode properties that will be returned by `node.toDict()`.)
 */
const NODE_DICT_PROPS = new Set<string>(NODE_PROPS);
NODE_DICT_PROPS.delete("_partsel");
NODE_DICT_PROPS.delete("unselectable");

// /** Node properties that are of type bool (or boolean & string).
//  *  When parsing, we accept 0 for false and 1 for true for better JSON compression.
//  */
// export const NODE_BOOL_PROPS: Set<string> = new Set([
//   "checkbox",
//   "colspan",
//   "expanded",
//   "icon",
//   "iconTooltip",
//   "radiogroup",
//   "selected",
//   "tooltip",
//   "unselectable",
// ]);

/**
 * A single tree node.
 *
 * **NOTE:** <br>
 * Generally you should not modify properties directly, since this may break
 * the internal bookkeeping.
 */
export class WunderbaumNode {
  static sequence = 0;

  /** Reference to owning tree. */
  public tree: Wunderbaum;
  /** Parent node (null for the invisible root node `tree.root`). */
  public parent: WunderbaumNode;
  /** Name of the node.
   * @see Use {@link setTitle} to modify. */
  public title: string;
  /** Unique key. Passed with constructor or defaults to `SEQUENCE`.
   * @see Use {@link setKey} to modify. */
  public readonly key: string;
  /** Reference key. Unlike {@link key}, a `refKey` may occur multiple
   * times within a tree (in this case we have 'clone nodes').
   * @see Use {@link setKey} to modify.
   */
  public readonly refKey: string | undefined = undefined;
  /**
   * Array of child nodes (null for leaf nodes).
   * For lazy nodes, this is `null` or ùndefined` until the children are loaded
   * and leaf nodes may be `[]` (empty array).
   * @see {@link hasChildren}, {@link addChildren}, {@link lazy}.
   */
  public children: WunderbaumNode[] | null = null;
  /** Render a checkbox or radio button @see {@link selected}. */
  public checkbox?: CheckboxOption;
  /** If true, this node's children are considerd radio buttons.
   * @see {@link isRadio}.
   */
  public radiogroup?: boolean;
  /** If true, (in grid mode) no cells are rendered, except for the node title.*/
  public colspan?: boolean;
  /** Icon definition. */
  public icon?: IconOption;
  /** Lazy loading flag.
   * @see {@link isLazy}, {@link isLoaded}, {@link isUnloaded}.
   */
  public lazy?: boolean;
  /** Expansion state.
   * @see {@link isExpandable}, {@link isExpanded}, {@link setExpanded}. */
  public expanded?: boolean;
  /** Selection state.
   * @see {@link isSelected}, {@link setSelected}, {@link toggleSelected}. */
  public selected?: boolean;
  public unselectable?: boolean;
  /** Node type (used for styling).
   * @see {@link Wunderbaum.types}.
   */
  public type?: string;
  /** Tooltip definition (`true`: use node's title). */
  public tooltip?: TooltipOption;
  /** Icon tooltip definition (`true`: use node's title). */
  public iconTooltip?: TooltipOption;
  /** Additional classes added to `div.wb-row`.
   * @see {@link hasClass}, {@link setClass}. */
  public classes: Set<string> | null = null; //new Set<string>();
  /** Custom data that was passed to the constructor */
  public data: any = {};
  // --- Node Status ---
  public statusNodeType?: NodeStatusType;
  _isLoading = false;
  _requestId = 0;
  _errorInfo: any | null = null;
  _partsel = false;
  _partload = false;
  // --- FILTER ---
  /**
   * > 0 if matched (-1 to keep system nodes visible);
   * Added and removed by filter code.
   */
  public match?: number;
  public subMatchCount?: number = 0;
  // public subMatchBadge?: HTMLElement;
  /** @internal */
  public titleWithHighlight?: string;
  public _filterAutoExpanded?: boolean;

  _rowIdx: number | undefined = 0;
  _rowElem: HTMLDivElement | undefined = undefined;

  constructor(tree: Wunderbaum, parent: WunderbaumNode, data: any) {
    util.assert(!parent || parent.tree === tree, `Invalid parent: ${parent}`);
    util.assert(!data.children, "'children' not allowed here");

    this.tree = tree;
    this.parent = parent;
    this.key = "" + (data.key ?? ++WunderbaumNode.sequence);
    this.title = "" + (data.title ?? "<" + this.key + ">");
    this.expanded = !!data.expanded;
    this.lazy = !!data.lazy;

    // We set the following node properties only if a matching data value is
    // passed
    data.refKey != null ? (this.refKey = "" + data.refKey) : 0;
    data.type != null ? (this.type = "" + data.type) : 0;
    data.icon != null ? (this.icon = util.intToBool(data.icon)) : 0;
    data.tooltip != null ? (this.tooltip = util.intToBool(data.tooltip)) : 0;
    data.iconTooltip != null
      ? (this.iconTooltip = util.intToBool(data.iconTooltip))
      : 0;
    data.statusNodeType != null
      ? (this.statusNodeType = ("" + data.statusNodeType) as NodeStatusType)
      : 0;
    data.colspan != null ? (this.colspan = !!data.colspan) : 0;

    // Selection
    data.checkbox != null ? util.intToBool(data.checkbox) : 0;
    data.radiogroup != null ? (this.radiogroup = !!data.radiogroup) : 0;
    data.selected != null ? (this.selected = !!data.selected) : 0;
    data.unselectable != null ? (this.unselectable = !!data.unselectable) : 0;

    if (data.classes) {
      this.setClass(data.classes);
    }
    // Store custom fields as `node.data`
    for (const [key, value] of Object.entries(data)) {
      if (!NODE_PROPS.has(key)) {
        this.data[key] = value;
      }
    }

    if (parent && !this.statusNodeType) {
      // Don't register root node or status nodes
      tree._registerNode(this);
    }
  }

  /**
   * Return readable string representation for this instance.
   * @internal
   */
  toString() {
    return `WunderbaumNode@${this.key}<'${this.title}'>`;
  }

  /**
   * Iterate all descendant nodes depth-first, pre-order using `for ... of ...` syntax.
   * More concise, but slightly slower than {@link WunderbaumNode.visit}.
   *
   * Example:
   * ```js
   * for(const n of node) {
   *   ...
   * }
   * ```
   */

  *[Symbol.iterator](): IterableIterator<WunderbaumNode> {
    // let node: WunderbaumNode | null = this;
    const cl = this.children;
    if (cl) {
      for (let i = 0, l = cl.length; i < l; i++) {
        const n = cl[i];
        yield n;
        if (n.children) {
          yield* n;
        }
      }
      // Slower:
      // for (let node of this.children) {
      //   yield node;
      //   yield* node : 0;
      // }
    }
  }

  // /** Return an option value. */
  // protected _getOpt(
  //   name: string,
  //   nodeObject: any = null,
  //   treeOptions: any = null,
  //   defaultValue: any = null
  // ): any {
  //   return evalOption(
  //     name,
  //     this,
  //     nodeObject || this,
  //     treeOptions || this.tree.options,
  //     defaultValue
  //   );
  // }

  /** Call event handler if defined in tree.options.
   * Example:
   * ```js
   * node._callEvent("edit.beforeEdit", {foo: 42})
   * ```
   */
  _callEvent(type: string, extra?: any): any {
    return this.tree?._callEvent(
      type,
      util.extend(
        {
          node: this,
          typeInfo: this.type ? this.tree.types[this.type] : {},
        },
        extra
      )
    );
  }

  /**
   * Append (or insert) a list of child nodes.
   *
   * Tip: pass `{ before: 0 }` to prepend new nodes as first children.
   *
   * @returns first child added
   */
  addChildren(
    nodeData: WbNodeData | WbNodeData[],
    options?: AddChildrenOptions
  ): WunderbaumNode {
    const tree = this.tree;
    let { before = null, applyMinExpanLevel = true, _level } = options ?? {};
    // let { before, loadLazy=true, _level } = options ?? {};
    // const isTopCall = _level == null;
    _level ??= this.getLevel();
    const nodeList = [];

    try {
      tree.enableUpdate(false);

      if (util.isPlainObject(nodeData)) {
        nodeData = [<WbNodeData>nodeData];
      }
      const forceExpand =
        applyMinExpanLevel && _level < tree.options.minExpandLevel!;
      for (const child of <WbNodeData[]>nodeData) {
        const subChildren = child.children;
        delete child.children;

        const n = new WunderbaumNode(tree, this, child);
        if (forceExpand && !n.isUnloaded()) {
          n.expanded = true;
        }
        nodeList.push(n);
        if (subChildren) {
          n.addChildren(subChildren, { _level: _level + 1 });
        }
      }

      if (!this.children) {
        this.children = nodeList;
      } else if (before == null || this.children.length === 0) {
        this.children = this.children.concat(nodeList);
      } else {
        // Returns null if before is not a direct child:
        before = this.findDirectChild(before)!;
        const pos = this.children.indexOf(before);
        util.assert(
          pos >= 0,
          `options.before must be a direct child of ${this}`
        );
        // insert nodeList after children[pos]
        this.children.splice(pos, 0, ...nodeList);
      }
      // this.triggerModifyChild("add", nodeList.length === 1 ? nodeList[0] : null);
      tree.update(ChangeType.structure);
    } finally {
      // if (tree.options.selectMode === "hier") {
      //   if (this.parent && this.parent.children) {
      //     this.fixSelection3FromEndNodes();
      //   } else {
      //     // may happen when loading __root__;
      //   }
      // }
      tree.enableUpdate(true);
    }
    // if(isTopCall && loadLazy){
    //   this.logWarn("addChildren(): loadLazy is not yet implemented.")
    // }
    return nodeList[0];
  }

  /**
   * Append or prepend a node, or append a child node.
   *
   * This a convenience function that calls addChildren()
   *
   * @param nodeData node definition
   * @param [mode=child] 'before', 'after', 'firstChild', or 'child' ('over' is a synonym for 'child')
   * @returns new node
   */
  addNode(
    nodeData: WbNodeData,
    mode: InsertNodeType = "appendChild"
  ): WunderbaumNode {
    if (<string>mode === "over") {
      mode = "appendChild"; // compatible with drop region
    }
    switch (mode) {
      case "after":
        return this.parent.addChildren(nodeData, {
          before: this.getNextSibling(),
        });
      case "before":
        return this.parent.addChildren(nodeData, { before: this });
      case "prependChild":
        // Insert before the first child if any
        // let insertBefore = this.children ? this.children[0] : undefined;
        return this.addChildren(nodeData, { before: 0 });
      case "appendChild":
        return this.addChildren(nodeData);
    }
    util.assert(false, `Invalid mode: ${mode}`);
    return (<unknown>undefined) as WunderbaumNode;
  }

  /**
   * Apply a modification (or navigation) operation.
   *
   * @see {@link Wunderbaum.applyCommand}
   */
  applyCommand(cmd: ApplyCommandType, options: ApplyCommandOptions): any {
    return this.tree.applyCommand(cmd, this, options);
  }

  /**
   * Collapse all expanded sibling nodes if any.
   * (Automatically called when `autoCollapse` is true.)
   */
  collapseSiblings(options?: SetExpandedOptions): any {
    for (const node of this.parent.children!) {
      if (node !== this && node.expanded) {
        node.setExpanded(false, options);
      }
    }
  }

  /**
   * Add/remove one or more classes to `<div class='wb-row'>`.
   *
   * This also maintains `node.classes`, so the class will survive a re-render.
   *
   * @param className one or more class names. Multiple classes can be passed
   *     as space-separated string, array of strings, or set of strings.
   */
  setClass(
    className: string | string[] | Set<string>,
    flag: boolean = true
  ): void {
    const cnSet = util.toSet(className);
    if (flag) {
      if (this.classes === null) {
        this.classes = new Set<string>();
      }
      cnSet.forEach((cn) => {
        this.classes!.add(cn);
        this._rowElem?.classList.toggle(cn, flag);
      });
    } else {
      if (this.classes === null) {
        return;
      }
      cnSet.forEach((cn) => {
        this.classes!.delete(cn);
        this._rowElem?.classList.toggle(cn, flag);
      });
      if (this.classes.size === 0) {
        this.classes = null;
      }
    }
  }

  /** Start editing this node's title. */
  startEditTitle(): void {
    this.tree._callMethod("edit.startEditTitle", this);
  }

  /**
   * Call `setExpanded()` on all descendant nodes.
   *
   * @param flag true to expand, false to collapse.
   * @param options Additional options.
   * @see {@link Wunderbaum.expandAll}
   * @see {@link WunderbaumNode.setExpanded}
   */
  async expandAll(flag: boolean = true, options?: ExpandAllOptions) {
    const tree = this.tree;
    const {
      collapseOthers,
      deep,
      depth,
      force,
      keepActiveNodeVisible = true,
      loadLazy,
      resetLazy,
    } = options ?? {};
    // limit expansion level to `depth` (or tree.minExpandLevel). Default: unlimited
    const treeLevel = this.tree.options.minExpandLevel || null; // 0 -> null
    const minLevel = depth ?? (force ? null : treeLevel);
    const expandOpts = {
      deep: deep,
      force: force,
      loadLazy: loadLazy,
      resetLazy: resetLazy,
      scrollIntoView: false, // don't scroll every node while iterating
    };

    this.logInfo(`expandAll(${flag}, depth=${depth}, minLevel=${minLevel})`);

    util.assert(
      !(flag && deep != null && !collapseOthers),
      "Expanding with `deep` option is not supported (implied by the `depth` option)."
    );

    // Expand all direct children in parallel:
    async function _iter(n: WunderbaumNode, level: number) {
      // n.logInfo(`  _iter(level=${level})`);
      const promises: Promise<unknown>[] = [];
      n.children?.forEach((cn) => {
        if (flag) {
          if (
            !cn.expanded &&
            (minLevel == null || level < minLevel) &&
            (cn.children || (loadLazy && cn.lazy))
          ) {
            // Node is collapsed and may be expanded (i.e. has children or is lazy)
            // Expanding may be async, so we store the promise.
            // Also the recursion is delayed until expansion finished.
            const p = cn.setExpanded(true, expandOpts);
            promises.push(p);
            if (depth == null) {
              p.then(async () => {
                await _iter(cn, level + 1);
              });
            }
          } else {
            // We don't expand the node, but still visit descendants.
            // There we may find lazy nodes, so we
            promises.push(_iter(cn, level + 1));
          }
        } else {
          // Collapsing is always synchronous, so no promises required
          // Do not collapse until minExpandLevel
          if (minLevel == null || level >= minLevel) {
            cn.setExpanded(false, expandOpts);
          }
          if ((minLevel != null && level < minLevel) || deep) {
            _iter(cn, level + 1); // recursion, even if cn was already collapsed
          }
        }
      });
      return new Promise((resolve) => {
        Promise.all(promises).then(() => {
          resolve(true);
        });
      });
    }

    const tag = tree.logTime(`${this}.expandAll(${flag}, depth=${depth})`);
    try {
      tree.enableUpdate(false);

      await _iter(this, 0);

      if (collapseOthers) {
        util.assert(flag, "Option `collapseOthers` requires flag=true");
        util.assert(
          minLevel != null,
          "Option `collapseOthers` requires `depth` or `minExpandLevel`"
        );
        this.expandAll(false, { depth: minLevel! });
      }
    } finally {
      tree.enableUpdate(true);
      tree.logTimeEnd(tag);
    }
    if (tree.activeNode && keepActiveNodeVisible) {
      tree.activeNode.scrollIntoView();
    }
  }

  /**
   * Find all descendant nodes that match condition (excluding self).
   *
   * If `match` is a string, search for exact node title.
   * If `match` is a RegExp expression, apply it to node.title, using
   * [RegExp.test()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test).
   * If `match` is a callback, match all nodes for that the callback(node) returns true.
   *
   * Returns an empty array if no nodes were found.
   *
   * Examples:
   * ```js
   * // Match all node titles that match exactly 'Joe':
   * nodeList = node.findAll("Joe")
   * // Match all node titles that start with 'Joe' case sensitive:
   * nodeList = node.findAll(/^Joe/)
   * // Match all node titles that contain 'oe', case insensitive:
   * nodeList = node.findAll(/oe/i)
   * // Match all nodes with `data.price` >= 99:
   * nodeList = node.findAll((n) => {
   *   return n.data.price >= 99;
   * })
   * ```
   */
  findAll(match: string | RegExp | MatcherCallback): WunderbaumNode[] {
    const matcher =
      typeof match === "function" ? match : makeNodeTitleMatcher(match);
    const res: WunderbaumNode[] = [];
    this.visit((n) => {
      if (matcher(n)) {
        res.push(n);
      }
    });
    return res;
  }

  /** Return the direct child with a given key, index or null. */
  findDirectChild(
    ptr: number | string | WunderbaumNode
  ): WunderbaumNode | null {
    const cl = this.children;

    if (!cl) {
      return null;
    }
    if (typeof ptr === "string") {
      for (let i = 0, l = cl.length; i < l; i++) {
        if (cl[i].key === ptr) {
          return cl[i];
        }
      }
    } else if (typeof ptr === "number") {
      return cl[ptr];
    } else if (ptr.parent === this) {
      // Return null if `ptr` is not a direct child
      return ptr;
    }
    return null;
  }

  /**
   * Find first descendant node that matches condition (excluding self) or null.
   *
   * @see {@link WunderbaumNode.findAll} for examples.
   */
  findFirst(match: string | RegExp | MatcherCallback): WunderbaumNode | null {
    const matcher =
      typeof match === "function" ? match : makeNodeTitleMatcher(match);
    let res = null;
    this.visit((n) => {
      if (matcher(n)) {
        res = n;
        return false;
      }
    });
    return res;
  }

  /** Find a node relative to self.
   *
   * @see {@link Wunderbaum.findRelatedNode|tree.findRelatedNode()}
   */
  findRelatedNode(where: NavigationType, includeHidden = false) {
    return this.tree.findRelatedNode(this, where, includeHidden);
  }

  /**
   * Iterator version of {@link WunderbaumNode.format}.
   */
  *format_iter(
    name_cb?: NodeStringCallback,
    connectors?: string[]
  ): IterableIterator<string> {
    connectors ??= ["    ", " |  ", " ╰─ ", " ├─ "];
    name_cb ??= (node: WunderbaumNode) => "" + node;

    function _is_last(node: WunderbaumNode): boolean {
      const ca = node.parent.children!;
      return node === ca[ca.length - 1];
    }

    const _format_line = (node: WunderbaumNode) => {
      // https://www.measurethat.net/Benchmarks/Show/12196/0/arr-unshift-vs-push-reverse-small-array
      const parts = [name_cb!(node)];
      parts.unshift(connectors![_is_last(node) ? 2 : 3]);
      let p = node.parent;
      while (p && p !== this) {
        // `this` is the top node
        parts.unshift(connectors![_is_last(p) ? 0 : 1]);
        p = p.parent;
      }
      return parts.join("");
    };

    yield name_cb(this);
    for (const node of this) {
      yield _format_line(node);
    }
  }

  /**
   * Return a multiline string representation of a node/subnode hierarchy.
   * Mostly useful for debugging.
   *
   * Example:
   * ```js
   * console.info(tree.getActiveNode().format((n)=>n.title));
   * ```
   * logs
   * ```
   * Books
   *  ├─ Art of War
   *  ╰─ Don Quixote
   * ```
   * @see {@link WunderbaumNode.format_iter}
   */
  format(name_cb?: NodeStringCallback, connectors?: string[]): string {
    const a = [];
    for (const line of this.format_iter(name_cb, connectors)) {
      a.push(line);
    }
    return a.join("\n");
  }

  /** Return the `<span class='wb-col'>` element with a given index or id.
   * @returns {WunderbaumNode | null}
   */
  getColElem(colIdx: number | string) {
    if (typeof colIdx === "string") {
      colIdx = this.tree.columns.findIndex((value) => value.id === colIdx);
    }
    const colElems = this._rowElem?.querySelectorAll("span.wb-col");
    return colElems ? (colElems[colIdx] as HTMLSpanElement) : null;
  }
  /**
   * Return all nodes with the same refKey.
   *
   * @param includeSelf Include this node itself.
   * @see {@link Wunderbaum.findByRefKey}
   */
  getCloneList(includeSelf = false): WunderbaumNode[] {
    if (!this.refKey) {
      return [];
    }
    const clones = this.tree.findByRefKey(this.refKey);
    if (includeSelf) {
      return clones;
    }
    return [...clones].filter((n) => n !== this);
  }

  /** Return the first child node or null.
   * @returns {WunderbaumNode | null}
   */
  getFirstChild() {
    return this.children ? this.children[0] : null;
  }

  /** Return the last child node or null.
   * @returns {WunderbaumNode | null}
   */
  getLastChild() {
    return this.children ? this.children[this.children.length - 1] : null;
  }

  /** Return node depth (starting with 1 for top level nodes). */
  getLevel(): number {
    let i = 0,
      p = this.parent;

    while (p) {
      i++;
      p = p.parent;
    }
    return i;
  }

  /** Return the successive node (under the same parent) or null. */
  getNextSibling(): WunderbaumNode | null {
    const ac = this.parent.children!;
    const idx = ac.indexOf(this);
    return ac[idx + 1] || null;
  }

  /** Return the parent node (null for the system root node). */
  getParent(): WunderbaumNode | null {
    // TODO: return null for top-level nodes?
    return this.parent;
  }

  /** Return an array of all parent nodes (top-down).
   * @param includeRoot Include the invisible system root node.
   * @param includeSelf Include the node itself.
   */
  getParentList(includeRoot = false, includeSelf = false) {
    const l = [];
    let dtn = includeSelf ? this : this.parent;
    while (dtn) {
      if (includeRoot || dtn.parent) {
        l.unshift(dtn);
      }
      dtn = dtn.parent;
    }
    return l;
  }
  /** Return a string representing the hierachical node path, e.g. "a/b/c".
   * @param includeSelf
   * @param part property name or callback
   * @param separator
   */
  getPath(
    includeSelf: boolean = true,
    part: keyof WunderbaumNode | NodeAnyCallback = "title",
    separator: string = "/"
  ) {
    // includeSelf = includeSelf !== false;
    // part = part || "title";
    // separator = separator || "/";

    let val;
    const path: string[] = [];
    const isFunc = typeof part === "function";

    this.visitParents((n) => {
      if (n.parent) {
        val = isFunc
          ? (<NodeAnyCallback>part)(n)
          : n[<keyof WunderbaumNode>part];
        path.unshift(val);
      }
      return undefined; // TODO remove this line
    }, includeSelf);
    return path.join(separator);
  }

  /** Return the preceeding node (under the same parent) or null. */
  getPrevSibling(): WunderbaumNode | null {
    const ac = this.parent.children!;
    const idx = ac.indexOf(this);
    return ac[idx - 1] || null;
  }

  /** Return true if node has children.
   * Return undefined if not sure, i.e. the node is lazy and not yet loaded.
   */
  hasChildren() {
    if (this.lazy) {
      if (this.children == null) {
        return undefined; // null or undefined: Not yet loaded
      } else if (this.children.length === 0) {
        return false; // Loaded, but response was empty
      } else if (
        this.children.length === 1 &&
        this.children[0].isStatusNode()
      ) {
        return undefined; // Currently loading or load error
      }
      return true; // One or more child nodes
    }
    return !!(this.children && this.children.length);
  }

  /** Return true if node has className set. */
  hasClass(className: string): boolean {
    return this.classes ? this.classes.has(className) : false;
  }

  /** Return true if node ist the currently focused node. @since 0.9.0 */
  hasFocus(): boolean {
    return this.tree.focusNode === this;
  }

  /** Return true if this node is the currently active tree node. */
  isActive() {
    return this.tree.activeNode === this;
  }

  /** Return true if this node is a direct or indirect parent of `other`.
   * @see {@link WunderbaumNode.isParentOf}
   */
  isAncestorOf(other: WunderbaumNode) {
    return other && other.isDescendantOf(this);
  }

  /** Return true if this node is a **direct** subnode of `other`.
   * @see {@link WunderbaumNode.isDescendantOf}
   */
  isChildOf(other: WunderbaumNode) {
    return other && this.parent === other;
  }

  /** Return true if this node's refKey is used by at least one other node.
   */
  isClone() {
    return !!this.refKey && this.tree.findByRefKey(this.refKey).length > 1;
  }
  /** Return true if this node's title spans all columns, i.e. the node has no
   * grid cells.
   */
  isColspan() {
    return !!this.getOption("colspan");
  }

  /** Return true if this node is a direct or indirect subnode of `other`.
   * @see {@link WunderbaumNode.isChildOf}
   */
  isDescendantOf(other: WunderbaumNode) {
    if (!other || other.tree !== this.tree) {
      return false;
    }
    let p = this.parent;
    while (p) {
      if (p === other) {
        return true;
      }
      if (p === p.parent) {
        util.error(`Recursive parent link: ${p}`);
      }
      p = p.parent;
    }
    return false;
  }

  /** Return true if this node has children, i.e. the node is generally expandable.
   * If `andCollapsed` is set, we also check if this node is collapsed, i.e.
   * an expand operation is currently possible.
   */
  isExpandable(andCollapsed = false): boolean {
    // `false` is never expandable (unoffical)
    if ((andCollapsed && this.expanded) || <any>this.children === false) {
      return false;
    }
    if (this.children == null) {
      return !!this.lazy; // null or undefined can trigger lazy load
    }
    if (this.children.length === 0) {
      return !!this.tree.options.emptyChildListExpandable;
    }
    return true;
  }

  /** Return true if _this_ node is currently in edit-title mode.
   *
   * See {@link WunderbaumNode.startEditTitle}.
   */
  isEditingTitle(): boolean {
    return this.tree._callMethod("edit.isEditingTitle", this);
  }

  /** Return true if this node is currently expanded. */
  isExpanded(): boolean {
    return !!this.expanded;
  }

  /** Return true if this node is the first node of its parent's children. */
  isFirstSibling(): boolean {
    const p = this.parent;
    return !p || p.children![0] === this;
  }

  /** Return true if this node is the last node of its parent's children. */
  isLastSibling(): boolean {
    const p = this.parent;
    return !p || p.children![p.children!.length - 1] === this;
  }

  /** Return true if this node is lazy (even if data was already loaded) */
  isLazy(): boolean {
    return !!this.lazy;
  }

  /** Return true if node is lazy and loaded. For non-lazy nodes always return true. */
  isLoaded(): boolean {
    return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node
  }

  /** Return true if node is currently loading, i.e. a GET request is pending. */
  isLoading(): boolean {
    return this._isLoading;
  }

  /** Return true if this node is a temporarily generated status node of type 'paging'. */
  isPagingNode(): boolean {
    return this.statusNodeType === "paging";
  }

  /** Return true if this node is a **direct** parent of `other`.
   * @see {@link WunderbaumNode.isAncestorOf}
   */
  isParentOf(other: WunderbaumNode) {
    return other && other.parent === this;
  }

  /** Return true if this node is partially loaded. @experimental  */
  isPartload(): boolean {
    return !!this._partload;
  }

  /** Return true if this node is partially selected (tri-state). */
  isPartsel(): boolean {
    return !this.selected && !!this._partsel;
  }

  /** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */
  isRadio(): boolean {
    return !!this.parent.radiogroup || this.getOption("checkbox") === "radio";
  }

  /** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */
  isRendered(): boolean {
    return !!this._rowElem;
  }

  /** Return true if this node is the (invisible) system root node.
   * @see {@link WunderbaumNode.isTopLevel}
   */
  isRootNode(): boolean {
    return this.tree.root === this;
  }

  /** Return true if this node is selected, i.e. the checkbox is set.
   * `undefined` if partly selected (tri-state), false otherwise.
   */
  isSelected(): TristateType {
    return this.selected ? true : this._partsel ? undefined : false;
  }

  /** Return true if this node is a temporarily generated system node like
   * 'loading', 'paging', or 'error' (node.statusNodeType contains the type).
   */
  isStatusNode(): boolean {
    return !!this.statusNodeType;
  }

  /** Return true if this a top level node, i.e. a direct child of the (invisible) system root node. */
  isTopLevel(): boolean {
    return this.tree.root === this.parent;
  }

  /** Return true if node is marked lazy but not yet loaded.
   * For non-lazy nodes always return false.
   */
  isUnloaded(): boolean {
    // Also checks if the only child is a status node:
    return this.hasChildren() === undefined;
  }

  /** Return true if all parent nodes are expanded. Note: this does not check
   * whether the node is scrolled into the visible part of the screen or viewport.
   */
  isVisible(): boolean {
    const hasFilter = this.tree.filterMode === "hide";
    const parents = this.getParentList(false, false);

    // TODO: check $(n.span).is(":visible")
    // i.e. return false for nodes (but not parents) that are hidden
    // by a filter
    if (hasFilter && !this.match && !this.subMatchCount) {
      // this.debug( "isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")" );
      return false;
    }

    for (let i = 0, l = parents.length; i < l; i++) {
      const n = parents[i];

      if (!n.expanded) {
        // this.debug("isVisible: HIDDEN (parent collapsed)");
        return false;
      }
      // if (hasFilter && !n.match && !n.subMatchCount) {
      // 	this.debug("isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")");
      // 	return false;
      // }
    }
    // this.debug("isVisible: VISIBLE");
    return true;
  }

  protected _loadSourceObject(source: any, level?: number) {
    const tree = this.tree;

    level ??= this.getLevel();

    // Let caller modify the parsed JSON response:
    const res = this._callEvent("receive", { response: source });
    if (res != null) {
      source = res;
    }

    if (util.isArray(source)) {
      source = { children: source };
    }
    util.assert(
      util.isPlainObject(source),
      `Expected an array or plain object: ${source}`
    );

    const format: string = source.format ?? "nested";
    util.assert(
      format === "nested" || format === "flat",
      `Expected source.format = 'nested' or 'flat': ${format}`
    );

    // Pre-rocess for 'nested' or 'flat' format
    decompressSourceData(source);

    util.assert(
      source.children,
      "If `source` is an object, it must have a `children` property"
    );
    if (source.types) {
      tree.logInfo("Redefine types", source.columns);
      tree.setTypes(source.types, false);
      delete source.types;
    }
    if (source.columns) {
      tree.logInfo("Redefine columns", source.columns);
      tree.columns = source.columns;
      delete source.columns;
      tree.update(ChangeType.colStructure);
    }

    this.addChildren(source.children);

    // Add extra data to `tree.data`
    for (const [key, value] of Object.entries(source)) {
      if (!RESERVED_TREE_SOURCE_KEYS.has(key)) {
        tree.data[key] = value;
        // tree.logDebug(`Add source.${key} to tree.data.${key}`);
      }
    }
    if (tree.options.selectMode === "hier") {
      this.fixSelection3FromEndNodes();
    }
    // Allow to un-sort nodes after sorting
    this.resetNativeChildOrder();

    this._callEvent("load");
  }

  async _fetchWithOptions(source: any) {
    // Either a URL string or an object with a `.url` property.
    let url: string, params, body, options, rest;
    let fetchOpts: RequestInit = {};
    if (typeof source === "string") {
      // source is a plain URL string: assume GET request
      url = source;
      fetchOpts.method = "GET";
    } else if (util.isPlainObject(source)) {
      // source is a plain object with `.url` property.
      ({ url, params, body, options, ...rest } = source);
      util.assert(
        !rest || Object.keys(rest).length === 0,
        `Unexpected source properties: ${Object.keys(
          rest
        )}. Use 'options' instead.`
      );
      util.assert(typeof url === "string", `expected source.url as string`);
      if (util.isPlainObject(options)) {
        fetchOpts = options;
      }
      if (util.isPlainObject(body)) {
        // we also accept 'body' as object...
        util.assert(
          !fetchOpts.body,
          "options.body should be passed as source.body"
        );
        fetchOpts.body = JSON.stringify(fetchOpts.body);
        fetchOpts.method ??= "POST"; // set default
      }
      if (util.isPlainObject(params)) {
        url += "?" + new URLSearchParams(params);
        fetchOpts.method ??= "GET"; // set default
      }
    } else {
      url = ""; // keep linter happy
      util.error(`Unsupported source format: ${source}`);
    }
    this.setStatus(NodeStatusType.loading);
    const response = await fetch(url, fetchOpts);
    if (!response.ok) {
      util.error(`GET ${url} returned ${response.status}, ${response}`);
    }
    return await response.json();
  }
  /** Download  data from the cloud, then call `.update()`. */
  async load(source: SourceType) {
    const tree = this.tree;
    const requestId = Date.now();
    const prevParent = this.parent;
    const start = Date.now();
    let elap = 0,
      elapLoad = 0,
      elapProcess = 0;

    // Check for overlapping requests
    if (this._requestId) {
      this.logWarn(
        `Recursive load request #${requestId} while #${this._requestId} is pending. ` +
          "The previous request will be ignored."
      );
    }
    this._requestId = requestId;

    // const timerLabel = tree.logTime(this + ".load()");

    try {
      const url: string =
        typeof source === "string" ? source : (<any>source).url;
      if (!url) {
        // An array or a plain object (that does NOT contain a `.url` property)
        // will be treated as native Wunderbaum data
        if (typeof (<any>source).then === "function") {
          const msg = tree.logTime(`Resolve thenable ${source}`);
          source = await Promise.resolve(source);
          tree.logTimeEnd(msg);
        }
        this._loadSourceObject(source);
        elapProcess = Date.now() - start;
      } else {
        // Either a URL string or an object with a `.url` property.
        const data = await this._fetchWithOptions(source);

        elapLoad = Date.now() - start;

        if (this._requestId && this._requestId > requestId) {
          this.logWarn(
            `Ignored load response #${requestId} because #${this._requestId} is pending.`
          );
          return;
        } else {
          this.logDebug(`Received response for load request #${requestId}`);
        }
        if (this.parent === null && prevParent !== null) {
          this.logWarn(
            "Lazy parent node was removed while loading: discarding response."
          );
          return;
        }
        this.setStatus(NodeStatusType.ok);
        // if (data.columns) {
        //   tree.logInfo("Re-define columns", data.columns);
        //   util.assert(!this.parent);
        //   tree.columns = data.columns;
        //   delete data.columns;
        //   tree.updateColumns({ calculateCols: false });
        // }
        const startProcess = Date.now();
        this._loadSourceObject(data);
        elapProcess = Date.now() - startProcess;
      }
    } catch (error) {
      this.logError("Error during load()", source, error);
      this._callEvent("error", { error: error });
      this.setStatus(NodeStatusType.error, { message: "" + error });
      throw error;
    } finally {
      this._requestId = 0;
      elap = Date.now() - start;
      if (tree.options.debugLevel! >= 3) {
        tree.logInfo(
          `Load source took ${elap / 1000} seconds ` +
            `(transfer: ${elapLoad / 1000}s, ` +
            `processing: ${elapProcess / 1000}s)`
        );
      }
    }
  }

  /**
   * Load content of a lazy node.
   * If the node is already loaded, nothing happens.
   * @param [forceReload=false] If true, reload even if already loaded.
   */
  async loadLazy(forceReload: boolean = false) {
    const wasExpanded = this.expanded;

    util.assert(this.lazy, "load() requires a lazy node");

    if (!forceReload && !this.isUnloaded()) {
      return; // Already loaded: nothing to do
    }
    if (this.isLoading()) {
      this.logWarn("loadLazy() called while already loading: ignored.");
      return; // Already loading: prevent duplicate requests
    }
    if (this.isLoaded()) {
      this.resetLazy(); // Also collapses if currently expanded
    }
    // `lazyLoad` may be long-running, so mark node as loading now. `this.load()`
    // will reset the status later.
    this.setStatus(NodeStatusType.loading);
    try {
      const source = await this._callEvent("lazyLoad");
      if (source === false) {
        this.setStatus(NodeStatusType.ok);
        return;
      }
      util.assert(
        util.isArray(source) || (source && source.url),
        "The lazyLoad event must return a node list, `{url: ...}`, or false."
      );

      await this.load(source);

      this.setStatus(NodeStatusType.ok); // Also resets `this._isLoading`

      if (wasExpanded) {
        this.expanded = true;
        this.tree.update(ChangeType.structure);
      } else {
        this.update(); // Fix expander icon to 'loaded'
      }
    } catch (e) {
      this.logError("Error during loadLazy()", e);
      this._callEvent("error", { error: e });
      // Also resets `this._isLoading`:
      this.setStatus(NodeStatusType.error, { message: "" + e });
    }
    return;
  }

  /** Write to `console.log` with node name as prefix if opts.debugLevel >= 4.
   * @see {@link WunderbaumNode.logDebug}
   */
  log(...args: any[]) {
    if (this.tree.options.debugLevel! >= 4) {
      console.log(this.toString(), ...args); // eslint-disable-line no-console
    }
  }

  /** Write to `console.debug` with node name as prefix if opts.debugLevel >= 4
   * and browser console level includes debug/verbose messages.
   * @see {@link WunderbaumNode.log}
   */
  logDebug(...args: any[]) {
    if (this.tree.options.debugLevel! >= 4) {
      console.debug(this.toString(), ...args); // eslint-disable-line no-console
    }
  }

  /** Write to `console.error` with node name as prefix if opts.debugLevel >= 1. */
  logError(...args: any[]) {
    if (this.tree.options.debugLevel! >= 1) {
      console.error(this.toString(), ...args); // eslint-disable-line no-console
    }
  }

  /** Write to `console.info` with node name as prefix if opts.debugLevel >= 3. */
  logInfo(...args: any[]) {
    if (this.tree.options.debugLevel! >= 3) {
      console.info(this.toString(), ...args); // eslint-disable-line no-console
    }
  }

  /** Write to `console.warn` with node name as prefix if opts.debugLevel >= 2. */
  logWarn(...args: any[]) {
    if (this.tree.options.debugLevel! >= 2) {
      console.warn(this.toString(), ...args); // eslint-disable-line no-console
    }
  }

  /** Expand all parents and optionally scroll into visible area as neccessary.
   * Promise is resolved, when lazy loading and animations are done.
   * @param {object} [options] passed to `setExpanded()`.
   *     Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true}
   */
  async makeVisible(options?: MakeVisibleOptions) {
    let i;
    const dfd = new Deferred();
    const deferreds = [];
    const parents = this.getParentList(false, false);
    const len = parents.length;
    const noAnimation = util.getOption(options, "noAnimation", false);
    const scroll = util.getOption(options, "scrollIntoView", true);

    // Expand bottom-up, so only the top node is animated
    for (i = len - 1; i >= 0; i--) {
      // self.debug("pushexpand" + parents[i]);
      const seOpts = { noAnimation: noAnimation };
      deferreds.push(parents[i].setExpanded(true, seOpts));
    }
    Promise.all(deferreds).then(() => {
      // All expands have finished
      // self.debug("expand DONE", scroll);
      // Note: this.tree may be none when switching demo trees
      if (scroll && this.tree) {
        // Make sure markup and _rowIdx is updated before we do the scroll calculations
        this.tree.updatePendingModifications();
        this.scrollIntoView().then(() => {
          // self.debug("scroll DONE");
          dfd.resolve();
        });
      } else {
        dfd.resolve();
      }
    });
    return dfd.promise();
  }

  /** Move this node to targetNode. */
  moveTo(
    targetNode: WunderbaumNode,
    mode: InsertNodeType = "appendChild",
    map?: NodeAnyCallback
  ) {
    if (<string>mode === "over") {
      mode = "appendChild"; // compatible with drop region
    }
    if (mode === "prependChild") {
      if (targetNode.children && targetNode.children.length) {
        mode = "before";
        targetNode = targetNode.children[0];
      } else {
        mode = "appendChild";
      }
    }
    let pos;
    const tree = this.tree;
    const prevParent = this.parent;
    const targetParent =
      mode === "appendChild" ? targetNode : targetNode.parent;

    if (this === targetNode) {
      return;
    } else if (!this.parent) {
      util.error("Cannot move system root");
    } else if (targetParent.isDescendantOf(this)) {
      util.error("Cannot move a node to its own descendant");
    }
    if (targetParent !== prevParent) {
      prevParent.triggerModifyChild("remove", this);
    }
    // Unlink this node from current parent
    if (this.parent.children!.length === 1) {
      if (this.parent === targetParent) {
        return; // #258
      }
      this.parent.children = this.parent.lazy ? [] : null;
      this.parent.expanded = false;
    } else {
      pos = this.parent.children!.indexOf(this);
      util.assert(pos >= 0, "invalid source parent");
      this.parent.children!.splice(pos, 1);
    }

    // Insert this node to target parent's child list
    this.parent = targetParent;
    if (targetParent.hasChildren()) {
      switch (mode) {
        case "appendChild":
          // Append to existing target children
          targetParent.children!.push(this);
          break;
        case "before":
          // Insert this node before target node
          pos = targetParent.children!.indexOf(targetNode);
          util.assert(pos >= 0, "invalid target parent");
          targetParent.children!.splice(pos, 0, this);
          break;
        case "after":
          // Insert this node after target node
          pos = targetParent.children!.indexOf(targetNode);
          util.assert(pos >= 0, "invalid target parent");
          targetParent.children!.splice(pos + 1, 0, this);
          break;
        default:
          util.error(`Invalid mode '${mode}'.`);
      }
    } else {
      targetParent.children = [this];
    }

    // Let caller modify the nodes
    if (map) {
      targetNode.visit(map, true);
    }
    if (targetParent === prevParent) {
      targetParent.triggerModifyChild("move", this);
    } else {
      // prevParent.triggerModifyChild("remove", this);
      targetParent.triggerModifyChild("add", this);
    }
    // Handle cross-tree moves
    if (tree !== targetNode.tree) {
      // Fix node.tree for all source nodes
      // 	util.assert(false, "Cross-tree move is not yet implemented.");
      this.logWarn("Cross-tree moveTo is experimental!");
      this.visit((n) => {
        // TODO: fix selection state and activation, ...
        n.tree = targetNode.tree;
      }, true);
    }
    // Make sure we update async, because discarding the markup would prevent
    // DragAndDrop to generate a dragend event on the source node
    setTimeout(() => {
      // Even indentation may have changed:
      tree.update(ChangeType.any);
    }, 0);
    // TODO: fix selection state
    // TODO: fix active state
  }

  /** Set focus relative to this node and optionally activate.
   *
   * 'left' collapses the node if it is expanded, or move to the parent
   * otherwise.
   * 'right' expands the node if it is collapsed, or move to the first
   * child otherwise.
   *
   * @param where 'down', 'first', 'last', 'left', 'parent', 'right', or 'up'.
   *   (Alternatively the `event.key` that would normally trigger this move,
   *   e.g. `ArrowLeft` = 'left'.
   * @param options
   */
  async navigate(where: NavigationType | string, options?: NavigateOptions) {
    // Allow to pass 'ArrowLeft' instead of 'left'
    const navType = (KEY_TO_NAVIGATION_MAP[where] ?? where) as NavigationType;

    // Otherwise activate or focus the related node
    const node = this.findRelatedNode(navType);
    if (!node) {
      this.logWarn(`Could not find related node '${where}'.`);
      return Promise.resolve(this);
    }
    // setFocus/setActive will scroll later (if autoScroll is specified)
    try {
      node.makeVisible({ scrollIntoView: false });
    } catch (e) {
      // ignore
    }
    node.setFocus();
    if (options?.activate === false) {
      return Promise.resolve(this);
    }
    return node.setActive(true, { event: options?.event });
  }

  /** Delete this node and all descendants. */
  remove() {
    const tree = this.tree;
    const pos = this.parent.children!.indexOf(this);
    this.triggerModify("remove");
    this.parent.children!.splice(pos, 1);
    this.visit((n) => {
      n.removeMarkup();
      tree._unregisterNode(n);
    }, true);
    tree.update(ChangeType.structure);
  }

  /** Remove all descendants of this node. */
  removeChildren() {
    const tree = this.tree;

    if (!this.children) {
      return;
    }
    if (tree.activeNode?.isDescendantOf(this)) {
      tree.activeNode.setActive(false); // TODO: don't fire events
    }
    if (tree.focusNode?.isDescendantOf(this)) {
      tree._setFocusNode(null);
    }
    // TODO: persist must take care to clear select and expand cookies
    // Unlink children to support GC
    // TODO: also delete this.children (not possible using visit())
    this.triggerModifyChild("remove", null);
    this.visit((n) => {
      tree._unregisterNode(n);
    });
    if (this.lazy) {
      // 'undefined' would be interpreted as 'not yet loaded' for lazy nodes
      this.children = [];
    } else {
      this.children = null;
    }
    // util.assert(this.parent); // don't call this for root node
    if (!this.isRootNode()) {
      this.expanded = false;
    }
    this.tree.update(ChangeType.structure);
  }

  /** Remove all HTML markup from the DOM. */
  removeMarkup() {
    if (this._rowElem) {
      delete (<any>this._rowElem)._wb_node;
      this._rowElem.remove();
      this._rowElem = undefined;
    }
  }

  protected _getRenderInfo(): any {
    const allColInfosById: ColumnEventInfoMap = {};
    const renderColInfosById: ColumnEventInfoMap = {};
    const isColspan = this.isColspan();
    const colElems = this._rowElem
      ? ((<unknown>(
          this._rowElem.querySelectorAll("span.wb-col")
        )) as HTMLSpanElement[])
      : null;

    let idx = 0;
    for (const col of this.tree.columns) {
      allColInfosById[col.id] = {
        id: col.id,
        idx: idx,
        elem: colElems ? colElems[idx] : null,
        info: col,
      };
      // renderColInfosById only contains columns that need rendering:
      if (!isColspan && col.id !== "*") {
        renderColInfosById[col.id] = allColInfosById[col.id];
      }
      idx++;
    }
    return {
      allColInfosById: allColInfosById,
      renderColInfosById: renderColInfosById,
    };
  }

  protected _createIcon(
    parentElem: HTMLElement,
    replaceChild: HTMLElement | null,
    showLoading: boolean
  ): HTMLElement | null {
    const iconElem = this.tree._createNodeIcon(this, showLoading, true);
    if (iconElem) {
      if (replaceChild) {
        parentElem.replaceChild(iconElem, replaceChild);
      } else {
        parentElem.appendChild(iconElem);
      }
    }
    return iconElem;
  }

  /**
   * Create a whole new `<div class="wb-row">` element.
   * @see {@link WunderbaumNode._render}
   */
  protected _render_markup(opts: RenderOptions) {
    const tree = this.tree;
    const treeOptions = tree.options;
    const rowHeight = treeOptions.rowHeightPx!;
    const checkbox = this.getOption("checkbox");
    const columns = tree.columns;
    const level = this.getLevel();
    const activeColIdx = tree.isRowNav() ? null : tree.activeColIdx;

    let elem: HTMLElement;
    let rowDiv = this._rowElem;
    let checkboxSpan: HTMLElement | null = null;
    let expanderSpan: HTMLElement | null = null;

    const isNew = !rowDiv;
    util.assert(isNew, "Expected unrendered node");
    util.assert(
      !isNew || (opts && opts.after),
      "opts.after expected, unless updating"
    );
    util.assert(!this.isRootNode(), "Root node not allowed");

    rowDiv = document.createElement("div");
    rowDiv.classList.add("wb-row");

    rowDiv.style.top = this._rowIdx! * rowHeight + "px";

    this._rowElem = rowDiv;

    // Attach a node reference to the DOM Element:
    (<any>rowDiv)._wb_node = this;

    const nodeElem: HTMLSpanElement = document.createElement("span");
    nodeElem.classList.add("wb-node", "wb-col");
    rowDiv.appendChild(nodeElem);

    let ofsTitlePx = 0;

    if (checkbox) {
      checkboxSpan = document.createElement("i");
      checkboxSpan.classList.add("wb-checkbox");
      if (checkbox === "radio" || this.parent.radiogroup) {
        checkboxSpan.classList.add("wb-radio");
      }
      nodeElem.appendChild(checkboxSpan);
      ofsTitlePx += ICON_WIDTH;
    }

    for (let i = level - 1; i > 0; i--) {
      elem = document.createElement("i");
      elem.classList.add("wb-indent");
      nodeElem.appendChild(elem);
      ofsTitlePx += ICON_WIDTH;
    }

    if (!treeOptions.minExpandLevel || level > treeOptions.minExpandLevel) {
      expanderSpan = document.createElement("i");
      expanderSpan.classList.add("wb-expander");
      nodeElem.appendChild(expanderSpan);
      ofsTitlePx += ICON_WIDTH;
    }

    // Render the icon (show a 'loading' icon if we do not have an expander that
    // we would prefer).
    const iconSpan = this._createIcon(nodeElem, null, !expanderSpan);
    if (iconSpan) {
      ofsTitlePx += ICON_WIDTH;
    }

    const titleSpan = document.createElement("span");
    titleSpan.classList.add("wb-title");
    nodeElem.appendChild(titleSpan);

    // this._callEvent("enhanceTitle", { titleSpan: titleSpan });

    // Store the width of leading icons with the node, so we can calculate
    // the width of the embedded title span later
    (<any>nodeElem)._ofsTitlePx = ofsTitlePx;

    // Support HTML5 drag-n-drop
    if (tree.options.dnd!.dragStart) {
      nodeElem.draggable = true;
    }

    // Render columns
    const isColspan = this.isColspan();

    if (!isColspan && columns.length > 1) {
      let colIdx = 0;
      for (const col of columns) {
        colIdx++;

        let colElem;
        if (col.id === "*") {
          colElem = nodeElem;
        } else {
          colElem = document.createElement("span");
          colElem.classList.add("wb-col");
          rowDiv.appendChild(colElem);
        }
        if (colIdx === activeColIdx) {
          colElem.classList.add("wb-active");
        }
        // Add classes from `columns` definition to `<div.wb-col>` cells
        col.classes ? colElem.classList.add(...col.classes.split(" ")) : 0;

        colElem.style.left = col._ofsPx + "px";
        colElem.style.width = col._widthPx + "px";
        if (isNew && col.html) {
          if (typeof col.html === "string") {
            colElem.innerHTML = col.html;
          }
        }
      }
    }
    // Attach to DOM as late as possible
    const after = opts ? opts.after : "last";
    switch (after) {
      case "first":
        tree.nodeListElement.prepend(rowDiv);
        break;
      case "last":
        tree.nodeListElement.appendChild(rowDiv);
        break;
      default:
        opts.after.after(rowDiv);
    }
    // Now go on and fill in data and update classes
    opts.isNew = true;
    this._render_data(opts);
  }

  /**
   * Render `node.title`, `.icon` into an existing row.
   *
   * @see {@link WunderbaumNode._render}
   */
  protected _render_data(opts: RenderOptions) {
    util.assert(this._rowElem, "No _rowElem");

    const tree = this.tree;
    const treeOptions = tree.options;
    const rowDiv = this._rowElem!;
    const isNew = !!opts.isNew; // Called by _render_markup()?
    const preventScroll = !!opts.preventScroll;
    const columns = tree.columns;
    const isColspan = this.isColspan();

    // Row markup already exists
    const nodeElem = rowDiv.querySelector("span.wb-node") as HTMLSpanElement;
    const titleSpan = nodeElem.querySelector(
      "span.wb-title"
    ) as HTMLSpanElement;

    const scrollTop = tree.element.scrollTop;
    if (this.titleWithHighlight) {
      titleSpan.innerHTML = this.titleWithHighlight;
    } else {
      titleSpan.textContent = this.title; // TODO: this triggers scroll events
    }
    const tooltip = this.getOption("tooltip", false);
    if (tooltip) {
      titleSpan.title = tooltip === true ? this.title : tooltip;
    }

    // NOTE: At least on Safari, this render call triggers a scroll event
    // probably when a focused input is replaced.
    if (preventScroll) {
      tree.element.scrollTop = scrollTop;
    }

    // Set the width of the title span, so overflow ellipsis work
    if (!treeOptions.skeleton) {
      if (isColspan) {
        const vpWidth = tree.element.clientWidth;
        titleSpan.style.width =
          vpWidth - (<any>nodeElem)._ofsTitlePx - TITLE_SPAN_PAD_Y + "px";
      } else {
        titleSpan.style.width =
          columns[0]._widthPx! -
          (<any>nodeElem)._ofsTitlePx -
          TITLE_SPAN_PAD_Y +
          "px";
      }
    }

    // Update row classes
    opts.isDataChange = true;
    this._render_status(opts);

    // Let user modify the result
    if (this.statusNodeType) {
      this._callEvent("renderStatusNode", {
        isNew: isNew,
        nodeElem: nodeElem,
        isColspan: isColspan,
      });
    } else if (this.parent) {
      // Skip root node
      const renderInfo = this._getRenderInfo();

      this._callEvent("render", {
        isNew: isNew,
        nodeElem: nodeElem,
        isColspan: isColspan,
        allColInfosById: renderInfo.allColInfosById,
        renderColInfosById: renderInfo.renderColInfosById,
      });
    }
  }

  /**
   * Update row classes to reflect active, focuses, etc.
   * @see {@link WunderbaumNode._render}
   */
  protected _render_status(opts: RenderOptions) {
    // this.log("_render_status", opts);
    const tree = this.tree;
    const iconMap = tree.iconMap;
    const treeOptions = tree.options;
    const typeInfo = this.type ? tree.types[this.type] : null;
    const rowDiv = this._rowElem!;

    // Row markup already exists
    const nodeElem = rowDiv.querySelector("span.wb-node") as HTMLSpanElement;
    const expanderSpan = nodeElem.querySelector(
      "i.wb-expander"
    ) as HTMLLIElement;
    const checkboxSpan = nodeElem.querySelector(
      "i.wb-checkbox"
    ) as HTMLLIElement;

    const rowClasses = ["wb-row"];
    this.expanded ? rowClasses.push("wb-expanded") : 0;
    this.lazy ? rowClasses.push("wb-lazy") : 0;
    this.selected ? rowClasses.push("wb-selected") : 0;
    this._partsel ? rowClasses.push("wb-partsel") : 0;
    this === tree.activeNode ? rowClasses.push("wb-active") : 0;
    this === tree.focusNode ? rowClasses.push("wb-focus") : 0;
    this._errorInfo ? rowClasses.push("wb-error") : 0;
    this._isLoading ? rowClasses.push("wb-loading") : 0;
    this.isColspan() ? rowClasses.push("wb-colspan") : 0;
    this.statusNodeType
      ? rowClasses.push("wb-status-" + this.statusNodeType)
      : 0;

    this.match ? rowClasses.push("wb-match") : 0;
    this.subMatchCount ? rowClasses.push("wb-submatch") : 0;
    treeOptions.skeleton ? rowClasses.push("wb-skeleton") : 0;

    // Replace previous classes:
    rowDiv.className = rowClasses.join(" ");

    // Add classes from `node.classes`
    this.classes ? rowDiv.classList.add(...this.classes) : 0;

    // Add classes from `tree.types[node.type]`
    if (typeInfo && typeInfo.classes) {
      rowDiv.classList.add(...typeInfo.classes);
    }

    if (expanderSpan) {
      let image = null;
      if (this._isLoading) {
        image = iconMap.loading;
      } else if (this.isExpandable(false)) {
        if (this.expanded) {
          image = iconMap.expanderExpanded;
        } else {
          image = iconMap.expanderCollapsed;
        }
      } else if (this.lazy && this.children == null) {
        image = iconMap.expanderLazy;
      }

      if (image == null) {
        expanderSpan.classList.add("wb-indent");
      } else if (TEST_IMG.test(image)) {
        expanderSpan.style.backgroundImage = `url('${image}')`;
      } else {
        expanderSpan.className = "wb-expander " + image;
      }
    }
    if (checkboxSpan) {
      let cbclass = "wb-checkbox ";
      if (this.isRadio()) {
        cbclass += "wb-radio ";
        if (this.selected) {
          cbclass += iconMap.radioChecked;
          // } else if (this._partsel) {
          //   cbclass += iconMap.radioUnknown;
        } else {
          cbclass += iconMap.radioUnchecked;
        }
      } else {
        if (this.selected) {
          cbclass += iconMap.checkChecked;
        } else if (this._partsel) {
          cbclass += iconMap.checkUnknown;
        } else {
          cbclass += iconMap.checkUnchecked;
        }
      }
      checkboxSpan.className = cbclass;
    }
    // Fix active cell in cell-nav mode
    if (!opts.isNew) {
      let i = 0;
      for (const colSpan of rowDiv.children) {
        colSpan.classList.toggle("wb-active", i++ === tree.activeColIdx);
        colSpan.classList.remove("wb-error", "wb-invalid");
      }
      // Update icon (if not opts.isNew, which would rebuild markup anyway)
      const iconSpan = nodeElem.querySelector("i.wb-icon") as HTMLElement;
      if (iconSpan) {
        this._createIcon(nodeElem, iconSpan, !expanderSpan);
      }
    }
    // Adjust column width
    if (opts.resizeCols !== false && !this.isColspan()) {
      const colElems = rowDiv.querySelectorAll("span.wb-col");
      let idx = 0;
      let ofs = 0;
      for (const colDef of this.tree.columns) {
        const colElem = colElems[idx] as HTMLSpanElement;
        colElem.style.left = `${ofs}px`;
        colElem.style.width = `${colDef._widthPx}px`;
        idx++;
        ofs += colDef._widthPx!;
      }
    }
  }

  /*
   * Create or update node's markup.
   *
   * `options.change` defaults to ChangeType.data, which updates the title,
   * icon, and status. It also triggers the `render` event, that lets the user
   * create or update the content of embeded cell elements.
   *
   * If only the status or other class-only modifications have changed,
   * `options.change` should be set to ChangeType.status instead for best
   * efficiency.
   *
   * Calling `update()` is almost always a better alternative.
   * @see {@link WunderbaumNode.update}
   */
  _render(options?: RenderOptions) {
    // this.log("render", options);
    const opts = Object.assign({ change: ChangeType.data }, options);
    if (!this._rowElem) {
      opts.change = ChangeType.row;
    }
    switch (opts.change) {
      case "status":
        this._render_status(opts);
        break;
      case "data":
        this._render_data(opts);
        break;
      case "row":
        // _rowElem is not yet created (asserted in _render_markup)
        this._render_markup(opts);
        break;
      default:
        util.error(`Invalid change type '${opts.change}'.`);
    }
  }

  /**
   * Remove all children, collapse, and set the lazy-flag, so that the lazyLoad
   * event is triggered on next expand.
   */
  resetLazy() {
    this.removeChildren();
    this.expanded = false;
    this.lazy = true;
    this.children = null;
    this.tree.update(ChangeType.structure);
  }

  /** Convert node (or whole branch) into a plain object.
   *
   * The result is compatible with node.addChildren().
   *
   * @param recursive include child nodes
   * @param callback is called for every node, in order to allow
   *     modifications.
   *     Return `false` to ignore this node or `"skip"` to include this node
   *     without its children.
   * @see {@link Wunderbaum.toDictArray}.
   */
  toDict(recursive = false, callback?: NodeToDictCallback): WbNodeData {
    const dict: any = {};

    NODE_DICT_PROPS.forEach((propName: string) => {
      const val = (<any>this)[propName];

      if (val instanceof Set) {
        // Convert Set to string (or skip if set is empty)
        val.size
          ? (dict[propName] = Array.prototype.join.call(val.keys(), " "))
          : 0;
      } else if (val || val === false || val === 0) {
        dict[propName] = val;
      }
    });
    if (!util.isEmptyObject(this.data)) {
      dict.data = util.extend({}, this.data);
      if (util.isEmptyObject(dict.data)) {
        delete dict.data;
      }
    }
    if (callback) {
      const res = callback(dict, this);
      if (res === false) {
        // Note: a return value of `false` is only used internally
        return <any>false; // Don't include this node nor its children
      }
      if (res === "skip") {
        recursive = false; // Include this node, but not the children
      }
    }
    if (recursive) {
      if (util.isArray(this.children)) {
        dict.children = [];
        for (let i = 0, l = this.children!.length; i < l; i++) {
          const node = this.children![i];
          if (!node.isStatusNode()) {
            // Note: a return value of `false` is only used internally
            const res = <any>node.toDict(true, callback);
            if (res !== false) {
              dict.children.push(res);
            }
          }
        }
      }
    }
    return dict;
  }

  /** Return an option value that has a default, but may be overridden by a
   * callback or a node instance attribute.
   *
   * Evaluation sequence:
   *
   * - If `tree.options.<name>` is a callback that returns something, use that.
   * - Else if `node.<name>` is defined, use that.
   * - Else if `tree.types[<node.type>]` is a value, use that.
   * - Else if `tree.options.<name>` is a value, use that.
   * - Else use `defaultValue`.
   *
   * @param name name of the option property (on node and tree)
   * @param defaultValue return this if nothing else matched
   * {@link Wunderbaum.getOption|Wunderbaum.getOption}
   */
  getOption(name: string, defaultValue?: any) {
    const tree = this.tree;
    let opts: any = tree.options;

    // Lookup `name` in options dict
    if (name.indexOf(".") >= 0) {
      [opts, name] = name.split(".");
    }
    const value = opts[name]; // ?? defaultValue;

    // A callback resolver always takes precedence
    if (typeof value === "function") {
      const res = value.call(tree, {
        type: "resolve",
        tree: tree,
        node: this,
        // typeInfo: this.type ? tree.types[this.type] : {},
      });
      if (res !== undefined) {
        return res;
      }
    }
    // If this node has an explicit local setting, use it:
    if ((<any>this)[name] !== undefined) {
      return (<any>this)[name];
    }
    // Use value from type definition if defined
    const typeInfo = this.type ? tree.types[this.type] : undefined;
    const res = typeInfo ? typeInfo[name] : undefined;
    if (res !== undefined) {
      return res;
    }
    // Use value from value options dict, fallback do default
    return value ?? defaultValue;
  }

  /** Make sure that this node is visible in the viewport.
   * @see {@link Wunderbaum.scrollTo|Wunderbaum.scrollTo}
   */
  async scrollIntoView(options?: ScrollIntoViewOptions) {
    const opts = Object.assign({ node: this }, options);
    return this.tree.scrollTo(opts);
  }

  /**
   * Activate this node, deactivate previous, send events, activate column and
   * scroll into viewport.
   */
  async setActive(flag: boolean = true, options?: SetActiveOptions) {
    const tree = this.tree;
    const prev = tree.getActiveNode();
    const retrigger = options?.retrigger; // Default: false
    const focusTree = options?.focusTree; // Default: false
    // const focusNode = options?.focusNode !== false; // Default: true
    const noEvents = options?.noEvents; // Default: false
    const orgEvent = options?.event; // Default: null
    const colIdx = options?.colIdx; // Default: null
    const edit = options?.edit; // Default: false

    // util.assert(!colIdx || tree.isCellNav(), "colIdx requires cellNav");
    util.assert(!edit || colIdx != null, "edit requires colIdx");

    if (!noEvents) {
      if (flag) {
        if (prev !== this || retrigger) {
          if (
            prev?._callEvent("deactivate", {
              nextNode: this,
              event: orgEvent,
            }) === false ||
            this._callEvent("beforeActivate", {
              prevNode: prev,
              event: orgEvent,
            }) === false
          ) {
            return;
          }
          tree._setActiveNode(null);
          prev?.update(ChangeType.status);
        }
      } else if (prev === this || retrigger) {
        this._callEvent("deactivate", { nextNode: null, event: orgEvent });
      }
    }

    if (prev !== this) {
      if (flag) {
        tree._setActiveNode(this);
      }
      prev?.update(ChangeType.status);
      this.update(ChangeType.status);
    }
    return this.makeVisible().then(() => {
      if (flag) {
        if (focusTree || edit) {
          tree.setFocus();
          tree._setFocusNode(this);
          tree.focusNode!.setFocus();
        }
        // if (focusNode || edit) {
        //   tree.focusNode = this;
        //   tree.focusNode.setFocus();
        // }
        if (colIdx != null && tree.isCellNav()) {
          tree.setColumn(colIdx, { edit: edit });
        }
        if (!noEvents) {
          this._callEvent("activate", { prevNode: prev, event: orgEvent });
        }
      }
    });
  }

  /**
   * Expand or collapse this node.
   */
  async setExpanded(flag: boolean = true, options?: SetExpandedOptions) {
    const { force, scrollIntoView, immediate, resetLazy } = options ?? {};
    const sendEvents = !options?.noEvents; // Default: send events
    if (
      !flag &&
      this.isExpanded() &&
      this.getLevel() <= this.tree.getOption("minExpandLevel") &&
      !force
    ) {
      this.logDebug("Ignored collapse request below minExpandLevel.");
      return;
    }
    if (!flag === !this.expanded) {
      return; // Nothing to do
    }
    if (
      sendEvents &&
      this._callEvent("beforeExpand", { flag: flag }) === false
    ) {
      return;
    }

    // this.log("setExpanded()");
    if (flag && this.getOption("autoCollapse")) {
      this.collapseSiblings(options);
    }
    if (flag && this.lazy && this.children == null) {
      await this.loadLazy();
    } else if (!flag && resetLazy && this.lazy && this.children) {
      this.resetLazy();
    }
    this.expanded = flag;
    const updateOpts = { immediate: immediate };
    // const updateOpts = { immediate: !!util.getOption(options, "immediate") };
    this.tree.update(ChangeType.structure, updateOpts);
    if (flag && scrollIntoView) {
      const lastChild = this.getLastChild();
      if (lastChild) {
        this.tree.updatePendingModifications();
        lastChild.scrollIntoView({ topNode: this });
      }
    }
    if (sendEvents) {
      this._callEvent("expand", { flag: flag });
    }
  }

  /**
   * Set keyboard focus here.
   * @see {@link setActive}
   */
  setFocus(flag: boolean = true) {
    util.assert(!!flag, "Blur is not yet implemented");
    const prev = this.tree.focusNode;
    this.tree._setFocusNode(this);
    prev?.update();
    this.update();
  }

  /** Set a new icon path or class. */
  setIcon(icon: string) {
    this.icon = icon;
    this.update();
  }

  /** Change node's {@link key} and/or {@link refKey}.  */
  setKey(key: string | null, refKey: string | null) {
    throw new Error("Not yet implemented");
  }

  /**
   * Trigger a repaint, typically after a status or data change.
   *
   * `change` defaults to 'data', which handles modifcations of title, icon,
   * and column content. It can be reduced to 'ChangeType.status' if only
   * active/focus/selected state has changed.
   *
   * This method will eventually call  {@link WunderbaumNode._render} with
   * default options, but may be more consistent with the tree's
   * {@link Wunderbaum.update} API.
   */
  update(change: ChangeType = ChangeType.data) {
    util.assert(
      change === ChangeType.status || change === ChangeType.data,
      `Invalid change type ${change}`
    );
    this.tree.update(change, this);
  }

  /**
   * Return an array of selected nodes.
   * @param stopOnParents only return the topmost selected node (useful with selectMode 'hier')
   */
  getSelectedNodes(stopOnParents: boolean = false): WunderbaumNode[] {
    const nodeList: WunderbaumNode[] = [];
    this.visit((node) => {
      if (node.selected) {
        nodeList.push(node);
        if (stopOnParents === true) {
          return "skip"; // stop processing this branch
        }
      }
    });
    return nodeList;
  }

  /** Toggle the check/uncheck state. */
  toggleSelected(options?: SetSelectedOptions): TristateType {
    let flag = this.isSelected();
    if (flag === undefined && !this.isRadio()) {
      flag = this._anySelectable();
    } else {
      flag = !flag;
    }
    return this.setSelected(flag, options);
  }

  /** Return true if at least on selectable descendant end-node is unselected. @internal */
  _anySelectable(): boolean {
    let found = false;
    this.visit((node) => {
      if (
        node.selected === false &&
        !node.unselectable &&
        !node.hasChildren() &&
        !node.parent.radiogroup
      ) {
        found = true;
        return false; // Stop iteration
      }
    });
    return found;
  }

  /* Apply selection state to a single node. */
  protected _changeSelectStatusProps(state: TristateType): boolean {
    let changed = false;
    switch (state) {
      case false:
        changed = this.selected || this._partsel;
        this.selected = false;
        this._partsel = false;
        break;
      case true:
        changed = !this.selected || !this._partsel;
        this.selected = true;
        this._partsel = true;
        break;
      case undefined:
        changed = this.selected || !this._partsel;
        this.selected = false;
        // #110: end nodess cannot have a `_partsel` flag
        this._partsel = this.hasChildren() ? true : false;
        break;
      default:
        util.error(`Invalid state: ${state}`);
    }
    if (changed) {
      this.update();
    }
    return changed;
  }
  /**
   * Fix selection status, after this node was (de)selected in `selectMode: 'hier'`.
   * This includes (de)selecting all descendants.
   */
  fixSelection3AfterClick(opts?: SetSelectedOptions): void {
    const force = !!opts?.force;
    const flag = this.isSelected();

    this.visit((node) => {
      if (node.radiogroup) {
        return "skip"; // Don't (de)select this branch
      }
      if (force || !node.getOption("unselectable")) {
        node._changeSelectStatusProps(flag);
      }
    });
    this.fixSelection3FromEndNodes();
  }

  /**
   * Fix selection status for multi-hier mode.
   * Only end-nodes are considered to update the descendants branch and parents.
   * Should be called after this node has loaded new children or after
   * children have been modified using the API.
   */
  fixSelection3FromEndNodes(opts?: SetSelectedOptions): void {
    const force = !!opts?.force;
    util.assert(
      this.tree.options.selectMode === "hier",
      "expected selectMode 'hier'"
    );

    // Visit all end nodes and adjust their parent's `selected` and `_partsel`
    // attributes. Return selection state true, false, or undefined.
    const _walk = (node: WunderbaumNode) => {
      let state;
      const children = node.children;

      if (children && children.length) {
        // check all children recursively
        let allSelected = true;
        let someSelected = false;

        for (let i = 0, l = children.length; i < l; i++) {
          const child = children[i];
          // the selection state of a node is not relevant; we need the end-nodes
          const s = _walk(child);
          if (s !== false) {
            someSelected = true;
          }
          if (s !== true) {
            allSelected = false;
          }
        }
        state = allSelected ? true : someSelected ? undefined : false;
      } else {
        // This is an end-node: simply report the status
        state = !!node.selected;
      }
      // #939: Keep a `_partsel` flag that was explicitly set on a lazy node
      if (
        node._partsel &&
        !node.selected &&
        node.lazy &&
        node.children == null
      ) {
        state = undefined;
      }
      if (force || !node.getOption("unselectable")) {
        node._changeSelectStatusProps(state);
      }
      return state;
    };
    _walk(this);

    // Update parent's state
    this.visitParents((node) => {
      let state;
      const children = node.children!;
      let allSelected = true;
      let someSelected = false;

      for (let i = 0, l = children.length; i < l; i++) {
        const child = children[i];

        state = !!child.selected;
        // When fixing the parents, we trust the sibling status (i.e. we don't recurse)
        if (state || child._partsel) {
          someSelected = true;
        }
        if (!state) {
          allSelected = false;
        }
      }
      state = allSelected ? true : someSelected ? undefined : false;
      node._changeSelectStatusProps(state);
    });
  }

  /** Modify the check/uncheck state. */
  setSelected(
    flag: boolean = true,
    options?: SetSelectedOptions
  ): TristateType {
    const tree = this.tree;
    const sendEvents = !options?.noEvents; // Default: send events
    const prev = this.isSelected();
    const isRadio = this.parent && this.parent.radiogroup;
    const selectMode = tree.options.selectMode;
    const canSelect = options?.force || !this.getOption("unselectable");

    flag = !!flag;
    // this.logDebug(`setSelected(${flag})`, this);
    if (!canSelect) {
      return prev;
    }
    if (options?.propagateDown && selectMode === "multi") {
      tree.runWithDeferredUpdate(() => {
        this.visit((node) => {
          node.setSelected(flag);
        });
      });
      return prev;
    }

    if (
      flag === prev ||
      (sendEvents && this._callEvent("beforeSelect", { flag: flag }) === false)
    ) {
      return prev;
    }

    tree.runWithDeferredUpdate(() => {
      if (isRadio) {
        // Radiobutton Group
        if (!flag && !options?.force) {
          return prev; // don't uncheck radio buttons
        }
        for (const sibling of this.parent.children!) {
          sibling.selected = sibling === this;
        }
      } else {
        this.selected = flag;
        if (selectMode === "hier") {
          this.fixSelection3AfterClick();
        } else if (selectMode === "single") {
          tree.visit((n) => {
            n.selected = false;
          });
        }
      }
    });

    if (sendEvents) {
      this._callEvent("select", { flag: flag });
    }
    return prev;
  }

  /** Display node status (ok, loading, error, noData) using styles and a dummy child node. */
  setStatus(
    status: NodeStatusType,
    options?: SetStatusOptions
  ): WunderbaumNode | null {
    const tree = this.tree;
    const message = options?.message;
    const details = options?.details;

    let statusNode: WunderbaumNode | null = null;

    const _clearStatusNode = () => {
      // Remove dedicated dummy node, if any
      const children = this.children;

      if (children && children.length && children[0].isStatusNode()) {
        children[0].remove();
      }
    };

    const _setStatusNode = (data: any) => {
      // Create/modify the dedicated dummy node for 'loading...' or
      // 'error!' status. (only called for direct child of the invisible
      // system root)
      const children = this.children;
      const firstChild = children ? children[0] : null;

      util.assert(data.statusNodeType, "Not a status node");
      util.assert(
        !firstChild || !firstChild.isStatusNode(),
        "Child must not be a status node"
      );

      statusNode = this.addNode(data, "prependChild");
      statusNode.match = -1; // Mark as 'match' to avoid hiding
      tree.update(ChangeType.structure);

      return statusNode;
    };

    _clearStatusNode();

    switch (status) {
      case "ok":
        this._isLoading = false;
        this._errorInfo = null;
        break;
      case "loading":
        this._isLoading = true;
        this._errorInfo = null;
        if (this.parent) {
          this.update(ChangeType.status);
        } else {
          // If this is the invisible root, add a visible top-level node
          _setStatusNode({
            statusNodeType: status,
            title:
              tree.options.strings!.loading +
              (message ? " (" + message + ")" : ""),
            checkbox: false,
            colspan: true,
            tooltip: details,
          });
        }
        // this.update();
        break;
      case "error":
        _setStatusNode({
          statusNodeType: status,
          title:
            tree.options.strings!.loadError +
            (message ? " (" + message + ")" : ""),
          checkbox: false,
          colspan: true,
          // classes: "wb-center",
          tooltip: details,
        });
        this._isLoading = false;
        this._errorInfo = { message: message, details: details };
        break;
      case "noData":
        _setStatusNode({
          statusNodeType: status,
          title: message || tree.options.strings!.noData,
          checkbox: false,
          colspan: true,
          tooltip: details,
        });
        this._isLoading = false;
        this._errorInfo = null;
        break;
      default:
        util.error("invalid node status " + status);
    }
    tree.update(ChangeType.structure);
    return statusNode;
  }

  /** Rename this node. */
  setTitle(title: string): void {
    this.title = title;
    this.update();
    // this.triggerModify("rename"); // TODO
  }

  /** Set the node tooltip. */
  setTooltip(tooltip: TooltipOption): void {
    this.tooltip = tooltip;
    this.update();
  }

  _sortChildren(cmp: SortCallback, deep: boolean): void {
    const cl = this.children;

    if (!cl) {
      return;
    }
    cl.sort(cmp);
    if (deep) {
      for (let i = 0, l = cl.length; i < l; i++) {
        if (cl[i].children) {
          cl[i]._sortChildren(cmp, deep);
        }
      }
    }
  }

  /**
   * Sort child list by title or custom criteria.
   * @param {function} cmp custom compare function(a, b) that returns -1, 0, or 1
   *    (defaults to sorting by title).
   * @param {boolean} deep pass true to sort all descendant nodes recursively
   */
  sortChildren(
    cmp: SortCallback | null = nodeTitleSorter,
    deep: boolean = false
  ): void {
    this._sortChildren(cmp || nodeTitleSorter, deep);
    this.tree.update(ChangeType.structure);
    // this.triggerModify("sort"); // TODO
  }

  /**
   * Renumber nodes `_nativeIndex`. This is useful to allow to restore the
   * order after sorting a column.
   * This method is automatically called after loading new child nodes.
   * @since 0.11.0
   */
  resetNativeChildOrder(options?: ResetOrderOptions) {
    const { recursive = true, propName = "_nativeIndex" } = options ?? {};

    if (this.children) {
      this.children.forEach((child, i) => {
        child.data[propName] = i;
        if (recursive && child.children) {
          child.resetNativeChildOrder(options);
        }
      });
    }
  }

  /**
   * Convenience method to implement column sorting.
   * @since 0.11.0
   */
  sortByProperty(options: SortByPropertyOptions) {
    const {
      caseInsensitive = true,
      deep = true,
      nativeOrderPropName = "_nativeIndex",
      updateColInfo = false,
    } = options;

    let order: SortOrderType;
    let colDef: ColumnDefinition | null;

    if (updateColInfo) {
      colDef = this.tree["_columnsById"][options.colId!];
      util.assert(colDef, `Invalid colId specified: ${options.colId}`);
      order =
        options.order ??
        util.rotate(colDef!.sortOrder, ["asc", "desc", undefined]);

      for (const col of this.tree.columns) {
        col.sortOrder = col === colDef ? order : undefined;
      }

      this.tree.update(ChangeType.colStructure);
    } else {
      order = options.order ?? "asc";
    }

    let propName = options.propName ?? (options.colId || "");
    if (propName === "*") {
      propName = "title";
    }
    if (order == null) {
      propName = nativeOrderPropName;
      order = "asc";
    }
    this.logDebug(`sortByProperty(), propName=${propName}, ${order}`, options);
    util.assert(propName, "No property name specified");

    const cmp = (a: WunderbaumNode, b: WunderbaumNode) => {
      let av, bv;
      if (NODE_DICT_PROPS.has(<string>propName)) {
        av = a[propName as keyof WunderbaumNode];
        bv = b[propName as keyof WunderbaumNode];
      } else {
        av = a.data[propName];
        bv = b.data[propName];
      }
      if (av == null && bv == null) {
        return 0;
      }
      if (av == null) {
        av = typeof bv === "string" ? "" : 0;
      } else if (typeof av === "boolean") {
        av = av ? 1 : 0;
      }
      if (bv == null) {
        bv = typeof av === "string" ? "" : 0;
      } else if (typeof bv === "boolean") {
        bv = bv ? 1 : 0;
      }
      if (caseInsensitive) {
        if (typeof av === "string") {
          av = av.toLowerCase();
        }
        if (typeof bv === "string") {
          bv = bv.toLowerCase();
        }
      }
      if (order === "desc") {
        return av === bv ? 0 : av > bv ? -1 : 1;
      }
      return av === bv ? 0 : av > bv ? 1 : -1;
    };

    return this.sortChildren(cmp, deep);
  }

  /**
   * Trigger `modifyChild` event on a parent to signal that a child was modified.
   * @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ...
   */
  triggerModifyChild(
    operation: string,
    child: WunderbaumNode | null,
    extra?: any
  ) {
    this.logDebug(`modifyChild(${operation})`, extra, child);
    if (!this.tree.options.modifyChild) {
      return;
    }
    if (child && child.parent !== this) {
      util.error("child " + child + " is not a child of " + this);
    }
    this._callEvent(
      "modifyChild",
      util.extend({ operation: operation, child: child }, extra)
    );
  }

  /**
   * Trigger `modifyChild` event on node.parent(!).
   * @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ...
   * @param {object} [extra]
   */
  triggerModify(operation: string, extra?: any) {
    // if (!this.parent) {
    //   return;
    // }
    this.parent.triggerModifyChild(operation, this, extra);
  }

  /**
   * Call `callback(node)` for all descendant nodes in hierarchical order (depth-first, pre-order).
   *
   * Stop iteration, if fn() returns false. Skip current branch, if fn()
   * returns "skip".<br>
   * Return false if iteration was stopped.
   *
   * @param {function} callback the callback function.
   *     Return false to stop iteration, return "skip" to skip this node and
   *     its children only.
   * @see {@link IterableIterator<WunderbaumNode>}, {@link Wunderbaum.visit}.
   */
  visit(
    callback: NodeVisitCallback,
    includeSelf: boolean = false
  ): NodeVisitResponse {
    let res: any = true;
    const children = this.children;

    if (includeSelf === true) {
      res = callback(this);
      if (res === false || res === "skip") {
        return res;
      }
    }
    if (children) {
      for (let i = 0, l = children.length; i < l; i++) {
        res = children[i].visit(callback, true);
        if (res === false) {
          break;
        }
      }
    }
    return res;
  }

  /** Call fn(node) for all parent nodes, bottom-up, including invisible system root.<br>
   * Stop iteration, if callback() returns false.<br>
   * Return false if iteration was stopped.
   *
   * @param callback the callback function. Return false to stop iteration
   */
  visitParents(
    callback: (node: WunderbaumNode) => boolean | void,
    includeSelf: boolean = false
  ): boolean {
    if (includeSelf && callback(this) === false) {
      return false;
    }
    let p = this.parent;
    while (p) {
      if (callback(p) === false) {
        return false;
      }
      p = p.parent;
    }
    return true;
  }

  /**
   * Call fn(node) for all sibling nodes.<br>
   * Stop iteration, if fn() returns false.<br>
   * Return false if iteration was stopped.
   *
   * @param callback the callback function.
   *     Return false to stop iteration.
   * @param includeSelf include this node in the iteration.
   */
  visitSiblings(
    callback: (node: WunderbaumNode) => boolean | void,
    includeSelf: boolean = false
  ): boolean {
    const ac = this.parent.children!;

    for (let i = 0, l = ac.length; i < l; i++) {
      const n = ac[i];
      if (includeSelf || n !== this) {
        if (callback(n) === false) {
          return false;
        }
      }
    }
    return true;
  }
  /**
   * [ext-filter] Return true if this node is matched by current filter (or no filter is active).
   */
  isMatched() {
    return !(this.tree.filterMode && !this.match);
  }
}