UNPKG

chrome-devtools-frontend

Version:
223 lines (196 loc) • 8.35 kB
// 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; };