1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2025 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17 RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new tree node widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {string} attrs.name the tree node name
27 * @param {boolean} [attrs.folder] the folder flag, defaults to `false` if no
28 * child nodes are provided in constructor call
29 * @param {string} [attrs.icon] the icon reference to use, defaults
30 * to "FOLDER" for folders and "DOCUMENT" otherwise
31 * @param {string} [attrs.tooltip] the tooltip text when hovering
32 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to `false`
33 * @param {...TreeNode} [child] the child tree node widgets
34 *
35 * @return {Widget} the widget DOM node
36 *
37 * @class The tree node widget class. Used to provide a tree node in a tree,
38 * using a number of `<div>` HTML elements. Note that events should
39 * normally not be listened for on individual tree nodes, but rather on
40 * the tree as a whole.
41 * @extends RapidContext.Widget
42 *
43 * @example <caption>JavaScript</caption>
44 * let parent = RapidContext.Widget.TreeNode({ folder: true, name: "Parent" });
45 * let child = RapidContext.Widget.TreeNode({ name: "Child" });
46 * parent.addAll(child);
47 *
48 * @example <caption>User Interface XML</caption>
49 * <TreeNode name="Parent">
50 * <TreeNode name="Child" />
51 * </TreeNode>
52 */
53RapidContext.Widget.TreeNode = function (attrs/*, ...*/) {
54 const toggle = RapidContext.Widget.Icon("fa fa-fw");
55 const icon = RapidContext.Widget.Icon("fa fa-fw fa-dot-circle-o");
56 const label = RapidContext.UI.SPAN({ "class": "widgetTreeNodeText" });
57 const cls = "widgetTreeNodeLabel overflow-ellipsis text-nowrap";
58 const div = RapidContext.UI.DIV({ "class": cls }, toggle, icon, label);
59 const o = RapidContext.UI.DIV({}, div);
60 RapidContext.Widget._widgetMixin(o, RapidContext.Widget.TreeNode);
61 o.classList.add("widgetTreeNode");
62 const isFolder = (arguments.length > 1);
63 attrs = { name: "Tree Node", folder: isFolder, ...attrs };
64 o.setAttrs(attrs);
65 o.addAll(Array.from(arguments).slice(1));
66 return o;
67};
68
69// Register widget class
70RapidContext.Widget.Classes.TreeNode = RapidContext.Widget.TreeNode;
71
72/**
73 * Returns and optionally creates the widget container DOM node. If a
74 * child container is created, it will be hidden by default.
75 *
76 * @param {boolean} [create] the create flag, defaults to `false`
77 *
78 * @return {Node} the container DOM node, or
79 * null if this widget has no container (yet)
80 */
81RapidContext.Widget.TreeNode.prototype._containerNode = function (create) {
82 let container = this.lastChild;
83 if (container.classList.contains("widgetTreeNodeContainer")) {
84 return container;
85 } else if (create) {
86 container = RapidContext.UI.DIV({ "class": "widgetTreeNodeContainer widgetHidden" });
87 this.append(container);
88 this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-plus-square-o" });
89 if (!this.icon) {
90 this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder" });
91 }
92 return container;
93 } else {
94 return null;
95 }
96};
97
98/**
99 * Updates the widget or HTML DOM node attributes.
100 *
101 * @param {Object} attrs the widget and node attributes to set
102 * @param {string} [attrs.name] the tree node name
103 * @param {boolean} [attrs.folder] the folder flag, cannot be
104 * reverted to `false` once set (implicitly or explicitly)
105 * @param {Icon|Object|string} [attrs.icon] icon the icon to set, or
106 * null to remove
107 * @param {string} [attrs.tooltip] the tooltip text when hovering
108 * @param {boolean} [attrs.hidden] the hidden widget flag
109 */
110RapidContext.Widget.TreeNode.prototype.setAttrs = function (attrs) {
111 this.marked = false;
112 attrs = { ...attrs };
113 if ("name" in attrs) {
114 this.querySelector(".widgetTreeNodeText").innerText = attrs.name;
115 }
116 if ("folder" in attrs) {
117 this._containerNode(RapidContext.Data.bool(attrs.folder));
118 delete attrs.folder;
119 }
120 if ("icon" in attrs) {
121 const icon = RapidContext.Widget.Icon(attrs.icon);
122 this.firstChild.childNodes[1].replaceWith(icon);
123 }
124 if ("tooltip" in attrs) {
125 this.firstChild.title = attrs.tooltip;
126 }
127 this.__setAttrs(attrs);
128};
129
130/**
131 * Adds a single child tree node widget to this widget.
132 *
133 * @param {Widget} child the tree node widget to add
134 */
135RapidContext.Widget.TreeNode.prototype.addChildNode = function (child) {
136 if (!RapidContext.Widget.isWidget(child, "TreeNode")) {
137 throw new Error("TreeNode widget can only have TreeNode children");
138 }
139 this._containerNode(true).append(child);
140};
141
142/**
143 * Removes a single child tree node widget from this widget.
144 *
145 * @param {Widget} child the tree node widget to remove
146 */
147RapidContext.Widget.TreeNode.prototype.removeChildNode = function (child) {
148 const elem = this._containerNode();
149 if (elem) {
150 child && child.unselect();
151 elem.removeChild(child);
152 }
153};
154
155/**
156 * Removes all marked tree nodes. When adding or updating tree nodes, any
157 * node modified is automatically unmarked (e.g. by calling `setAttrs`). This
158 * makes it easy to prune a tree after an update, by initially marking all
159 * tree nodes with `markAll()`, inserting or touching all nodes to keep, and
160 * finally calling this method to remove the remaining nodes.
161 *
162 * @example
163 * parent.markAll();
164 * parent.setAttrs();
165 * child.setAttrs();
166 * ...
167 * parent.removeAllMarked();
168 */
169RapidContext.Widget.TreeNode.prototype.removeAllMarked = function () {
170 const children = this.getChildNodes();
171 for (let i = 0; i < children.length; i++) {
172 if (children[i].marked === true) {
173 this.removeChildNode(children[i]);
174 } else {
175 children[i].removeAllMarked();
176 }
177 }
178};
179
180/**
181 * Marks this tree node and all child nodes recursively. When adding or
182 * updating tree nodes, any node modified is automatically unmarked (e.g. by
183 * calling `setAttrs`). This makes it easy to prune a tree after an update, by
184 * initially marking all tree nodes, inserting or touching all nodes to keep,
185 * and finally calling `removeAllMarked()` to remove the remaining nodes.
186 *
187 * @example
188 * parent.markAll();
189 * parent.setAttrs();
190 * child.setAttrs();
191 * ...
192 * parent.removeAllMarked();
193 */
194RapidContext.Widget.TreeNode.prototype.markAll = function () {
195 this.marked = true;
196 const children = this.getChildNodes();
197 for (let i = 0; i < children.length; i++) {
198 children[i].markAll();
199 }
200};
201
202/**
203 * Checks if this node is a folder.
204 *
205 * @return {boolean} `true` if this node is a folder, or
206 * `false` otherwise
207 */
208RapidContext.Widget.TreeNode.prototype.isFolder = function () {
209 return this._containerNode() != null;
210};
211
212/**
213 * Checks if this folder node is expanded.
214 *
215 * @return {boolean} `true` if this node is expanded, or
216 * `false` otherwise
217 */
218RapidContext.Widget.TreeNode.prototype.isExpanded = function () {
219 const container = this._containerNode();
220 return !!container && !container.classList.contains("widgetHidden");
221};
222
223/**
224 * Checks if this node is selected.
225 *
226 * @return {boolean} `true` if the node is selected, or
227 * `false` otherwise
228 */
229RapidContext.Widget.TreeNode.prototype.isSelected = function () {
230 return this.firstChild.classList.contains("selected");
231};
232
233/**
234 * Returns the ancestor tree widget.
235 *
236 * @return {Widget} the ancestor tree widget, or
237 * null if none was found
238 */
239RapidContext.Widget.TreeNode.prototype.tree = function () {
240 const parent = this.parent();
241 if (parent != null) {
242 return parent.tree();
243 }
244 if (RapidContext.Widget.isWidget(this.parentNode, "Tree")) {
245 return this.parentNode;
246 } else {
247 return null;
248 }
249};
250
251/**
252 * Returns the parent tree node widget.
253 *
254 * @return {Widget} the parent tree node widget, or
255 * null if this is a root node
256 */
257RapidContext.Widget.TreeNode.prototype.parent = function () {
258 const node = this.parentNode;
259 if (node && node.classList.contains("widgetTreeNodeContainer")) {
260 return node.parentNode;
261 } else {
262 return null;
263 }
264};
265
266/**
267 * Returns the path to this tree node.
268 *
269 * @return {Array} the tree node path, i.e an array of node names
270 */
271RapidContext.Widget.TreeNode.prototype.path = function () {
272 const parent = this.parent();
273 if (parent == null) {
274 return [this.name];
275 } else {
276 const path = parent.path();
277 path.push(this.name);
278 return path;
279 }
280};
281
282/**
283 * Finds a child tree node with the specified name.
284 *
285 * @param {string} name the child tree node name
286 *
287 * @return {Widget} the child tree node found, or
288 * null if not found
289 */
290RapidContext.Widget.TreeNode.prototype.findChild = function (name) {
291 const children = this.getChildNodes();
292 for (let i = 0; i < children.length; i++) {
293 if (children[i].name == name) {
294 return children[i];
295 }
296 }
297 return null;
298};
299
300/**
301 * Searches for a descendant tree node from the specified path.
302 *
303 * @param {Array} path the tree node path (array of node names)
304 *
305 * @return {Widget} the descendant tree node found, or
306 * null if not found
307 */
308RapidContext.Widget.TreeNode.prototype.findByPath = function (path) {
309 let node = this;
310 if (path != null) {
311 for (let i = 0; node != null && i < path.length; i++) {
312 node = node.findChild(path[i]);
313 }
314 }
315 return node;
316};
317
318/**
319 * Selects this tree node.
320 */
321RapidContext.Widget.TreeNode.prototype.select = function () {
322 this.firstChild.classList.add("selected");
323 const tree = this.tree();
324 if (tree != null) {
325 tree._handleSelect(this);
326 }
327 this.expand();
328};
329
330/**
331 * Unselects this tree node.
332 */
333RapidContext.Widget.TreeNode.prototype.unselect = function () {
334 if (this.isSelected()) {
335 this.firstChild.classList.remove("selected");
336 const tree = this.tree();
337 if (tree != null) {
338 tree._handleSelect(null);
339 }
340 }
341};
342
343/**
344 * Expands this node to display any child nodes. If the parent node
345 * is not expanded, it will be expanded as well.
346 */
347RapidContext.Widget.TreeNode.prototype.expand = function () {
348 const parent = this.parent();
349 if (parent != null && !parent.isExpanded()) {
350 parent.expand();
351 }
352 const container = this._containerNode();
353 if (container != null && !this.isExpanded()) {
354 this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-minus-square-o" });
355 if (!this.icon) {
356 this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder-open" });
357 }
358 container.classList.remove("widgetHidden");
359 const tree = this.tree();
360 if (tree != null) {
361 const detail = { tree: tree, node: this };
362 tree.emit("expand", { detail: detail });
363 }
364 }
365};
366
367/**
368 * Recursively expands this node and all its children. If a depth is
369 * specified, expansions will not continue below that depth.
370 *
371 * @param {number} [depth] the optional maximum depth
372 */
373RapidContext.Widget.TreeNode.prototype.expandAll = function (depth) {
374 if (typeof(depth) !== "number") {
375 depth = 10;
376 }
377 this.expand();
378 if (depth > 0) {
379 const children = this.getChildNodes();
380 for (let i = 0; i < children.length; i++) {
381 children[i].expandAll(depth - 1);
382 }
383 }
384};
385
386/**
387 * Collapses this node to hide any child nodes.
388 */
389RapidContext.Widget.TreeNode.prototype.collapse = function () {
390 const container = this._containerNode();
391 if (container != null && this.isExpanded()) {
392 this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-plus-square-o" });
393 if (!this.icon) {
394 this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder" });
395 }
396 container.classList.add("widgetHidden");
397 const tree = this.tree();
398 if (tree != null) {
399 const detail = { tree: tree, node: this };
400 tree.emit("collapse", { detail: detail });
401 }
402 }
403};
404
405/**
406 * Recursively collapses this node and all its children. If a depth
407 * is specified, only children below that depth will be collapsed.
408 *
409 * @param {number} [depth] the optional minimum depth
410 */
411RapidContext.Widget.TreeNode.prototype.collapseAll = function (depth) {
412 if (typeof(depth) !== "number") {
413 depth = 0;
414 }
415 if (depth <= 0) {
416 this.collapse();
417 }
418 const children = this.getChildNodes();
419 for (let i = 0; i < children.length; i++) {
420 children[i].collapseAll(depth - 1);
421 }
422};
423
424/**
425 * Toggles expand and collapse for this node.
426 */
427RapidContext.Widget.TreeNode.prototype.toggle = function () {
428 if (this.isExpanded()) {
429 this.collapse();
430 } else {
431 this.expand();
432 }
433};
434
RapidContext
Access · Discovery · Insight
www.rapidcontext.com