chrome-devtools-frontend
Version:
Chrome DevTools UI
223 lines (196 loc) • 8.35 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 '../../platform/platform.js';
import * as LitHtml from '../../third_party/lit-html/lit-html.js';
interface BaseTreeNode {
key: string;
renderer?: (node: TreeNode, state: {isExpanded: boolean}) => LitHtml.TemplateResult;
}
export interface TreeNodeWithChildren extends BaseTreeNode {
children: () => Promise<TreeNode[]>;
}
interface LeafNode extends BaseTreeNode {
children?: never;
}
export type TreeNode = TreeNodeWithChildren|LeafNode;
export function isExpandableNode(node: TreeNode): node is TreeNodeWithChildren {
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.
*/
export const trackDOMNodeToTreeNode =
LitHtml.directive((weakMap: WeakMap<HTMLLIElement, TreeNode>, treeNode: TreeNode) => {
return (part: LitHtml.Part): void => {
if (!(part instanceof LitHtml.AttributePart)) {
throw new Error('Ref directive must be used as an attribute.');
}
const elem = part.committer.element;
if (!(elem instanceof HTMLLIElement)) {
throw new Error('trackTreeNodeToDOMNode must be used on <li> elements.');
}
weakMap.set(elem, treeNode);
};
});
/**
* 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;
};
interface KeyboardNavigationOptions {
currentDOMNode: HTMLLIElement;
currentTreeNode: TreeNode;
direction: Platform.KeyboardUtilities.ArrowKey;
setNodeExpandedState: (treeNode: TreeNode, expanded: boolean) => void;
}
export const findNextNodeForTreeOutlineKeyboardNavigation = (options: KeyboardNavigationOptions): 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;
};