chrome-devtools-frontend
Version:
Chrome DevTools UI
315 lines (277 loc) • 12.2 kB
text/typescript
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Platform from '../../../core/platform/platform.js';
import * as LitHtml from '../../lit-html/lit-html.js';
export type TreeNodeId = string;
interface BaseTreeNode<TreeNodeDataType> {
treeNodeData: TreeNodeDataType;
renderer?: (node: TreeNode<TreeNodeDataType>, state: {isExpanded: boolean}) => LitHtml.TemplateResult;
id: TreeNodeId;
}
export interface TreeNodeWithChildren<TreeNodeDataType> extends BaseTreeNode<TreeNodeDataType> {
children: () => Promise<TreeNode<TreeNodeDataType>[]>;
}
interface LeafNode<TreeNodeDataType> extends BaseTreeNode<TreeNodeDataType> {
children?: never;
}
export type TreeNode<TreeNodeDataType> = TreeNodeWithChildren<TreeNodeDataType>|LeafNode<TreeNodeDataType>;
export function isExpandableNode<TreeNodeDataType>(node: TreeNode<TreeNodeDataType>):
node is TreeNodeWithChildren<TreeNodeDataType> {
return 'children' in node;
}
/**
* This is a custom lit-html directive that lets us track the DOM nodes that Lit
* creates and maps them to the tree node that was given to us. This means we
* can navigate between real DOM node and structural tree node easily in code.
*/
class TrackDOMNodeToTreeNode extends LitHtml.Directive.Directive {
constructor(partInfo: LitHtml.Directive.PartInfo) {
super(partInfo);
if (partInfo.type !== LitHtml.Directive.PartType.ATTRIBUTE) {
throw new Error('TrackDOMNodeToTreeNode directive must be used as an attribute.');
}
}
override update(part: LitHtml.Directive.ElementPart, [weakMap, treeNode]: LitHtml.Directive.DirectiveParameters<this>): void {
const elem = part.element;
if (!(elem instanceof HTMLLIElement)) {
throw new Error('trackTreeNodeToDOMNode must be used on <li> elements.');
}
weakMap.set(elem, treeNode);
}
/*
* Because this directive doesn't render anything, there's no implementation
* here for the render method. But we need it to state the params the
* directive takes so the update() method's types are correct. Unfortunately
* we have to pass any as the generic type because we can't define this class
* using a generic - the generic gets lost when wrapped in the directive call
* below.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(_weakmap: WeakMap<HTMLLIElement, TreeNode<any>>, _treeNode: TreeNode<any>): void {
}
}
export const trackDOMNodeToTreeNode = LitHtml.Directive.directive(TrackDOMNodeToTreeNode);
/**
* Finds the next sibling of the node's parent, recursing up the tree if
* required.
* Given:
* A
* * B
* * C
* D
* If called on B, this will return D. If called on C, this will also return D.
*/
const findNextParentSibling = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
// We go up two parents here because the structure is:
// <li treeitem> => <ul group> => <li treeitem>
// So if we are on the last treeitem (furthest to the right), we need to find its parent tree item, which is two parents up.
const currentDOMNodeParentListItem = currentDOMNode.parentElement?.parentElement;
if (currentDOMNodeParentListItem && currentDOMNodeParentListItem instanceof HTMLLIElement) {
const parentNodeSibling = currentDOMNodeParentListItem.nextElementSibling;
// If this parent doesn't have a sibling, recurse up the tree to look for
// the nearest parent that does have a sibling.
if (parentNodeSibling && parentNodeSibling instanceof HTMLLIElement) {
return parentNodeSibling;
}
return findNextParentSibling(currentDOMNodeParentListItem);
}
return null;
};
const getFirstChildOfExpandedTreeNode = (currentDOMNode: HTMLLIElement): HTMLLIElement => {
const firstChild =
currentDOMNode.querySelector<HTMLLIElement>(':scope > [role="group"] > [role="treeitem"]:first-child');
if (!firstChild) {
throw new Error('Could not find child of expanded node.');
}
return firstChild;
};
const domNodeIsExpandable = (domNode: HTMLLIElement): boolean => {
// Nodes with no children are not given the aria-expanded attributes.
// Nodes with children are given aria-expanded = true/false.
return domNode.getAttribute('aria-expanded') !== null;
};
const domNodeIsLeafNode = (domNode: HTMLLIElement): boolean => {
return !domNodeIsExpandable(domNode);
};
const domNodeIsExpanded = (domNode: HTMLLIElement): boolean => {
// Nodes with no children are not given the aria-expanded attributes.
// Nodes with children are given aria-expanded = true/false.
return domNodeIsExpandable(domNode) && domNode.getAttribute('aria-expanded') === 'true';
};
const getDeepLastChildOfExpandedTreeNode = (currentDOMNode: HTMLLIElement): HTMLLIElement => {
const lastChild =
currentDOMNode.querySelector<HTMLLIElement>(':scope > [role="group"] > [role="treeitem"]:last-child');
if (!lastChild) {
throw new Error('Could not find child of expanded node.');
}
if (domNodeIsExpanded(lastChild)) {
return getDeepLastChildOfExpandedTreeNode(lastChild);
}
return lastChild;
};
const getNextSiblingOfCurrentDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
const currentNodeSibling = currentDOMNode.nextElementSibling;
if (currentNodeSibling && currentNodeSibling instanceof HTMLLIElement) {
return currentNodeSibling;
}
return null;
};
const getPreviousSiblingOfCurrentDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
const currentNodeSibling = currentDOMNode.previousElementSibling;
if (currentNodeSibling && currentNodeSibling instanceof HTMLLIElement) {
return currentNodeSibling;
}
return null;
};
const getParentListItemForDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
let parentNode = currentDOMNode.parentElement;
if (!parentNode) {
return null;
}
while (parentNode && parentNode.getAttribute('role') !== 'treeitem' &&
(parentNode instanceof HTMLLIElement) === false) {
parentNode = parentNode.parentElement;
}
return parentNode as HTMLLIElement;
};
/**
* We cache a tree node's children; they are lazily evaluated and if two code
* paths get the children, we need to make sure they get the same objects.
*
* We're OK to use <unknown> here as the weakmap doesn't care and a TreeOutline that
* adds nodes of type X to the map will always then get children of that type
* back as that's enforced by the TreeOutline types elsewhere. We can't make
* this WeakMap easily generic as it's a top level variable.
*/
const treeNodeChildrenWeakMap = new WeakMap<TreeNode<unknown>, TreeNode<unknown>[]>();
export const getNodeChildren =
async<TreeNodeDataType>(node: TreeNode<TreeNodeDataType>): Promise<TreeNode<TreeNodeDataType>[]> => {
if (!node.children) {
throw new Error('Asked for children of node that does not have any children.');
}
const cachedChildren = treeNodeChildrenWeakMap.get(node as TreeNode<unknown>);
if (cachedChildren) {
return cachedChildren as unknown as TreeNode<TreeNodeDataType>[];
}
const children = await node.children();
treeNodeChildrenWeakMap.set(node as TreeNode<unknown>, children as TreeNode<unknown>[]);
return children;
};
/**
* Searches the tree and returns a path to the given node.
* e.g. if the tree is:
* A
* - B
* - C
* - D
* - E
* - F
*
* And you look for F, you'll get back [A, D, F]
*/
export const getPathToTreeNode =
async<TreeNodeDataType>(tree: readonly TreeNode<TreeNodeDataType>[], nodeIdToFind: TreeNodeId):
Promise<TreeNode<TreeNodeDataType>[]|null> => {
for (const rootNode of tree) {
const foundPathOrNull = await getPathToTreeNodeRecursively(rootNode, nodeIdToFind, [rootNode]);
if (foundPathOrNull !== null) {
return foundPathOrNull;
}
}
return null;
};
const getPathToTreeNodeRecursively = async<TreeNodeDataType>(
currentNode: TreeNode<TreeNodeDataType>, nodeIdToFind: TreeNodeId,
pathToNode: TreeNode<TreeNodeDataType>[]): Promise<TreeNode<TreeNodeDataType>[]|null> => {
if (currentNode.id === nodeIdToFind) {
return pathToNode;
}
if (currentNode.children) {
const children = await getNodeChildren(currentNode);
for (const child of children) {
const foundPathOrNull = await getPathToTreeNodeRecursively(child, nodeIdToFind, [...pathToNode, child]);
if (foundPathOrNull !== null) {
return foundPathOrNull;
}
}
}
return null;
};
interface KeyboardNavigationOptions<TreeNodeDataType> {
currentDOMNode: HTMLLIElement;
currentTreeNode: TreeNode<TreeNodeDataType>;
direction: Platform.KeyboardUtilities.ArrowKey;
setNodeExpandedState: (treeNode: TreeNode<TreeNodeDataType>, expanded: boolean) => void;
}
export const findNextNodeForTreeOutlineKeyboardNavigation =
<TreeNodeDataType>(options: KeyboardNavigationOptions<TreeNodeDataType>): HTMLLIElement => {
const {
currentDOMNode,
currentTreeNode,
direction,
setNodeExpandedState,
} = options;
if (!currentTreeNode) {
return currentDOMNode;
}
if (direction === Platform.KeyboardUtilities.ArrowKey.DOWN) {
// If the node has expanded children, down takes you into that list.
if (domNodeIsExpanded(currentDOMNode)) {
return getFirstChildOfExpandedTreeNode(currentDOMNode);
}
// If the node has a sibling, we go to that.
const currentNodeSibling = getNextSiblingOfCurrentDOMNode(currentDOMNode);
if (currentNodeSibling) {
return currentNodeSibling;
}
// If the Node's parent has a sibling then we go to that.
const parentSibling = findNextParentSibling(currentDOMNode);
if (parentSibling) {
return parentSibling;
}
} else if (direction === Platform.KeyboardUtilities.ArrowKey.RIGHT) {
if (domNodeIsLeafNode(currentDOMNode)) {
// If the node cannot be expanded, we have nothing to do and we leave everything as is.
return currentDOMNode;
}
// If the current node is expanded, move and focus into the first child
if (domNodeIsExpanded(currentDOMNode)) {
return getFirstChildOfExpandedTreeNode(currentDOMNode);
}
// Else, we expand the Node (but leave focus where it is)
setNodeExpandedState(currentTreeNode, true);
return currentDOMNode;
} else if (direction === Platform.KeyboardUtilities.ArrowKey.UP) {
// First see if there is a previous sibling
const currentNodePreviousSibling = getPreviousSiblingOfCurrentDOMNode(currentDOMNode);
if (currentNodePreviousSibling) {
// We now find the nested node within our previous sibling; if it has
// children that are expanded, we want to find the last child and
// highlight that, else we'll highlight our sibling directly.
if (domNodeIsExpanded(currentNodePreviousSibling)) {
return getDeepLastChildOfExpandedTreeNode(currentNodePreviousSibling);
}
// Otherwise, if we have a previous sibling with no children, focus it.
return currentNodePreviousSibling;
}
// Otherwise, let's go to the direct parent if there is one.
const parentNode = getParentListItemForDOMNode(currentDOMNode);
if (parentNode && parentNode instanceof HTMLLIElement) {
return parentNode;
}
} else if (direction === Platform.KeyboardUtilities.ArrowKey.LEFT) {
// If the node is expanded, we close it.
if (domNodeIsExpanded(currentDOMNode)) {
setNodeExpandedState(currentTreeNode, false);
return currentDOMNode;
}
// Otherwise, let's go to the parent if there is one.
const parentNode = getParentListItemForDOMNode(currentDOMNode);
if (parentNode && parentNode instanceof HTMLLIElement) {
return parentNode;
}
}
// If we got here, there's no other option than to stay put.
return currentDOMNode;
};