chrome-devtools-frontend
Version:
Chrome DevTools UI
566 lines (505 loc) • 20.5 kB
text/typescript
// Copyright (c) 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.
/* eslint-disable rulesdir/no-lit-render-outside-of-view */
import * as Platform from '../../../core/platform/platform.js';
import * as Lit from '../../lit/lit.js';
import * as VisualLogging from '../../visual_logging/visual_logging.js';
import * as CodeHighlighter from '../code_highlighter/code_highlighter.js';
import * as ComponentHelpers from '../helpers/helpers.js';
import * as RenderCoordinator from '../render_coordinator/render_coordinator.js';
import treeOutlineStyles from './treeOutline.css.js';
import {
findNextNodeForTreeOutlineKeyboardNavigation,
getNodeChildren,
getPathToTreeNode,
isExpandableNode,
trackDOMNodeToTreeNode,
type TreeNode,
type TreeNodeId,
type TreeNodeWithChildren,
} from './TreeOutlineUtils.js';
const {html, Directives: {ifDefined}} = Lit;
export interface TreeOutlineData<TreeNodeDataType> {
defaultRenderer: (node: TreeNode<TreeNodeDataType>, state: {isExpanded: boolean}) => Lit.TemplateResult;
/**
* Note: it is important that all the TreeNode objects are unique. They are
* used internally to the TreeOutline as keys to track state (such as if a
* node is expanded or not), and providing the same object multiple times will
* cause issues in the TreeOutline.
*/
tree: ReadonlyArray<TreeNode<TreeNodeDataType>>;
filter?: (node: TreeNodeDataType) => FilterOption;
compact?: boolean;
}
export function defaultRenderer(node: TreeNode<string>): Lit.TemplateResult {
return html`${node.treeNodeData}`;
}
export class ItemSelectedEvent<TreeNodeDataType> extends Event {
static readonly eventName = 'itemselected';
data: {
node: TreeNode<TreeNodeDataType>,
};
constructor(node: TreeNode<TreeNodeDataType>) {
super(ItemSelectedEvent.eventName, {bubbles: true, composed: true});
this.data = {node};
}
}
export class ItemMouseOverEvent<TreeNodeDataType> extends Event {
static readonly eventName = 'itemmouseover';
data: {
node: TreeNode<TreeNodeDataType>,
};
constructor(node: TreeNode<TreeNodeDataType>) {
super(ItemMouseOverEvent.eventName, {bubbles: true, composed: true});
this.data = {node};
}
}
export class ItemMouseOutEvent<TreeNodeDataType> extends Event {
static readonly eventName = 'itemmouseout';
data: {
node: TreeNode<TreeNodeDataType>,
};
constructor(node: TreeNode<TreeNodeDataType>) {
super(ItemMouseOutEvent.eventName, {bubbles: true, composed: true});
this.data = {node};
}
}
/**
*
* The tree can be filtered by providing a custom filter function.
* The filter is applied on every node when constructing the tree
* and proceeds as follows:
* - If the filter return SHOW for a node, the node is included in the tree.
* - If the filter returns FLATTEN, the node is ignored but its subtree is included.
*/
export const enum FilterOption {
SHOW = 'SHOW',
FLATTEN = 'FLATTEN',
}
export class TreeOutline<TreeNodeDataType> extends HTMLElement {
readonly #shadow = this.attachShadow({mode: 'open'});
#treeData: ReadonlyArray<TreeNode<TreeNodeDataType>> = [];
#nodeExpandedMap = new Map<string, boolean>();
#domNodeToTreeNodeMap = new WeakMap<HTMLLIElement, TreeNode<TreeNodeDataType>>();
#hasRenderedAtLeastOnce = false;
/**
* If we have expanded to a certain node, we want to focus it once we've
* rendered. But we render lazily and wrapped in Lit.until, so we can't
* know for sure when that node will be rendered. This variable tracks the
* node that we want focused but may not yet have been rendered.
*/
#nodeIdPendingFocus: TreeNodeId|null = null;
#selectedTreeNode: TreeNode<TreeNodeDataType>|null = null;
#defaultRenderer = (node: TreeNode<TreeNodeDataType>, _state: {isExpanded: boolean}): Lit.TemplateResult => {
if (typeof node.treeNodeData !== 'string') {
console.warn(`The default TreeOutline renderer simply stringifies its given value. You passed in ${
JSON.stringify(
node.treeNodeData, null,
2)}. Consider providing a different defaultRenderer that can handle nodes of this type.`);
}
return html`${String(node.treeNodeData)}`;
};
#nodeFilter?: ((node: TreeNodeDataType) => FilterOption);
#compact = false;
/**
* scheduledRender = render() has been called and scheduled a render.
*/
#scheduledRender = false;
/**
* enqueuedRender = render() was called mid-way through an existing render.
*/
#enqueuedRender = false;
static get observedAttributes(): string[] {
return ['nowrap', 'toplevelbordercolor'];
}
attributeChangedCallback(name: 'nowrap'|'toplevelbordercolor', oldValue: string|null, newValue: string|null): void {
if (oldValue === newValue) {
return;
}
switch (name) {
case 'nowrap': {
this.#setNodeKeyNoWrapCSSVariable(newValue);
break;
}
case 'toplevelbordercolor': {
this.#setTopLevelNodeBorderColorCSSVariable(newValue);
break;
}
}
}
connectedCallback(): void {
this.#setTopLevelNodeBorderColorCSSVariable(this.getAttribute('toplevelbordercolor'));
this.#setNodeKeyNoWrapCSSVariable(this.getAttribute('nowrap'));
}
get data(): TreeOutlineData<TreeNodeDataType> {
return {
tree: this.#treeData as Array<TreeNode<TreeNodeDataType>>,
defaultRenderer: this.#defaultRenderer,
};
}
set data(data: TreeOutlineData<TreeNodeDataType>) {
this.#defaultRenderer = data.defaultRenderer;
this.#treeData = data.tree;
this.#nodeFilter = data.filter;
this.#compact = data.compact || false;
if (!this.#hasRenderedAtLeastOnce) {
this.#selectedTreeNode = this.#treeData[0];
}
void this.#render();
}
/**
* Recursively expands the tree from the root nodes, to a max depth. The max
* depth is 0 indexed - so a maxDepth of 2 (default) will expand 3 levels: 0,
* 1 and 2.
*/
async expandRecursively(maxDepth = 2): Promise<void> {
await Promise.all(this.#treeData.map(rootNode => this.#expandAndRecurse(rootNode, 0, maxDepth)));
await this.#render();
}
/**
* Collapses all nodes in the tree.
*/
async collapseAllNodes(): Promise<void> {
this.#nodeExpandedMap.clear();
await this.#render();
}
/**
* Takes a TreeNode, expands the outline to reveal it, and focuses it.
*/
async expandToAndSelectTreeNode(targetTreeNode: TreeNode<TreeNodeDataType>): Promise<void> {
return await this.expandToAndSelectTreeNodeId(targetTreeNode.id);
}
/**
* Takes a TreeNode ID, expands the outline to reveal it, and focuses it.
*/
async expandToAndSelectTreeNodeId(targetTreeNodeId: TreeNodeId): Promise<void> {
const pathToTreeNode = await getPathToTreeNode(this.#treeData, targetTreeNodeId);
if (pathToTreeNode === null) {
throw new Error(`Could not find node with id ${targetTreeNodeId} in the tree.`);
}
pathToTreeNode.forEach((node, index) => {
// We don't expand the very last node, which was the target node.
if (index < pathToTreeNode.length - 1) {
this.#setNodeExpandedState(node, true);
}
});
// Mark the node as pending focus so when it is rendered into the DOM we can focus it
this.#nodeIdPendingFocus = targetTreeNodeId;
await this.#render();
}
/**
* Takes a list of TreeNode IDs and expands the corresponding nodes.
*/
expandNodeIds(nodeIds: TreeNodeId[]): Promise<void> {
nodeIds.forEach(id => this.#nodeExpandedMap.set(id, true));
return this.#render();
}
/**
* Takes a TreeNode ID and focuses the corresponding node.
*/
focusNodeId(nodeId: TreeNodeId): Promise<void> {
this.#nodeIdPendingFocus = nodeId;
return this.#render();
}
async collapseChildrenOfNode(domNode: HTMLLIElement): Promise<void> {
const treeNode = this.#domNodeToTreeNodeMap.get(domNode);
if (!treeNode) {
return;
}
await this.#recursivelyCollapseTreeNodeChildren(treeNode);
await this.#render();
}
#setNodeKeyNoWrapCSSVariable(attributeValue: string|null): void {
this.style.setProperty('--override-key-whitespace-wrapping', attributeValue !== null ? 'nowrap' : 'initial');
}
#setTopLevelNodeBorderColorCSSVariable(attributeValue: string|null): void {
this.style.setProperty('--override-top-node-border', attributeValue ? `1px solid ${attributeValue}` : '');
}
async #recursivelyCollapseTreeNodeChildren(treeNode: TreeNode<TreeNodeDataType>): Promise<void> {
if (!isExpandableNode(treeNode) || !this.#nodeIsExpanded(treeNode)) {
return;
}
const children = await this.#fetchNodeChildren(treeNode);
const childRecursions = Promise.all(children.map(child => this.#recursivelyCollapseTreeNodeChildren(child)));
await childRecursions;
this.#setNodeExpandedState(treeNode, false);
}
async #flattenSubtree(node: TreeNodeWithChildren<TreeNodeDataType>, filter: (node: TreeNodeDataType) => FilterOption):
Promise<Array<TreeNode<TreeNodeDataType>>> {
const children = await getNodeChildren(node);
const filteredChildren = [];
for (const child of children) {
const filtering = filter(child.treeNodeData);
// We always include the selected node in the tree, regardless of its filtering status.
const toBeSelected = this.#isSelectedNode(child) || child.id === this.#nodeIdPendingFocus;
// If a node is already expanded we should not flatten it away.
const expanded = this.#nodeExpandedMap.get(child.id);
if (filtering === FilterOption.SHOW || toBeSelected || expanded) {
filteredChildren.push(child);
} else if (filtering === FilterOption.FLATTEN && isExpandableNode(child)) {
const grandChildren = await this.#flattenSubtree(child, filter);
filteredChildren.push(...grandChildren);
}
}
return filteredChildren;
}
async #fetchNodeChildren(node: TreeNodeWithChildren<TreeNodeDataType>): Promise<Array<TreeNode<TreeNodeDataType>>> {
const children = await getNodeChildren(node);
const filter = this.#nodeFilter;
if (!filter) {
return children;
}
const filteredDescendants = await this.#flattenSubtree(node, filter);
return filteredDescendants.length ? filteredDescendants : children;
}
#setNodeExpandedState(node: TreeNode<TreeNodeDataType>, newExpandedState: boolean): void {
this.#nodeExpandedMap.set(node.id, newExpandedState);
}
#nodeIsExpanded(node: TreeNode<TreeNodeDataType>): boolean {
return this.#nodeExpandedMap.get(node.id) || false;
}
async #expandAndRecurse(node: TreeNode<TreeNodeDataType>, currentDepth: number, maxDepth: number): Promise<void> {
if (!isExpandableNode(node)) {
return;
}
this.#setNodeExpandedState(node, true);
if (currentDepth === maxDepth || !isExpandableNode(node)) {
return;
}
const children = await this.#fetchNodeChildren(node);
await Promise.all(children.map(child => this.#expandAndRecurse(child, currentDepth + 1, maxDepth)));
}
#onArrowClick(node: TreeNode<TreeNodeDataType>): ((e: Event) => void) {
return (event: Event): void => {
event.stopPropagation();
if (isExpandableNode(node)) {
this.#setNodeExpandedState(node, !this.#nodeIsExpanded(node));
void this.#render();
}
};
}
#onNodeClick(event: Event): void {
// Avoid it bubbling up to parent tree elements, else clicking a node deep in the tree will toggle it + all its ancestor's visibility.
event.stopPropagation();
const nodeClickExpandsOrContracts = this.getAttribute('clickabletitle') !== null;
const domNode = event.currentTarget as HTMLLIElement;
const node = this.#domNodeToTreeNodeMap.get(domNode);
if (nodeClickExpandsOrContracts && node && isExpandableNode(node)) {
this.#setNodeExpandedState(node, !this.#nodeIsExpanded(node));
}
void this.#focusTreeNode(domNode);
}
async #focusTreeNode(domNode: HTMLLIElement): Promise<void> {
const treeNode = this.#domNodeToTreeNodeMap.get(domNode);
if (!treeNode) {
return;
}
this.#selectedTreeNode = treeNode;
await this.#render();
this.dispatchEvent(new ItemSelectedEvent(treeNode));
void RenderCoordinator.write('DOMNode focus', () => {
domNode.focus();
});
}
#processHomeAndEndKeysNavigation(key: 'Home'|'End'): void {
if (key === 'Home') {
const firstRootNode = this.#shadow.querySelector<HTMLLIElement>('ul[role="tree"] > li[role="treeitem"]');
if (firstRootNode) {
void this.#focusTreeNode(firstRootNode);
}
} else if (key === 'End') {
/**
* The End key takes the user to the last visible node in the tree - you
* can think of this as the one that's rendered closest to the bottom of
* the page.
*
* We could walk our tree and compute this - but it will also be the last
* li[role="treeitem"] in the DOM because we only render visible nodes.
* Therefore we can select all the nodes and pick the last one.
*/
const allTreeItems = this.#shadow.querySelectorAll<HTMLLIElement>('li[role="treeitem"]');
const lastTreeItem = allTreeItems[allTreeItems.length - 1];
if (lastTreeItem) {
void this.#focusTreeNode(lastTreeItem);
}
}
}
async #processArrowKeyNavigation(key: Platform.KeyboardUtilities.ArrowKey, currentDOMNode: HTMLLIElement):
Promise<void> {
const currentTreeNode = this.#domNodeToTreeNodeMap.get(currentDOMNode);
if (!currentTreeNode) {
return;
}
const domNode = findNextNodeForTreeOutlineKeyboardNavigation({
currentDOMNode,
currentTreeNode,
direction: key,
setNodeExpandedState: (node, expanded) => this.#setNodeExpandedState(node, expanded),
});
await this.#focusTreeNode(domNode);
}
#processEnterOrSpaceNavigation(currentDOMNode: HTMLLIElement): void {
const currentTreeNode = this.#domNodeToTreeNodeMap.get(currentDOMNode);
if (!currentTreeNode) {
return;
}
if (isExpandableNode(currentTreeNode)) {
const currentExpandedState = this.#nodeIsExpanded(currentTreeNode);
this.#setNodeExpandedState(currentTreeNode, !currentExpandedState);
void this.#render();
}
}
async #onTreeKeyDown(event: KeyboardEvent): Promise<void> {
if (!(event.target instanceof HTMLLIElement)) {
throw new Error('event.target was not an <li> element');
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
this.#processHomeAndEndKeysNavigation(event.key);
} else if (Platform.KeyboardUtilities.keyIsArrowKey(event.key)) {
event.preventDefault();
await this.#processArrowKeyNavigation(event.key, event.target);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.#processEnterOrSpaceNavigation(event.target);
}
}
#focusPendingNode(domNode: HTMLLIElement): void {
this.#nodeIdPendingFocus = null;
void this.#focusTreeNode(domNode);
}
#isSelectedNode(node: TreeNode<TreeNodeDataType>): boolean {
if (this.#selectedTreeNode) {
return node.id === this.#selectedTreeNode.id;
}
return false;
}
#renderNode(node: TreeNode<TreeNodeDataType>, {depth, setSize, positionInSet}: {
depth: number,
setSize: number,
positionInSet: number,
}): Lit.TemplateResult {
let childrenToRender;
const nodeIsExpanded = this.#nodeIsExpanded(node);
if (!isExpandableNode(node) || !nodeIsExpanded) {
childrenToRender = Lit.nothing;
} else {
const childNodes = this.#fetchNodeChildren(node).then(children => {
return children.map((childNode, index) => {
return this.#renderNode(childNode, {depth: depth + 1, setSize: children.length, positionInSet: index});
});
});
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
childrenToRender = html`<ul role="group">${Lit.Directives.until(childNodes)}</ul>`;
// clang-format on
}
const nodeIsFocusable = this.#isSelectedNode(node);
const tabIndex = nodeIsFocusable ? 0 : -1;
const listItemClasses = Lit.Directives.classMap({
expanded: isExpandableNode(node) && nodeIsExpanded,
parent: isExpandableNode(node),
selected: this.#isSelectedNode(node),
'is-top-level': depth === 0,
compact: this.#compact,
});
const ariaExpandedAttribute = !isExpandableNode(node) ? undefined : nodeIsExpanded ? 'true' : 'false';
let renderedNodeKey: Lit.TemplateResult;
if (node.renderer) {
renderedNodeKey = node.renderer(node, {isExpanded: nodeIsExpanded});
} else {
renderedNodeKey = this.#defaultRenderer(node, {isExpanded: nodeIsExpanded});
}
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
return html`
<li role="treeitem"
tabindex=${tabIndex}
aria-setsize=${setSize}
aria-expanded=${ifDefined(ariaExpandedAttribute)}
aria-level=${depth + 1}
aria-posinset=${positionInSet + 1}
class=${listItemClasses}
jslog=${VisualLogging.treeItem(node.jslogContext).track({click: true, keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|Space|Home|End'})}
=${this.#onNodeClick}
track-dom-node-to-tree-node=${trackDOMNodeToTreeNode(this.#domNodeToTreeNodeMap, node)}
on-render=${ComponentHelpers.Directives.nodeRenderedCallback(domNode => {
/**
* Because TreeNodes are lazily rendered, you can call
* `outline.expandToAndSelect(NodeX)`, but `NodeX` will be rendered at some
* later point, once it's been fully resolved, within a Lit.until
* directive. That means we don't have a direct hook into when it's
* rendered, which we need because we want to focus the element, so we use this directive to receive a callback when the node is rendered.
*/
if (!(domNode instanceof HTMLLIElement)) {
return;
}
if (this.#nodeIdPendingFocus && node.id === this.#nodeIdPendingFocus) {
this.#focusPendingNode(domNode);
}
})}
>
<span class="arrow-and-key-wrapper"
=${() => {
this.dispatchEvent(new ItemMouseOverEvent(node));
}}
=${() => {
this.dispatchEvent(new ItemMouseOutEvent(node));
}}
>
<span class="arrow-icon" =${this.#onArrowClick(node)} jslog=${VisualLogging.expand().track({click: true})}>
</span>
<span class="tree-node-key" data-node-key=${node.treeNodeData}>${renderedNodeKey}</span>
</span>
${childrenToRender}
</li>
`;
// clang-format on
}
async #render(): Promise<void> {
if (this.#scheduledRender) {
// If we are already rendering, don't render again immediately, but
// enqueue it to be run after we're done on our current render.
this.#enqueuedRender = true;
return;
}
this.#scheduledRender = true;
await RenderCoordinator.write('TreeOutline render', () => {
// Disabled until https://crbug.com/1079231 is fixed.
// clang-format off
Lit.render(html`
<style>${treeOutlineStyles}</style>
<style>${CodeHighlighter.codeHighlighterStyles}</style>
<div class="wrapping-container">
<ul role="tree" =${this.#onTreeKeyDown}>
${this.#treeData.map((topLevelNode, index) => {
return this.#renderNode(topLevelNode, {
depth: 0,
setSize: this.#treeData.length,
positionInSet: index,
});
})}
</ul>
</div>
`, this.#shadow, {
host: this,
});
});
// clang-format on
this.#hasRenderedAtLeastOnce = true;
this.#scheduledRender = false;
// If render() was called when we were already mid-render, let's re-render
// to ensure we're not rendering any stale UI.
if (this.#enqueuedRender) {
this.#enqueuedRender = false;
return await this.#render();
}
}
}
customElements.define('devtools-tree-outline', TreeOutline);
declare global {
interface HTMLElementTagNameMap {
'devtools-tree-outline': TreeOutline<unknown>;
}
}