// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* @fileoverview
* Base class and helpers for tree navigation UI with collapsible branches and
* keyboard navigation.
*/
/**
* Base class for UI to render and navigate a tree structure. In DOM this is
* rendered as a nested <ul> with <li> for each vertex. Each vertex can be:
* * A "group" containing <a class="node"> for structure. A group can be
* expanded or unexpanded, and is controlled by the base class.
* * A "leaf" containing <span class="node"> for data ldaves. A leaf is
, controlled by derived classes.
* Element rendering is done by derived classes, using custom templates.
*
* @template NODE_DATA_TYPE The data type of a tree node (groups and leaves)
*/
class TreeUi {
/** @param {!HTMLUListElement} rootElt */
constructor(rootElt) {
/** @protected @const {!HTMLUListElement} rootElt */
this.rootElt = rootElt;
/**
* @protected {HTMLCollectionOf<!TreeNodeElement>} Collection of all tree
* node elements. Updates itself automatically.
*/
this.liveNodeList =
/** @type {HTMLCollectionOf<!TreeNodeElement>} */ (
rootElt.getElementsByClassName('node'));
/**
* @protected @const {!WeakMap<HTMLElement, Readonly<NODE_DATA_TYPE>>}
* Maps from UI nodes to data object to enable queries by event listeners
* and other methods.
*/
this.uiNodeToData = new WeakMap();
/** @private @const {function(!MouseEvent): *} */
this.boundToggleGroupElement = this.toggleGroupElement.bind(this);
}
/**
* Decides whether |elt| is the node of a leaf or an unexpanded group.
* @param {!HTMLElement} elt
* @return {boolean}
* @protected
*/
isTerminalElement(elt) {
return elt.classList.contains('node') &&
elt.getAttribute('aria-expanded') === null;
}
/**
* Sets focus to a new tree element while updating the element that last had
* focus. The tabindex property is used to avoid needing to tab through every
* single tree item in the page to reach other areas.
* @param {?TreeNodeElement} nodeElt A tree node element.
* @protected
*/
setFocusElement(nodeElt) {
const lastFocused = /** @type {HTMLElement} */ (document.activeElement);
// If the last focused element was a tree node element, change its tabindex.
if (this.uiNodeToData.has(lastFocused))
lastFocused.tabIndex = -1;
if (nodeElt) {
nodeElt.tabIndex = 0;
nodeElt.focus();
}
}
/**
* Same as setFocusElement(), but takes index into |liveNodeList| instead.
* @param {number} index
* @protected
*/
setFocusElementByIndex(index) {
this.setFocusElement(this.liveNodeList[index]);
}
/**
* Creates an element for |nodeData| to represent a group or a leaf, which
* depends on whether there are >= 1 children. May bind events.
* @param {!NODE_DATA_TYPE} nodeData
* @return {!{fragment: !DocumentFragment, isLeaf: boolean}}
* @abstract @protected
*/
makeGroupOrLeafFragment(nodeData) {
return null;
}
/**
* Creates an Element for |nodeData|, and binds click on group nodes to
* toggleGroupElement().
* @param {!NODE_DATA_TYPE} nodeData
* @return {!DocumentFragment}
* @public
*/
makeNodeElement(nodeData) {
const {fragment, isLeaf} = this.makeGroupOrLeafFragment(nodeData);
const nodeElt = /** @type {TreeNodeElement} */ (
assertNotNull(fragment.querySelector('.node')));
// Associate clickable node & tree data.
this.uiNodeToData.set(nodeElt, Object.freeze(nodeData));
// Add click-to-toggle to group nodes.
if (!isLeaf)
nodeElt.addEventListener('click', this.boundToggleGroupElement);
return fragment;
}
/**
* Gets data for children of a group. Note that |link| is passed instead
* @param {!HTMLAnchorElement} link
* @return {!Promise<!Array<!NODE_DATA_TYPE>>}
* @abstract @protected
*/
async getGroupChildrenData(link) {
return null;
}
/**
* @param {!HTMLAnchorElement} link
* @return {!HTMLLIElement}
* @protected
*/
getTreeItemFromLink(link) {
// Canonical structure:
// <li> <!-- |treeitem| -->
// <a class="node">...</a> <!-- |link| -->
// <ul>...</ul> <!-- |group| -->
// </li>
return /** @type {!HTMLLIElement} */ (link.parentElement);
}
/**
* @param {!HTMLAnchorElement} link
* @return {!HTMLUListElement}
* @protected
*/
getGroupFromLink(link) {
return /** @type {!HTMLUListElement} */ (link.nextElementSibling);
}
/**
* @param {!HTMLElement} link
* @param {!Array<DocumentFragment>} childrenElements
* @protected
*/
autoExpandAttentionWorthyChild(link, childrenElements) {
if (childrenElements.length === 1) {
// Open inner element if it only has a single child; this ensures nodes
// like "java"->"com"->"google" are opened all at once.
const node = /** @type {!TreeNodeElement} */ (
childrenElements[0].querySelector('.node'));
node.click();
}
}
/**
* Populates |link| with
* @param {!HTMLAnchorElement} link
* @protected
*/
async expandGroupElement(link) {
const childrenData = await this.getGroupChildrenData(link);
const newElements = childrenData.map((data) => this.makeNodeElement(data));
this.autoExpandAttentionWorthyChild(link, newElements);
const newElementsFragment = dom.createFragment(newElements);
requestAnimationFrame(() => {
this.getGroupFromLink(link).appendChild(newElementsFragment);
});
}
/**
* Click event handler to expand or close a group node.
* @param {Event} event
* @protected
*/
async toggleGroupElement(event) {
event.preventDefault();
const link = /** @type {!HTMLAnchorElement} */ (event.currentTarget);
const treeitem = this.getTreeItemFromLink(link);
const group = this.getGroupFromLink(link);
const isExpanded = treeitem.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
// Take keyboard focus from descendent node.
const lastFocused = /** @type {HTMLElement} */ (document.activeElement);
if (lastFocused && group.contains(lastFocused))
this.setFocusElement(link);
// Update DOM.
treeitem.setAttribute('aria-expanded', 'false');
dom.replace(group, null);
} else {
treeitem.setAttribute('aria-expanded', 'true');
await this.expandGroupElement(link);
}
}
/**
* Helper to handle tree navigation on keydown event.
* @param {!KeyboardEvent} event Event passed from keydown event listener.
* @param {!TreeNodeElement} link Tree node element, either a group or leaf.
* Trees use <a> tags, leaves use <span> tags. For example, see
* #tmpl-symbol-tree-group and #tmpl-symbol-tree-leaf.
* @param {number} focusIndex
* @return {boolean} Whether the event is handled.
* @protected
*/
handleKeyNavigationCommon(event, link, focusIndex) {
/** Focuses the tree element immediately following this one. */
const focusNext = () => {
if (focusIndex > -1 && focusIndex < this.liveNodeList.length - 1) {
event.preventDefault();
this.setFocusElementByIndex(focusIndex + 1);
}
};
/** Opens or closes the tree element. */
const toggle = () => {
event.preventDefault();
/** @type {HTMLAnchorElement} */ (link).click();
};
switch (event.key) {
// Space should act like clicking or pressing enter & toggle the tree.
case ' ':
toggle();
return true;
// Move to previous focusable node.
case 'ArrowUp':
if (focusIndex > 0) {
event.preventDefault();
this.setFocusElementByIndex(focusIndex - 1);
}
return true;
// Move to next focusable node.
case 'ArrowDown':
focusNext();
return true;
// If closed tree, open tree. Otherwise, move to first child.
case 'ArrowRight': {
const expanded = link.parentElement.getAttribute('aria-expanded');
// Handle groups only (leaves do not have aria-expanded property).
if (expanded !== null) {
if (expanded === 'true') {
focusNext();
} else {
toggle();
}
}
return true;
}
// If opened tree, close tree. Otherwise, move to parent.
case 'ArrowLeft': {
const isExpanded =
link.parentElement.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
toggle();
} else {
const groupList = link.parentElement.parentElement;
if (groupList.getAttribute('role') === 'group') {
event.preventDefault();
/** @type {HTMLAnchorElement} */
const parentLink = /** @type {HTMLAnchorElement} */ (
groupList.previousElementSibling);
this.setFocusElement(parentLink);
}
}
return true;
}
// Focus first node.
case 'Home':
event.preventDefault();
this.setFocusElementByIndex(0);
return true;
// Focus last node on screen.
case 'End':
event.preventDefault();
this.setFocusElementByIndex(this.liveNodeList.length - 1);
return true;
// Expand all sibling nodes.
case '*':
const groupList = link.parentElement.parentElement;
if (groupList.getAttribute('role') === 'group') {
event.preventDefault();
for (const li of groupList.children) {
if (li.getAttribute('aria-expanded') !== 'true') {
const otherLink =
/** @type {!TreeNodeElement} */ (li.querySelector('.node'));
otherLink.click();
}
}
}
return true;
// Remove focus from the tree view.
case 'Escape':
link.blur();
return true;
}
return false;
}
/**
* Handler for gaining focus relative to other TreeUi instances.
* @protected
*/
onTreeFocus() {}
/**
* Handler for losing focus relative to other TreeUi instances, i.e., this
* does NOT fire when non-TreeUi UI elements gain focus.
* @protected
*/
onTreeBlur() {}
/** @public */
focus() {
if (TreeUi.activeTreeUi !== this) {
if (TreeUi.activeTreeUi)
TreeUi.activeTreeUi.onTreeBlur();
TreeUi.activeTreeUi = this;
TreeUi.activeTreeUi.onTreeFocus();
}
}
/** @public */
init() {
// Each instance contributes to managing focus / blur dynamics.
this.rootElt.addEventListener('click', () => this.focus());
}
}
/** @type {?TreeUi} */
TreeUi.activeTreeUi = null;