Source: frontend-taglib/src/main/resources/META-INF/resources/cards_treeview/CardsTreeview.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import {Treeview} from 'frontend-js-web';
import Soy from 'metal-soy';
import {Config} from 'metal-state';

import templates from './CardsTreeview.soy';

/**
 * CardsTreeview
 *
 * This is an extension of the default TreeView component that adds
 * the following features:
 *
 * - Node selection management, both single and multiple
 * - Custom tree node template using Lexicon Horizontal Cards
 * - Improved accessibility for keyboard navigation following common tree widget patterns
 * @review
 */
class CardsTreeview extends Treeview {
	/**
	 * @inheritDoc
	 * @review
	 */
	created() {
		this.expandSelectedNodesParentNodes_(this.nodes);
		this.addSelectedNodes_(this.nodes);
	}

	/**
	 * Adds nodes with selected attribute to selectedNodes list in case when
	 * they are still not there.
	 * @param nodes Nodes to check and add to selectedNodes list.
	 * @protected
	 * @review
	 */
	addSelectedNodes_(nodes) {
		nodes.forEach(node => {
			if (node.children) {
				this.addSelectedNodes_(node.children);
			}

			if (node.selected) {
				this.selectNode_(node);
			}
		});
	}

	/**
	 * Deselects all selected tree nodes
	 * @protected
	 * @review
	 */
	deselectAll_() {
		for (let i = this.selectedNodes.length - 1; i >= 0; i--) {
			this.selectedNodes[i].selected = false;
			this.selectedNodes.pop();
		}
	}

	/**
	 * Selects specific nodes
	 * @param node to deselect.
	 * @protected
	 * @review
	 */
	deselectNode_(node) {
		node.selected = false;

		this.selectedNodes.splice(this.selectedNodes.indexOf(node), 1);

		this.selectedNodes = this.selectedNodes;
	}

	/**
	 * Expands all parent nodes of expanded children.
	 * @param nodes List of nodes to expand all parent nodes of expanded children.
	 * @protected
	 * @review
	 */
	expandSelectedNodesParentNodes_(nodes) {
		let expanded;
		let expandedParent;

		nodes.forEach(node => {
			expanded = node.expanded;

			if (node.selected) {
				expandedParent = true;
			}

			if (node.children) {
				expanded =
					this.expandSelectedNodesParentNodes_(node.children) ||
					expanded;
			}

			node.expanded = expanded;
		}, this);

		return expandedParent;
	}

	/**
	 * Focus the given tree node.
	 * @param {!Object} nodeObj
	 * @protected
	 * @review
	 */
	focus_(nodeObj) {
		if (nodeObj) {
			this.element
				.querySelector('[data-treeitemid="' + nodeObj.id + '"] .card')
				.focus();
		}
	}

	/**
	 * Focus the next tree node of given tree node.
	 * @param {!Element} node
	 * @protected
	 * @review
	 */
	focusNextNode_(node) {
		const path = node.getAttribute('data-treeview-path').split('-');

		const nodeObj = this.getNodeObj(path);

		let nextNodeObj;

		if (nodeObj.children && nodeObj.expanded) {
			path.push(0);

			nextNodeObj = this.getNodeObj(path);
		} else {
			while (!nextNodeObj && path.length > 0) {
				path[path.length - 1]++;

				nextNodeObj = this.getNodeObj(path);

				path.pop();
			}
		}

		this.focus_(nextNodeObj);
	}

	/**
	 * Focus the previous tree node of given tree node.
	 * @param {!Element} node
	 * @protected
	 * @review
	 */
	focusPrevNode_(node) {
		const path = node.getAttribute('data-treeview-path').split('-');

		let prevNodeObj;

		if (path[path.length - 1] === '0') {
			path.pop();

			prevNodeObj = this.getNodeObj(path);
		} else {
			path[path.length - 1]--;

			prevNodeObj = this.getNodeObj(path);

			while (prevNodeObj.children && prevNodeObj.expanded) {
				prevNodeObj =
					prevNodeObj.children[prevNodeObj.children.length - 1];
			}
		}

		this.focus_(prevNodeObj);
	}

	/**
	 * This is called when one of this tree view's nodes is clicked.
	 * @param {!Event} event
	 * @protected
	 * @review
	 */
	handleNodeClicked_(event) {
		const path = event.delegateTarget.parentNode.parentNode.parentNode
			.getAttribute('data-treeview-path')
			.split('-');

		const node = this.getNodeObj(path);

		if (node.disabled) {
			return;
		}

		if (this.multiSelection) {
			if (node.selected) {
				this.deselectNode_(node);
			} else {
				this.selectNode_(node);
			}
		} else if (!node.selected) {
			this.deselectAll_();
			this.selectNode_(node);
		}

		this.nodes = this.nodes;
	}

	/**
	 * This is called when one of this tree view's nodes receives a keypress.
	 * Depending on the pressed key, the tree will:
	 * - ENTER or SPACE: Select the current node
	 * - DOWN ARROW: Focus the next node
	 * - UP ARROW: Focus the previous node
	 * - LEFT ARROW: Collapse the current node
	 * - RIGHT ARROW: Expand the current node
	 * @param {!Event} event
	 * @protected
	 * @review
	 */
	handleNodeKeyUp_(event) {
		const node = event.delegateTarget.parentNode.parentNode.parentNode;

		if (event.keyCode === 37) {
			this.setNodeExpandedState_(node, {
				expanded: false
			});
		} else if (event.keyCode === 38) {
			this.focusPrevNode_(node);
		} else if (event.keyCode === 39) {
			this.setNodeExpandedState_(node, {
				expanded: true
			});
		} else if (event.keyCode === 40) {
			this.focusNextNode_(node);
		} else if (event.keyCode === 13 || event.keyCode === 32) {
			this.handleNodeClicked_(event);
		}
	}

	/**
	 * This is called when one of this tree view's nodes toggler is clicked.
	 * @param {!Event} event
	 * @protected
	 * @review
	 */
	handleNodeTogglerClicked_(event) {
		this.toggleExpandedState_(
			event.delegateTarget.parentNode.parentNode.parentNode
		);
	}

	/**
	 * Selects specific node.
	 * @param node to select.
	 * @protected
	 * @review
	 */
	selectNode_(node) {
		node.selected = true;

		this.selectedNodes.push(node);

		this.selectedNodes = this.selectedNodes;
	}

	/**
	 * Sets the expanded state of a node
	 * @param {!Element} node The tree node we want to change the expanded state to
	 * @param {!Object} state A state object with the new value of the expanded state
	 * @protected
	 * @review
	 */
	setNodeExpandedState_(node, state) {
		const path = node.getAttribute('data-treeview-path').split('-');

		const nodeObj = this.getNodeObj(path);

		nodeObj.expanded = state.expanded;

		this.nodes = this.nodes;
	}
}

/**
 * CardsTreeview state definition.
 * @type {!Object}
 * @review
 * @static
 */
CardsTreeview.STATE = {
	/**
	 * Enables multiple selection of tree elements
	 * @review
	 * @type {boolean}
	 */
	multiSelection: Config.bool().value(false),

	/**
	 * List of selected nodes
	 * @review
	 * @type {Array.<Object>}
	 */
	selectedNodes: Config.array().value([]),

	/**
	 * Type of view to render. Accepted values are 'tree' and 'flat'
	 * @review
	 * @type {String}
	 */
	viewType: Config.string().value('tree')
};

Soy.register(CardsTreeview, templates);

export default CardsTreeview;