chrome-devtools-frontend
Version:
Chrome DevTools UI
2,145 lines • 71.7 kB
text/typescript
// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* Copyright (C) 2007 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as TextUtils from '../../core/text_utils/text_utils.js';
import type * as Buttons from '../components/buttons/buttons.js';
import * as Highlighting from '../components/highlighting/highlighting.js';
import type {Icon} from '../kit/kit.js';
import * as Lit from '../lit/lit.js';
import * as VisualLogging from '../visual_logging/visual_logging.js';
import * as ARIAUtils from './ARIAUtils.js';
import {appendStyle} from './DOMUtilities.js';
import {type Config, InplaceEditor} from './InplaceEditor.js';
import {Keys} from './KeyboardShortcut.js';
import type {SearchableView} from './SearchableView.js';
import {Tooltip} from './Tooltip.js';
import treeoutlineStyles from './treeoutline.css.js';
import {
createShadowRootWithCoreStyles,
deepElementFromPoint,
enclosingNodeOrSelfWithNodeNameInArray,
HTMLElementWithLightDOMTemplate,
isEditing,
} from './UIUtils.js';
const UIStrings = {
/**
* @description Screen reader announcement made when the user expands a tree item, such as a DOM
* node in the Elements panel. Uses a polite live region so the tree item's name, role, and
* position are announced before this state update.
*/
expanded: 'expanded',
/**
* @description Screen reader announcement made when the user collapses a tree item, such as a DOM
* node in the Elements panel. Uses a polite live region so the tree item's name, role, and
* position are announced before this state update.
*/
collapsed: 'collapsed',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/Treeoutline.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const nodeToParentTreeElementMap = new WeakMap<Node, TreeElement>();
const {render} = Lit;
export enum Events {
/* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
ElementAttached = 'ElementAttached',
ElementsDetached = 'ElementsDetached',
ElementExpanded = 'ElementExpanded',
ElementCollapsed = 'ElementCollapsed',
ElementSelected = 'ElementSelected',
/* eslint-enable @typescript-eslint/naming-convention */
}
export interface EventTypes {
[Events.ElementAttached]: TreeElement;
[Events.ElementsDetached]: void;
[Events.ElementExpanded]: TreeElement;
[Events.ElementCollapsed]: TreeElement;
[Events.ElementSelected]: TreeElement;
}
export class TreeOutline extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
readonly rootElementInternal: TreeElement;
renderSelection: boolean;
selectedTreeElement: TreeElement|null;
expandTreeElementsWhenArrowing: boolean;
comparator: ((arg0: TreeElement, arg1: TreeElement) => number)|null;
contentElement: HTMLOListElement;
preventTabOrder: boolean;
showSelectionOnKeyboardFocus: boolean;
private focusable: boolean;
element: HTMLElement;
private useLightSelectionColor: boolean;
private treeElementToScrollIntoView: TreeElement|null;
private centerUponScrollIntoView: boolean;
constructor() {
super();
this.rootElementInternal = this.createRootElement();
this.renderSelection = false;
this.selectedTreeElement = null;
this.expandTreeElementsWhenArrowing = false;
this.comparator = null;
this.contentElement = this.rootElementInternal.childrenListNode;
this.contentElement.addEventListener('keydown', this.treeKeyDown.bind(this), false);
this.preventTabOrder = false;
this.showSelectionOnKeyboardFocus = false;
this.focusable = true;
this.setFocusable(true);
this.element = this.contentElement;
this.element.setAttribute('jslog', `${VisualLogging.tree()}`);
ARIAUtils.markAsTree(this.element);
this.useLightSelectionColor = false;
this.treeElementToScrollIntoView = null;
this.centerUponScrollIntoView = false;
}
setShowSelectionOnKeyboardFocus(show: boolean, preventTabOrder?: boolean): void {
this.contentElement.classList.toggle('hide-selection-when-blurred', show);
this.preventTabOrder = Boolean(preventTabOrder);
if (this.focusable) {
this.contentElement.tabIndex = Boolean(preventTabOrder) ? -1 : 0;
}
this.showSelectionOnKeyboardFocus = show;
}
private createRootElement(): TreeElement {
const rootElement = new TreeElement();
rootElement.treeOutline = this;
rootElement.root = true;
rootElement.selectable = false;
rootElement.expanded = true;
rootElement.childrenListNode.classList.remove('children');
return rootElement;
}
rootElement(): TreeElement {
return this.rootElementInternal;
}
firstChild(): TreeElement|null {
return this.rootElementInternal.firstChild();
}
private lastDescendent(): TreeElement|null {
let last = this.rootElementInternal.lastChild();
while (last && last.expanded && last.childCount()) {
last = last.lastChild();
}
return last;
}
appendChild(child: TreeElement, comparator?: ((arg0: TreeElement, arg1: TreeElement) => number)): void {
this.rootElementInternal.appendChild(child, comparator);
}
insertChild(child: TreeElement, index: number): void {
this.rootElementInternal.insertChild(child, index);
}
removeChild(child: TreeElement): void {
this.rootElementInternal.removeChild(child);
}
removeChildren(): void {
this.rootElementInternal.removeChildren();
}
treeElementFromPoint(x: number, y: number): TreeElement|null {
const node = deepElementFromPoint(this.contentElement.ownerDocument, x, y);
if (!node) {
return null;
}
const listNode = enclosingNodeOrSelfWithNodeNameInArray(node, ['ol', 'li']);
if (listNode) {
return nodeToParentTreeElementMap.get(listNode) || treeElementBylistItemNode.get(listNode) || null;
}
return null;
}
treeElementFromEvent(event: MouseEvent|null): TreeElement|null {
return event ? this.treeElementFromPoint(event.pageX, event.pageY) : null;
}
setComparator(comparator: ((arg0: TreeElement, arg1: TreeElement) => number)|null): void {
this.comparator = comparator;
}
setFocusable(focusable: boolean): void {
this.focusable = focusable;
this.updateFocusable();
}
updateFocusable(): void {
if (this.focusable) {
this.contentElement.tabIndex = (this.preventTabOrder || Boolean(this.selectedTreeElement)) ? -1 : 0;
if (this.selectedTreeElement) {
this.selectedTreeElement.setFocusable(true);
}
} else {
this.contentElement.removeAttribute('tabIndex');
if (this.selectedTreeElement) {
this.selectedTreeElement.setFocusable(false);
}
}
}
focus(): void {
if (this.selectedTreeElement) {
this.selectedTreeElement.listItemElement.focus();
} else {
this.contentElement.focus();
}
}
setUseLightSelectionColor(flag: boolean): void {
this.useLightSelectionColor = flag;
}
getUseLightSelectionColor(): boolean {
return this.useLightSelectionColor;
}
bindTreeElement(element: TreeElement): void {
if (element.treeOutline) {
console.error('Binding element for the second time: ' + new Error().stack);
}
element.treeOutline = this;
element.onbind();
}
unbindTreeElement(element: TreeElement): void {
if (!element.treeOutline) {
console.error('Unbinding element that was not bound: ' + new Error().stack);
}
element.deselect();
element.onunbind();
element.treeOutline = null;
}
selectPrevious(): boolean {
let nextSelectedElement = this.selectedTreeElement?.traversePreviousTreeElement(true) ?? null;
while (nextSelectedElement && !nextSelectedElement.selectable) {
nextSelectedElement = nextSelectedElement.traversePreviousTreeElement(!this.expandTreeElementsWhenArrowing);
}
if (!nextSelectedElement) {
return false;
}
nextSelectedElement.select(false, true);
return true;
}
selectNext(): boolean {
let nextSelectedElement = this.selectedTreeElement?.traverseNextTreeElement(true) ?? null;
while (nextSelectedElement && !nextSelectedElement.selectable) {
nextSelectedElement = nextSelectedElement.traverseNextTreeElement(!this.expandTreeElementsWhenArrowing);
}
if (!nextSelectedElement) {
return false;
}
nextSelectedElement.select(false, true);
return true;
}
forceSelect(omitFocus: boolean|undefined = false, selectedByUser: boolean|undefined = true): void {
if (this.selectedTreeElement) {
this.selectedTreeElement.deselect();
}
this.selectFirst(omitFocus, selectedByUser);
}
private selectFirst(omitFocus: boolean|undefined = false, selectedByUser: boolean|undefined = true): boolean {
let first = this.firstChild();
while (first && !first.selectable) {
first = first.traverseNextTreeElement(true);
}
if (!first) {
return false;
}
first.select(omitFocus, selectedByUser);
return true;
}
private selectLast(): boolean {
let last = this.lastDescendent();
while (last && !last.selectable) {
last = last.traversePreviousTreeElement(true);
}
if (!last) {
return false;
}
last.select(false, true);
return true;
}
private treeKeyDown(event: KeyboardEvent): void {
if (event.shiftKey || event.metaKey || event.ctrlKey || isEditing()) {
return;
}
let handled = false;
if (!this.selectedTreeElement) {
if (event.key === 'ArrowUp' && !event.altKey) {
handled = this.selectLast();
} else if (event.key === 'ArrowDown' && !event.altKey) {
handled = this.selectFirst();
}
} else if (event.key === 'ArrowUp' && !event.altKey) {
handled = this.selectPrevious();
} else if (event.key === 'ArrowDown' && !event.altKey) {
handled = this.selectNext();
} else if (event.key === 'ArrowLeft') {
handled = this.selectedTreeElement.collapseOrAscend(event.altKey);
} else if (event.key === 'ArrowRight') {
if (!this.selectedTreeElement.revealed()) {
this.selectedTreeElement.reveal();
handled = true;
} else {
handled = this.selectedTreeElement.descendOrExpand(event.altKey);
}
} else if (event.keyCode === 8 /* Backspace */ || event.keyCode === 46 /* Delete */) {
handled = this.selectedTreeElement.ondelete();
} else if (event.key === 'Enter') {
handled = this.selectedTreeElement.onenter();
} else if (event.keyCode === Keys.Space.code) {
handled = this.selectedTreeElement.onspace();
} else if (event.key === 'Home') {
handled = this.selectFirst();
} else if (event.key === 'End') {
handled = this.selectLast();
}
if (handled) {
event.consume(true);
}
}
deferredScrollIntoView(treeElement: TreeElement, center: boolean): void {
const deferredScrollIntoView = (): void => {
if (!this.treeElementToScrollIntoView) {
return;
}
// This function doesn't use scrollIntoViewIfNeeded because it always
// scrolls in both directions even if only one is necessary to bring the
// item into view.
const itemRect = this.treeElementToScrollIntoView.listItemElement.getBoundingClientRect();
const treeRect = this.contentElement.getBoundingClientRect();
// Usually, this.element is the tree container that scrolls. But sometimes
// (i.e. in the Elements panel), its parent is.
let scrollParentElement: Element = this.element;
while (getComputedStyle(scrollParentElement).overflow === 'visible' &&
scrollParentElement.parentElementOrShadowHost()) {
const parent = scrollParentElement.parentElementOrShadowHost();
Platform.assertNotNullOrUndefined(parent);
scrollParentElement = parent;
}
const viewRect = scrollParentElement.getBoundingClientRect();
const currentScrollX = viewRect.left - treeRect.left;
const currentScrollY = viewRect.top - treeRect.top + this.contentElement.offsetTop;
// Only scroll into view on each axis if the item is not visible at all
// but if we do scroll and centerUponScrollIntoView is true
// then we center the top left corner of the item in view.
let deltaLeft: number = itemRect.left - treeRect.left;
if (deltaLeft > currentScrollX && deltaLeft < currentScrollX + viewRect.width) {
deltaLeft = currentScrollX;
} else if (this.centerUponScrollIntoView) {
deltaLeft = deltaLeft - viewRect.width / 2;
}
let deltaTop: number = itemRect.top - treeRect.top;
if (deltaTop > currentScrollY && deltaTop < currentScrollY + viewRect.height) {
deltaTop = currentScrollY;
} else if (this.centerUponScrollIntoView) {
deltaTop = deltaTop - viewRect.height / 2;
}
scrollParentElement.scrollTo(deltaLeft, deltaTop);
this.treeElementToScrollIntoView = null;
};
if (!this.treeElementToScrollIntoView) {
this.element.window().requestAnimationFrame(deferredScrollIntoView);
}
this.treeElementToScrollIntoView = treeElement;
this.centerUponScrollIntoView = center;
}
onStartedEditingTitle(_treeElement: TreeElement): void {
}
}
export const enum TreeVariant {
NAVIGATION_TREE = 'NavigationTree',
OTHER = 'Other',
}
export class TreeOutlineInShadow extends TreeOutline {
override element: HTMLElement;
shadowRoot: ShadowRoot;
private readonly disclosureElement: Element;
override renderSelection: boolean;
constructor(variant: TreeVariant = TreeVariant.OTHER, element?: HTMLElement, delegatesFocus?: boolean) {
super();
this.contentElement.classList.add('tree-outline');
this.element = element ?? document.createElement('div');
this.shadowRoot = createShadowRootWithCoreStyles(this.element, {cssFile: treeoutlineStyles, delegatesFocus});
this.disclosureElement = this.shadowRoot.createChild('div', 'tree-outline-disclosure');
this.disclosureElement.appendChild(this.contentElement);
this.renderSelection = true;
if (variant === TreeVariant.NAVIGATION_TREE) {
this.contentElement.classList.add('tree-variant-navigation');
}
}
setVariant(variant: TreeVariant): void {
this.contentElement.classList.toggle('tree-variant-navigation', variant === TreeVariant.NAVIGATION_TREE);
}
registerRequiredCSS(...cssFiles: Array<string&{_tag: 'CSS-in-JS'}>): void {
for (const cssFile of cssFiles) {
appendStyle(this.shadowRoot, cssFile);
}
}
setHideOverflow(hideOverflow: boolean): void {
this.disclosureElement.classList.toggle('tree-outline-disclosure-hide-overflow', hideOverflow);
}
setDense(dense: boolean): void {
this.contentElement.classList.toggle('tree-outline-dense', dense);
}
override onStartedEditingTitle(treeElement: TreeElement): void {
const selection = this.shadowRoot.getSelection();
if (selection) {
selection.selectAllChildren(treeElement.titleElement);
}
}
}
export const treeElementBylistItemNode = new WeakMap<Node, TreeElement>();
export class TreeElement {
treeOutline: TreeOutline|null;
parent: TreeElement|null;
previousSibling: TreeElement|null;
nextSibling: TreeElement|null;
private readonly boundOnFocus: () => void;
private readonly boundOnBlur: () => void;
readonly listItemNode: HTMLLIElement;
titleElement: Node;
titleInternal: string|Node;
private childrenInternal: TreeElement[]|null;
childrenListNode: HTMLOListElement;
private expandLoggable = {};
private hiddenInternal: boolean;
private selectableInternal: boolean;
expanded: boolean;
selected: boolean;
private expandable!: boolean;
#expandRecursively = true;
private collapsible: boolean;
toggleOnClick: boolean;
button: Buttons.Button.Button|null;
root: boolean;
private tooltipInternal: string;
private leadingIconsElement: HTMLElement|null;
private trailingIconsElement: HTMLElement|null;
protected selectionElementInternal: HTMLElement|null;
private disableSelectFocus: boolean;
constructor(title?: string|Node, expandable?: boolean, jslogContext?: string|number) {
this.treeOutline = null;
this.parent = null;
this.previousSibling = null;
this.nextSibling = null;
this.boundOnFocus = this.onFocus.bind(this);
this.boundOnBlur = this.onBlur.bind(this);
this.listItemNode = document.createElement('li');
this.titleElement = this.listItemNode.createChild('span', 'tree-element-title');
treeElementBylistItemNode.set(this.listItemNode, this);
this.titleInternal = '';
if (title) {
this.title = title;
}
this.listItemNode.addEventListener('mousedown', (this.handleMouseDown.bind(this) as EventListener), false);
this.listItemNode.addEventListener('click', (this.treeElementToggled.bind(this) as EventListener), false);
this.listItemNode.addEventListener('dblclick', this.handleDoubleClick.bind(this), false);
this.listItemNode.setAttribute(
'jslog', `${VisualLogging.treeItem().parent('parentTreeItem').context(jslogContext).track({
click: true,
resize: true,
keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Backspace|Delete|Enter|Space|Home|End',
})}`);
ARIAUtils.markAsTreeitem(this.listItemNode);
this.childrenInternal = null;
this.childrenListNode = document.createElement('ol');
nodeToParentTreeElementMap.set(this.childrenListNode, this);
this.childrenListNode.classList.add('children');
ARIAUtils.markAsGroup(this.childrenListNode);
this.hiddenInternal = false;
this.selectableInternal = true;
this.expanded = false;
this.selected = false;
this.setExpandable(expandable || false);
this.collapsible = true;
this.toggleOnClick = false;
this.button = null;
this.root = false;
this.tooltipInternal = '';
this.leadingIconsElement = null;
this.trailingIconsElement = null;
this.selectionElementInternal = null;
this.disableSelectFocus = false;
}
static getTreeElementBylistItemNode(node: Node): TreeElement|undefined {
return treeElementBylistItemNode.get(node);
}
hasAncestor(ancestor: TreeElement|null): boolean {
if (!ancestor) {
return false;
}
let currentNode: (TreeElement|null) = this.parent;
while (currentNode) {
if (ancestor === currentNode) {
return true;
}
currentNode = currentNode.parent;
}
return false;
}
hasAncestorOrSelf(ancestor: TreeElement|null): boolean {
return this === ancestor || this.hasAncestor(ancestor);
}
isHidden(): boolean {
if (this.hidden) {
return true;
}
let currentNode: (TreeElement|null) = this.parent;
while (currentNode) {
if (currentNode.hidden) {
return true;
}
currentNode = currentNode.parent;
}
return false;
}
children(): TreeElement[] {
return this.childrenInternal || [];
}
childCount(): number {
return this.childrenInternal ? this.childrenInternal.length : 0;
}
firstChild(): TreeElement|null {
return this.childrenInternal ? this.childrenInternal[0] : null;
}
lastChild(): TreeElement|null {
return this.childrenInternal ? this.childrenInternal[this.childrenInternal.length - 1] : null;
}
childAt(index: number): TreeElement|null {
return this.childrenInternal ? this.childrenInternal[index] : null;
}
indexOfChild(child: TreeElement): number {
return this.childrenInternal ? this.childrenInternal.indexOf(child) : -1;
}
appendChild(child: TreeElement, comparator?: ((arg0: TreeElement, arg1: TreeElement) => number)): void {
if (!this.childrenInternal) {
this.childrenInternal = [];
}
let insertionIndex;
if (comparator) {
insertionIndex = Platform.ArrayUtilities.lowerBound(this.childrenInternal, child, comparator);
} else if (this.treeOutline?.comparator) {
insertionIndex = Platform.ArrayUtilities.lowerBound(this.childrenInternal, child, this.treeOutline.comparator);
} else {
insertionIndex = this.childrenInternal.length;
}
this.insertChild(child, insertionIndex);
}
insertChild(child: TreeElement, index: number): void {
if (!this.childrenInternal) {
this.childrenInternal = [];
}
if (!child) {
throw new Error('child can\'t be undefined or null');
}
console.assert(
!child.parent, 'Attempting to insert a child that is already in the tree, reparenting is not supported.');
const previousChild = (index > 0 ? this.childrenInternal[index - 1] : null);
if (previousChild) {
previousChild.nextSibling = child;
child.previousSibling = previousChild;
} else {
child.previousSibling = null;
}
const nextChild = this.childrenInternal[index];
if (nextChild) {
nextChild.previousSibling = child;
child.nextSibling = nextChild;
} else {
child.nextSibling = null;
}
this.childrenInternal.splice(index, 0, child);
this.setExpandable(true);
child.parent = this;
if (this.treeOutline) {
this.treeOutline.bindTreeElement(child);
}
for (let current = child.firstChild(); this.treeOutline && current;
current = current.traverseNextTreeElement(false, child, true)) {
this.treeOutline.bindTreeElement(current);
}
child.onattach();
child.ensureSelection();
if (this.treeOutline) {
this.treeOutline.dispatchEventToListeners(Events.ElementAttached, child);
}
const nextSibling = child.nextSibling ? child.nextSibling.listItemNode : null;
this.childrenListNode.insertBefore(child.listItemNode, nextSibling);
this.childrenListNode.insertBefore(child.childrenListNode, nextSibling);
if (child.selected) {
child.select();
}
if (child.expanded) {
child.expand();
}
}
removeChildAtIndex(childIndex: number): void {
if (!this.childrenInternal || childIndex < 0 || childIndex >= this.childrenInternal.length) {
throw new Error('childIndex out of range');
}
const child = this.childrenInternal[childIndex];
this.childrenInternal.splice(childIndex, 1);
const parent = child.parent;
if (this.treeOutline?.selectedTreeElement?.hasAncestorOrSelf(child)) {
if (child.nextSibling) {
child.nextSibling.select(true);
} else if (child.previousSibling) {
child.previousSibling.select(true);
} else if (parent) {
parent.select(true);
}
}
if (child.previousSibling) {
child.previousSibling.nextSibling = child.nextSibling;
}
if (child.nextSibling) {
child.nextSibling.previousSibling = child.previousSibling;
}
child.parent = null;
if (this.treeOutline) {
this.treeOutline.unbindTreeElement(child);
}
for (let current = child.firstChild(); this.treeOutline && current;
current = current.traverseNextTreeElement(false, child, true)) {
this.treeOutline.unbindTreeElement(current);
}
child.detach();
if (this.treeOutline) {
this.treeOutline.dispatchEventToListeners(Events.ElementsDetached);
}
}
removeChild(child: TreeElement): void {
if (!child) {
throw new Error('child can\'t be undefined or null');
}
if (child.parent !== this) {
return;
}
const childIndex = this.childrenInternal ? this.childrenInternal.indexOf(child) : -1;
if (childIndex === -1) {
throw new Error('child not found in this node\'s children');
}
this.removeChildAtIndex(childIndex);
}
removeChildren(): void {
if (!this.root && this.treeOutline?.selectedTreeElement?.hasAncestorOrSelf(this)) {
this.select(true);
}
if (this.childrenInternal) {
for (const child of this.childrenInternal) {
child.previousSibling = null;
child.nextSibling = null;
child.parent = null;
if (this.treeOutline) {
this.treeOutline.unbindTreeElement(child);
}
for (let current = child.firstChild(); this.treeOutline && current;
current = current.traverseNextTreeElement(false, child, true)) {
this.treeOutline.unbindTreeElement(current);
}
child.detach();
}
}
this.childrenInternal = [];
if (this.treeOutline) {
this.treeOutline.dispatchEventToListeners(Events.ElementsDetached);
}
}
get selectable(): boolean {
if (this.isHidden()) {
return false;
}
return this.selectableInternal;
}
set selectable(x: boolean) {
this.selectableInternal = x;
}
get listItemElement(): HTMLLIElement {
return this.listItemNode;
}
get childrenListElement(): HTMLOListElement {
return this.childrenListNode;
}
get title(): string|Node {
return this.titleInternal;
}
set title(x: string|Node) {
if (this.titleInternal === x) {
return;
}
this.titleInternal = x;
if (typeof x === 'string') {
this.titleElement.textContent = x;
this.tooltip = x;
} else {
this.titleElement = x;
this.tooltip = '';
}
this.listItemNode.removeChildren();
if (this.leadingIconsElement) {
this.listItemNode.appendChild(this.leadingIconsElement);
}
this.listItemNode.appendChild(this.titleElement);
if (this.trailingIconsElement) {
this.listItemNode.appendChild(this.trailingIconsElement);
}
this.ensureSelection();
}
titleAsText(): string {
if (!this.titleInternal) {
return '';
}
if (typeof this.titleInternal === 'string') {
return this.titleInternal;
}
return this.titleInternal.textContent || '';
}
startEditingTitle<T>(editingConfig: Config<T>): void {
InplaceEditor.startEditing((this.titleElement as Element), editingConfig);
if (this.treeOutline) {
this.treeOutline.onStartedEditingTitle(this);
}
}
setLeadingIcons(icons: Icon[]|Lit.TemplateResult[]): void {
if (!this.leadingIconsElement && !icons.length) {
return;
}
if (!this.leadingIconsElement) {
this.leadingIconsElement = document.createElement('div');
this.leadingIconsElement.classList.add('leading-icons');
this.leadingIconsElement.classList.add('icons-container');
this.listItemNode.insertBefore(this.leadingIconsElement, this.titleElement);
this.ensureSelection();
}
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(icons, this.leadingIconsElement);
}
setTrailingIcons(icons: Icon[]|Lit.TemplateResult[]): void {
if (!this.trailingIconsElement && !icons.length) {
return;
}
if (!this.trailingIconsElement) {
this.trailingIconsElement = document.createElement('div');
this.trailingIconsElement.classList.add('trailing-icons');
this.trailingIconsElement.classList.add('icons-container');
this.listItemNode.appendChild(this.trailingIconsElement);
this.ensureSelection();
}
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(icons, this.trailingIconsElement);
}
get tooltip(): string {
return this.tooltipInternal;
}
set tooltip(x: string) {
if (this.tooltipInternal === x) {
return;
}
this.tooltipInternal = x;
Tooltip.install(this.listItemNode, x);
}
isExpandable(): boolean {
return this.expandable;
}
setExpandable(expandable: boolean): void {
if (this.expandable === expandable) {
return;
}
this.expandable = expandable;
this.listItemNode.classList.toggle('parent', expandable);
if (!expandable) {
this.collapse();
ARIAUtils.unsetExpandable(this.listItemNode);
} else {
VisualLogging.registerLoggable(
this.expandLoggable, `${VisualLogging.expand()}`, this.listItemNode, new DOMRect(0, 0, 16, 16));
ARIAUtils.setExpanded(this.listItemNode, false);
}
}
isExpandRecursively(): boolean {
return this.#expandRecursively;
}
setExpandRecursively(expandRecursively: boolean): void {
this.#expandRecursively = expandRecursively;
}
isCollapsible(): boolean {
return this.collapsible;
}
setCollapsible(collapsible: boolean): void {
if (this.collapsible === collapsible) {
return;
}
this.collapsible = collapsible;
this.listItemNode.classList.toggle('always-parent', !collapsible);
if (!collapsible) {
this.expand();
}
}
get hidden(): boolean {
return this.hiddenInternal;
}
set hidden(x: boolean) {
if (this.hiddenInternal === x) {
return;
}
this.hiddenInternal = x;
this.listItemNode.classList.toggle('hidden', x);
this.childrenListNode.classList.toggle('hidden', x);
if (x && this.treeOutline?.selectedTreeElement?.hasAncestorOrSelf(this)) {
const hadFocus = this.treeOutline.selectedTreeElement.listItemElement.hasFocus();
this.treeOutline.forceSelect(!hadFocus, /* selectedByUser */ false);
}
}
invalidateChildren(): void {
if (this.childrenInternal) {
this.removeChildren();
this.childrenInternal = null;
}
}
private ensureSelection(): void {
if (!this.treeOutline?.renderSelection) {
return;
}
if (!this.selectionElementInternal) {
this.selectionElementInternal = document.createElement('div');
this.selectionElementInternal.classList.add('selection');
this.selectionElementInternal.classList.add('fill');
}
this.listItemNode.insertBefore(this.selectionElementInternal, this.listItemElement.firstChild);
}
private treeElementToggled(event: MouseEvent): void {
const element = (event.currentTarget as Node | null);
if (!element || treeElementBylistItemNode.get(element) !== this || element.hasSelection()) {
return;
}
console.assert(Boolean(this.treeOutline));
const showSelectionOnKeyboardFocus = this.treeOutline ? this.treeOutline.showSelectionOnKeyboardFocus : false;
const toggleOnClick = this.toggleOnClick && (showSelectionOnKeyboardFocus || !this.selectable);
const isInTriangle = this.isEventWithinDisclosureTriangle(event);
if (!toggleOnClick && !isInTriangle) {
return;
}
void this.#setExpandedFromUser(!this.expanded, event.altKey);
void VisualLogging.logClick(this.expandLoggable, event);
event.consume();
}
private handleMouseDown(event: MouseEvent): void {
const element = (event.currentTarget as Node | null);
if (!element) {
return;
}
if (!this.selectable) {
return;
}
if (treeElementBylistItemNode.get(element) !== this) {
return;
}
if (this.isEventWithinDisclosureTriangle(event)) {
return;
}
this.selectOnMouseDown(event);
}
private handleDoubleClick(event: Event): void {
const element = (event.currentTarget as Node | null);
if (!element || treeElementBylistItemNode.get(element) !== this) {
return;
}
const handled = this.ondblclick(event);
if (handled) {
return;
}
if (this.expandable && !this.expanded) {
void this.#setExpandedFromUser(true, false);
}
}
async #setExpandedFromUser(shouldExpand: boolean, recursively: boolean): Promise<void> {
const wasExpanded = this.expanded;
if (shouldExpand) {
if (recursively) {
await this.expandRecursively();
} else {
this.expand();
}
} else if (recursively) {
this.collapseRecursively();
} else {
this.collapse();
}
// When a tree item stays focused, VoiceOver on macOS does not announce the updated
// `aria-expanded` state, leaving the user without feedback. Other screen readers announce it
// natively, so restrict this live-region announcement to macOS to avoid announcing the state
// twice elsewhere.
if (this.expanded !== wasExpanded && Host.Platform.isMac()) {
ARIAUtils.LiveAnnouncer.status(this.expanded ? i18nString(UIStrings.expanded) : i18nString(UIStrings.collapsed));
}
}
private detach(): void {
this.listItemNode.remove();
this.childrenListNode.remove();
}
collapse(): void {
if (!this.expanded || !this.collapsible) {
return;
}
this.listItemNode.classList.remove('expanded');
this.childrenListNode.classList.remove('expanded');
ARIAUtils.setExpanded(this.listItemNode, false);
this.expanded = false;
this.oncollapse();
if (this.treeOutline) {
this.treeOutline.dispatchEventToListeners(Events.ElementCollapsed, this);
}
const selectedTreeElement = this.treeOutline?.selectedTreeElement;
if (selectedTreeElement?.hasAncestor(this)) {
this.select(/* omitFocus */ true, /* selectedByUser */ true);
}
}
collapseRecursively(): void {
let item: (TreeElement|null)|this = this;
while (item) {
if (item.expanded) {
item.collapse();
}
item = item.traverseNextTreeElement(false, this, true);
}
}
collapseChildren(): void {
if (!this.childrenInternal) {
return;
}
for (const child of this.childrenInternal) {
child.collapseRecursively();
}
}
expand(): void {
if (!this.expandable || (this.expanded && this.childrenInternal)) {
return;
}
// Set this before onpopulate. Since onpopulate can add elements, this makes
// sure the expanded flag is true before calling those functions. This prevents the possibility
// of an infinite loop if onpopulate were to call expand.
this.expanded = true;
void this.populateIfNeeded();
this.listItemNode.classList.add('expanded');
this.childrenListNode.classList.add('expanded');
ARIAUtils.setExpanded(this.listItemNode, true);
if (this.treeOutline) {
this.onexpand();
this.treeOutline.dispatchEventToListeners(Events.ElementExpanded, this);
}
}
async expandRecursively(maxDepth?: number): Promise<void> {
let item: (TreeElement|null)|this = this;
const info = {depthChange: 0};
let depth = 0;
// The Inspector uses TreeOutlines to represents object properties, so recursive expansion
// in some case can be infinite, since JavaScript objects can hold circular references.
// So default to a recursion cap of 3 levels, since that gives fairly good results.
if (maxDepth === undefined || isNaN(maxDepth)) {
maxDepth = 3;
}
do {
if (item.isExpandRecursively()) {
await item.populateIfNeeded();
if (depth < maxDepth) {
item.expand();
}
}
item = item.traverseNextTreeElement(!item.isExpandRecursively(), this, true, info);
depth += info.depthChange;
} while (item !== null);
}
collapseOrAscend(altKey: boolean): boolean {
if (this.expanded && this.collapsible) {
void this.#setExpandedFromUser(false, altKey);
return true;
}
if (!this.parent || this.parent.root) {
return false;
}
if (!this.parent.selectable) {
this.parent.collapse();
return true;
}
let nextSelectedElement: (TreeElement|null)|TreeElement = this.parent;
while (nextSelectedElement && !nextSelectedElement.selectable) {
nextSelectedElement = nextSelectedElement.parent;
}
if (!nextSelectedElement) {
return false;
}
nextSelectedElement.select(false, true);
return true;
}
descendOrExpand(altKey: boolean): boolean {
if (!this.expandable) {
return false;
}
if (!this.expanded) {
void this.#setExpandedFromUser(true, altKey);
return true;
}
let nextSelectedElement = this.firstChild();
while (nextSelectedElement && !nextSelectedElement.selectable) {
nextSelectedElement = nextSelectedElement.nextSibling;
}
if (!nextSelectedElement) {
return false;
}
nextSelectedElement.select(false, true);
return true;
}
reveal(center?: boolean): void {
let currentAncestor: (TreeElement|null) = this.parent;
while (currentAncestor && !currentAncestor.root) {
if (!currentAncestor.expanded) {
currentAncestor.expand();
}
currentAncestor = currentAncestor.parent;
}
if (this.treeOutline) {
this.treeOutline.deferredScrollIntoView(this, Boolean(center));
}
}
revealed(): boolean {
let currentAncestor: (TreeElement|null) = this.parent;
while (currentAncestor && !currentAncestor.root) {
if (!currentAncestor.expanded) {
return false;
}
currentAncestor = currentAncestor.parent;
}
return true;
}
selectOnMouseDown(event: MouseEvent): void {
if (this.select(false, true)) {
event.consume(true);
}
if (this.listItemNode.draggable && this.selectionElementInternal && this.treeOutline) {
const marginLeft = this.treeOutline.element.getBoundingClientRect().left -
this.listItemNode.getBoundingClientRect().left - this.treeOutline.element.scrollLeft;
// By default the left margin extends far off screen. This is not a problem except when dragging an element.
// Setting the margin once here should be fine, because we believe the left margin should never change.
this.selectionElementInternal.style.setProperty('margin-left', marginLeft + 'px');
}
}
select(omitFocus?: boolean, selectedByUser?: boolean): boolean {
omitFocus = omitFocus || this.disableSelectFocus;
if (!this.treeOutline || !this.selectable || this.selected) {
if (!omitFocus) {
this.listItemElement.focus();
}
return false;
}
// Wait to deselect this element so that focus only changes once
const lastSelected = this.treeOutline.selectedTreeElement;
this.treeOutline.selectedTreeElement = null;
if (this.treeOutline.rootElementInternal === this) {
if (lastSelected) {
lastSelected.deselect();
}
if (!omitFocus) {
this.listItemElement.focus();
}
return false;
}
this.selected = true;
this.treeOutline.selectedTreeElement = this;
this.treeOutline.updateFocusable();
if (!omitFocus || this.treeOutline.contentElement.hasFocus()) {
this.listItemElement.focus();
}
this.listItemNode.classList.add('selected');
ARIAUtils.setSelected(this.listItemNode, true);
this.treeOutline.dispatchEventToListeners(Events.ElementSelected, this);
if (lastSelected) {
lastSelected.deselect();
}
return this.onselect(selectedByUser);
}
setFocusable(focusable: boolean): void {
if (focusable) {
this.listItemNode.setAttribute('tabIndex', (this.treeOutline?.preventTabOrder) ? '-1' : '0');
this.listItemNode.addEventListener('focus', this.boundOnFocus, false);
this.listItemNode.addEventListener('blur', this.boundOnBlur, false);
} else {
this.listItemNode.removeAttribute('tabIndex');
this.listItemNode.removeEventListener('focus', this.boundOnFocus, false);
this.listItemNode.removeEventListener('blur', this.boundOnBlur, false);
}
}
private onFocus(): void {
if (!this.treeOutline || this.treeOutline.getUseLightSelectionColor()) {
return;
}
if (!this.treeOutline.contentElement.classList.contains('hide-selection-when-blurred')) {
this.listItemNode.classList.add('force-white-icons');
}
}
private onBlur(): void {
if (!this.treeOutline || this.treeOutline.getUseLightSelectionColor()) {
return;
}
if (!this.treeOutline.contentElement.classList.contains('hide-selection-when-blurred')) {
this.listItemNode.classList.remove('force-white-icons');
}
}
revealAndSelect(omitFocus?: boolean): void {
this.reveal(true);
this.select(omitFocus);
}
deselect(): void {
const hadFocus = this.listItemNode.hasFocus();
this.selected = false;
this.listItemNode.classList.remove('selected');
ARIAUtils.clearSelected(this.listItemNode);
this.setFocusable(false);
if (this.treeOutline?.selectedTreeElement === this) {
this.treeOutline.selectedTreeElement = null;
this.treeOutline.updateFocusable();
if (hadFocus) {
this.treeOutline.focus();
}
}
}
private async populateIfNeeded(): Promise<void> {
if (this.treeOutline && this.expandable && !this.childrenInternal) {
this.childrenInternal = [];
await this.onpopulate();
}
}
async onpopulate(): Promise<void> {
// Overridden by subclasses.
}
onenter(): boolean {
// Expanding requires an expandable node; collapsing requires a collapsible one.
const canToggle = this.expanded ? this.collapsible : this.expandable;
if (!canToggle) {
return false;
}
void this.#setExpandedFromUser(!this.expanded, false);
return true;
}
ondelete(): boolean {
return false;
}
onspace(): boolean {
return false;
}
onbind(): void {
}
onunbind(): void {
}
onattach(): void {
}
onexpand(): void {
}
oncollapse(): void {
}
ondblclick(_e: Event): boolean {
return false;
}
onselect(_selectedByUser?: boolean): boolean {
return false;
}
traverseNextTreeElement(skipUnrevealed: boolean, stayWithin?: TreeElement|null, dontPopulate?: boolean, info?: {
depthChange: number,
}): TreeElement|null {
if (!dontPopulate) {
void this.populateIfNeeded();
}
if (info) {
info.depthChange = 0;
}
let element: (TreeElement|null)|this =
skipUnrevealed ? (this.revealed() ? this.firstChild() : null) : this.firstChild();
if (element && (!skipUnrevealed || (skipUnrevealed && this.expanded))) {
if (info) {
info.depthChange = 1;
}
return element;
}
if (this === stayWithin) {
return null;
}
element = skipUnrevealed ? (this.revealed() ? this.nextSibling : null) : this.nextSibling;
if (element) {
return element;
}
element = this;
while (element && !element.root &&
!(skipUnrevealed ? (element.revealed() ? element.nextSibling : null) : element.nextSibling) &&
element.parent !== stayWithin) {
if (info) {
info.depthChange -= 1;
}
element = element.parent;
}
if (!element || element.root) {
return null;
}
return (skipUnrevealed ? (element.revealed() ? element.nextSibling : null) : element.nextSibling);
}
traversePreviousTreeElement(skipUnrevealed: boolean, dontPopulate?: boolean): TreeElement|null {
let element: (TreeElement|null) =
skipUnrevealed ? (this.revealed() ? this.previousSibling : null) : this.previousSibling;
if (!dontPopulate && element) {
void element.populateIfNeeded();
}
while (element &&
(skipUnrevealed ? (element.revealed() && element.expanded ? element.lastChild() : null) :
element.lastChild())) {
if (!dontPopulate) {
void element.populateIfNeeded();
}
element =
(skipUnrevealed ? (element.revealed() && element.expanded ? element.lastChild() : null) :
element.lastChild());
}
if (element) {
return element;
}
if (!this.parent || this.parent.root) {
return null;
}
return this.parent;
}
isEventWithinDisclosureTriangle(event: MouseEvent): boolean {
const arrowToggleWidth = 10;
// FIXME: We should not use getComputedStyle(). For that we need to get rid of using ::before for disclosure triangle. (http://webk.it/74446)
const paddingLeftValue = window.getComputedStyle(this.listItemNode).paddingLeft;
console.assert(paddingLeftValue.endsWith('px'));
const computedLeftPadding = parseFloat(paddingLeftValue);
const left = this.listItemNode.getBoundingClientRect().left + computedLeftPadding;
return event.pageX >= left && event.pageX <= left + arrowToggleWidth && this.expandable;
}
setDisableSelectFocus(toggle: boolean): void {
this.disableSelectFocus = toggle;
}
}
function hasBooleanAttribute(element: Element, name: string): boolean {
return element.hasAttribute(name) && element.getAttribute(name) !== 'false';
}
export interface TreeNode<NodeT> {
treeNodeChildren(): Iterable<NodeT>;
}
export interface TreeSearchResult<NodeT> {
node: NodeT;
isPostOrderMatch: boolean;
matchIndexInNode: number;
}
export class TreeSearch < NodeT extends TreeNode<NodeT>,
SearchResultT extends TreeSearchResult<NodeT >= TreeSearchResult<NodeT>> extends
Common.ObjectWrapper.ObjectWrapper<TreeSearch.EventTypes> {
#matches: SearchResultT[] = [];
#currentMatchIndex = 0;
#nodeMatchMap: WeakMap<NodeT, SearchResultT[]>|undefined;
#reset(): void {
this.#matches = [];
this.#nodeMatchMap = undefined;
this.#currentMatchIndex = 0;
}
reset(): void {
this.#reset();
this.dispatchEventToListeners(TreeSearch.Events.SEARCH_CHANGED);
}
currentMatch(): SearchResultT|undefined {
return this.#matches.at(this.#currentMatchIndex);
}
#getNodeMatchMap(): WeakMap<NodeT, SearchResultT[]> {
if (!this.#nodeMatchMap) {
this.#nodeMatchMap = new WeakMap();
for (const match of this.#matches) {
let entry = this.#nodeMatchMap.get(match.node);
if (!entry) {
entry = [];
this.#nodeMatchMap.set(match.node, entry);
}
entry.push(match);
}
}
return this.#nodeMatchMap;
}
getResults(node: NodeT): SearchResultT[] {
return this.#getNodeMatchMap().get(node) ?? [];
}
static highlight(ranges: TextUtils.TextRange.SourceRange[], selectedRange: TextUtils.TextRange.SourceRange|undefined):
ReturnType<typeof Lit.Directives.ref> {
return Lit.Directives.ref(element => {
if (!(element instanceof HTMLElement)) {
return;
}
const configListItem = element.closest<HTMLLIElement>('li[role="treeitem"]');
const titleElement = configListItem ? TreeViewTreeElement.get(configListItem)?.titleElement : undefined;
if (configListItem && titleElement) {
const targetElement = HTMLElementWithLightDOMTemplate.findCorrespondingElement(
element, configListItem, titleElement as HTMLElement);
if (targetElement) {
Highlighting.HighlightManager.HighlightManager.instance().set(targetElement, ranges, selectedRange);
}
}
});
}
updateSearchableView(view: SearchableView): void {
view.updateSearchMatchesCount(this.#matches.length);
view.updateCurrentMatchIndex(this.#currentMatchIndex);
}
next(): SearchResultT|undefined {
this.#currentMatchIndex = Platform.NumberUtilities.mod(this.#currentMatchIndex + 1, this.#matches.length);
this.dispatchEventToListeners(TreeSearch.Events.SEARCH_CHANGED);
return this.currentMatch();
}
prev(): SearchResultT|undefined {
this.#currentMatchIndex = Platform.NumberUtilities.mod(this.#currentMatchIndex - 1, this.#matches.length);
this.dispatchEventToListeners(TreeSearch.Events.SEARCH_CHANGED);
return this.currentMatch();
}
// This is a generator to sidestep stack overflow risks
*
#innerSearch(
node: NodeT, currentMatch: SearchResultT|undefined, jumpBackwards: boolean,
match: (node: NodeT, isPostOrder: boolean) => SearchResultT[]): Generator<SearchResultT> {
const updateCurrentMatchIndex = (isPostOrder: boolean): void => {
if (currentMatch?.node === node && currentMatch.isPostOrderMatch === isPostOrder) {
// We're current matching the node that contains the currently focused search result, the n-th result
// within that node. When updating the search hits, make sure we're still focusing the n-th result within
// that node. That may make the result jump within the node, but at least we're still focusing the same
// node. If there are fewer than n hits in the current node, we're going to move the focus to the next
// search hit in the next node by default, or the last one in this node if searching backwards.
if (currentMatch.matchIndexInNode >= preOrderMatches.length) {
this.#currentMatchIndex = jumpBackwards ? this.#matches.length - 1 : this.#matches.length;
} else {
this.#currentMatchIndex = this.#matches.length - preOrderMatches.length + currentMatch.matchIndexInNode;
}
}
};
const preOrderMatches = match(node, /* isPostOrder=*/ false);
this.#matches.push(...preOrderMatches);
updateCurrentMatchIndex(/* isPostOrder=*/ false);
yield* preOrderMatches.values();
for (const child of node.treeNodeChildren()) {
yield* this.#innerSearch(child, currentMatch, jumpBackwards, match);
}
const postOrderMatches = match(node, /* isPostOrder=*/ true);
this.#matches.push(...postOrderMatches);
updateCurrentMatchIndex(/* isPostOrder=*/ true);
yield* postOrderMatches.values();
}
search(node: NodeT, jumpBackwards: boolean, match: (node: NodeT, isPostOrder: boolean) => SearchResultT[]): number {
const currentMatch = this.currentMatch();
this.#reset();
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
for (const _ of this.#innerSearch(node, currentMatch, jumpBackwards, match)) {
// run the generator
}
this.#currentMatchIndex = Platform.NumberUtilities.mod(this.#currentMatchIndex, this.#matches.length);
this.dispatchEventToListeners(TreeSearch.Events.SEARCH_CHANGED);
return this.#matches.length;
}
}
export namespace TreeSearch {
export const enum Events {
SEARCH_CHANGED = 'SearchChanged',
}
export interface EventTypes {
[Events.SEARCH_CHANGED]: void;
}
}
class TreeViewTreeElement extends TreeElement {
static readonly CLONED_ATTRIBUTES = SDK.DOMModel.ARIA_ATTRIBUTES.union(new Set(['jslog']));
#clonedAttributes = new Set<string>();
#clonedClasses = new Set<string>();
#userExpanded = false;
#isProcessingAttribute = false;
#previousOpenAttributeValue?: string|null;
#refreshScheduled = false;
static #elementToTreeElement = new WeakMap<Node, TreeViewTreeElement>();
readonly configElement: HTMLLIElement;
constructor(treeOutline: TreeOutline, configElement: HTMLLIElement) {
super(undefined, undefined, configElement.getAttribute('jslog-context') ?? undefined);
this.configElement = configElement;
TreeViewTreeElement.#elementToTreeElement.set(configElement, this);
this.refresh();
}
override onexpand(): void {
if (!this.#isProcessingAttribute) {
this.#userExpanded = true;
}
}
override oncollapse(): void {
if (!this.#isProcessingAttribute) {
this.#userExpanded = false;
}
}
updateExpansionFromAttribute(): void {
this.#isProcessingAttribute = true;
try {
const openAttr = this.configElement.getAttribute('open');
if (openAttr === this.#previousOpenAttributeValue) {
return;
}
this.#previousOpenAttributeValue = openAttr;
if (openAttr === null) {
if (this.#userExpanded) {
this.expand();
} else {
this.collapse();
}
} else if (openAttr === 'false') {
this.collapse();
} else {
this.expand();
}
} finally {
this.#isProcessingAttribute = false;
}
}
refreshSoon(): void {
if (this.#refreshScheduled) {
return;
}
this.#refreshScheduled = true;
queueMicrotask(() => {
this.#refreshScheduled = false;
this.refresh();
});
}
updateAttributes(): void {
const expandable = Boolean(this.configElement.querySelector(':scope > ul[role="group"]'));
this.setExpandable(expandable);
this.#clonedAttributes.forEach(attr => this.listItemElement.attributes.removeNamedItem(attr));
this.#clonedClasses.forEach(className => this.listItemElement.classList.remove(className));
this.#clonedAttributes.clear();
this.#clonedClasses.clear();
for (let i = 0; i < this.configElement.attributes.length; ++i) {
const attribute = this.configElement.attributes.item(i);
if (attribute && attribute.name !== 'role' && TreeViewTreeElement.CLONED_ATTRIBUTES.has(attribute.name)) {
this.listItemElement.setAttribute(attribute.name, attribute.value);
this.#clonedAttributes.add(attribute.name);
}
}
for (const className of this.configElement.classList) {
this.listItemElement.classList.add(className);
this.#clonedClasses.add(className);
}
this.hidden = hasBooleanAttribute(this.configElement, 'hidden');
this.updateExpansionFromAttribute();
}
refresh(): void {
this.titleElement.textContent = '';
this.updateAttributes();
const childUl = this.configElement.querySelector(':scope > ul[role="group"]');
const templateElements = childUl ? [this.configElement, childUl] : [this.configElement];
Lit.CustomDirectives.InterceptBindingDirective.setEventListeners(templateElements, this.listItemElement);
for (const child of this.configElement.childNodes) {
if (child instanceof HTMLUListElement && child.role === 'group') {
continue;
}
this.titleElement.appendChild(HTMLElementWithLightDOMTemplate.cloneNode(child));
}
this.hidden = hasBooleanAttribute(this.configElement, 'hidden');
this.toggleOnClick = hasBooleanAttribute(this.configElement, 'toggle-on-click');
this.updateExpansionFromAttribute();
Highlighting.HighlightManager.HighlightManager.instance().apply(this.titleElement);
}
static get(configElement: Node|undefined): TreeViewTreeElement|undefined {
return configElement && TreeViewTreeElement.#elementToTreeElement.get(configElement);
}
remove(): void {
removeNode(this,
Boolean(this.parent &&
(this.parent as TreeViewTreeElement).configElement?.querySelector(':scope > ul[role="group"]')));
TreeViewTreeElement.#elementToTreeElement.delete(this.configElement);
}
}
function getTreeNodes(nodeList: NodeList|Node[]): Array<HTMLLIElement|TreeElementWrapper> {
return nodeList.values()
.flatMap(node => {
if (node instanceof TreeElementWrapper) {
return [node];
}
if (node instanceof HTMLLIElement && node.role === 'treeitem') {
return [
node,
...node.querySelectorAll<HTMLLIElement|TreeElementWrapper>(
'ul[role="group"] li[role="treeitem"],ul[role="group"] devtools-tree-wrapper'),
];
}
if (node instanceof HTMLElement) {
return node.querySelectorAll<HTMLLIElement|TreeElementWrapper>('li[role="treeitem"],devtools-tree-wrapper');
}
return [];
})
.toArray();
}
function getStyleElements(nodes: NodeList|Node[]): HTMLElement[] {
return [...nodes].flatMap(node => {
if (node instanceof HTMLStyleElement) {
return [node];
}
if (node instanceof HTMLElement) {
return [...node.querySelectorAll<HTMLStyleElement>('style')];
}
return [] as HTMLElement[];
});
}
function removeNode(node: TreeElement, preserveParentExpandable = false): void {
const parent = node.parent;
if (parent) {
parent.removeChild(node);
if (!preserveParentExpandable) {
parent.setExpandable(parent.children().length > 0);
}
}
}
/**
* A tree element that can be used as progressive enhancement over a <ul> element. A `template` IDL attribute allows
* additionally to insert the <ul> into a <template>, avoiding rendering anything into light DOM, which is recommended.
* The <ul> itself will be cloned into shadow DOM and rendered there.
*
* ## Usage ##
*
* It can be used as
* ```
* <devtools-tree
* .template=${html`
* <ul role="tree">
* <li role="treeitem" @expand=${onExpand}>
* Tree Node Text
* <ul role="group">
* Node with subtree
* <li role="treeitem" jslog-context="context">
* <ul role="group">
* <li role="treeitem">Tree Node Text in collapsed subtree</li>
* <li role="treeitem">Tree Node Text in collapsed subtree</li>
* </ul>
* </li>
* <li role="treeitem" open>
* Tree Node Text in expanded subtree
* <ul role="group">
* <li role="treeitem">Tree Node Text in expanded subtree</li>
* <li role="treeitem">Tree Node Text in expanded subtree</li>
* </ul>
* </li>
* <li selected role="treeitem">Tree Node Text in a selected-by-default node</li>
* </ul>
* </li>
* </ul>
* </template>`}
* ></devtools-tree>
*
* ```
* where a <li role="treeitem"> element defines a tree node and its contents (the <li> is the `config element` for this
* tree node). If a tree node contains a <ul role="group">, that defines a subtree under that tree node. The `open`
* attribute on the <li> defines whether that subtree should render as expanded. Note that node expanding/collapsing do
* not reflect this state back to the attribute on the config element, those state changes are rather sent out as
* `expand` events on the config element.
*
* Under the hood this uses TreeOutline.
*
* ## Config Element Attributes ##
*
* - `selected`: Whether the tree node should be rendered as selected.
* - `jslog-context`: The jslog context for the tree element.
* - `aria-*`: All aria attributes defined on the config element are cloned over.
* - `open`: On the <li>, declares whether the subtree should be rendererd as expanded or collapsed.
*
* ## Event Handling ##
*
* This section is only relevant if NOT using the `template`.
*
* Since config elements are cloned into the shadow DOM, it's not possible to directly attach event listeners to the
* children of config elements. Instead, the `Lit.CustomDirectives.InterceptBindingDirective` directive needs to be used as a
* wrapper:
* ```
* const on = Lit.Directive.directive(Lit.CustomDirectives.InterceptBindingDirective);
*
* html`<li role="treeitem">
* <button @click=${on(clickHandler)}>click me</button>
* </li>`
* ```
*
* @property template Define the tree contents
* @event selected A node was selected
* @attribute navigation-variant Turn this tree into the navigation variant
* @attribute hide-overflow
*/
export class TreeViewElement extends HTMLElementWithLightDOMTemplate {
static readonly observedAttributes =
['navigation-variant', 'hide-overflow', 'dense', 'show-selection-on-keyboard-focus'];
readonly #treeOutline = new TreeOutlineInShadow(undefined, this, true);
constructor() {
super();
this.addEventListener('focusin', (event: Event) => {
const actualTarget = event.composedPath()[0];
if (actualTarget === this.#treeOutline.contentElement && !this.#treeOutline.selectedTreeElement &&
this.#treeOutline.firstChild()) {
this.#treeOutline.firstChild()?.select(/* omitFocus */ true, /* selectedByUser */ false);
}
});
this.#treeOutline.addEventListener(Events.ElementSelected, event => {
if (event.data instanceof TreeViewTreeElement) {
event.data.listItemElement.dispatchEvent(new TreeViewElement.SelectEvent());
}
});
this.#treeOutline.addEventListener(Events.ElementExpanded, event => {
// TODO(crbug.com/1166669): This is a stopgap. We can remove the global event once devtools-tree-wrapper is no longer needed.
this.dispatchEvent(new TreeViewElement.TreeElementExpandEvent(event.data, true));
if (event.data instanceof TreeViewTreeElement) {
event.data.listItemElement.dispatchEvent(new TreeViewElement.ExpandEvent({expanded: true}));
}
});
this.#treeOutline.addEventListener(Events.ElementCollapsed, event => {
// TODO(crbug.com/1166669): This is a stopgap. We can remove the global event once devtools-tree-wrapper is no longer needed.
this.dispatchEvent(new TreeViewElement.TreeElementExpandEvent(event.data, false));
if (event.data instanceof TreeViewTreeElement) {
event.data.listItemElement.dispatchEvent(new TreeViewElement.ExpandEvent({expanded: false}));
}
});
this.addNodes(getTreeNodes([this]));
}
getInternalTreeOutlineForTest(): TreeOutlineInShadow {
return this.#treeOutline;
}
override focus(): void {
if (!this.#treeOutline.selectedTreeElement && this.#treeOutline.firstChild()) {
this.#treeOutline.firstChild()?.select(/* omitFocus */ true, /* selectedByUser */ false);
} else {
this.#treeOutline.focus();
}
}
#getParentTreeElement(element: HTMLLIElement|TreeElementWrapper):
{treeElement: TreeElement, expanded: boolean, classes: DOMTokenList, attributes?: NamedNodeMap}|null {
const subtreeRoot = element.parentElement;
if (!(subtreeRoot instanceof HTMLUListElement)) {
return null;
}
if (subtreeRoot.role === 'tree') {
return {
treeElement: this.#treeOutline.rootElement(),
expanded: false,
classes: subtreeRoot.classList,
attributes: subtreeRoot.attributes,
};
}
if (subtreeRoot.role !== 'group' || !subtreeRoot.parentElement) {
return null;
}
const treeElement = TreeViewTreeElement.get(subtreeRoot.parentElement);
const expanded = treeElement ? treeElement.expanded : hasBooleanAttribute(subtreeRoot.parentElement, 'open');
return treeElement ? {expanded, treeElement, classes: subtreeRoot.classList, attributes: subtreeRoot.attributes} :
null;
}
protected override updateNode(node: Node, attributeName: string|null): void {
let current: Node|null = node;
while (current?.parentNode && !(current instanceof HTMLElement)) {
current = current.parentNode;
}
const treeNode = current instanceof HTMLElement ? current.closest('li[role="treeitem"]') : null;
if (!treeNode) {
return;
}
const treeElement = TreeViewTreeElement.get(treeNode);
if (!treeElement) {
return;
}
if (node === treeNode) {
treeElement.updateAttributes();
if (attributeName === 'selected' && hasBooleanAttribute(treeNode, 'selected')) {
treeElement.revealAndSelect(true);
}
return;
}
if (node === treeNode && attributeName === 'toggle-on-click') {
treeElement.toggleOnClick = hasBooleanAttribute(treeNode, 'toggle-on-click');
}
treeElement.refreshSoon();
}
protected override addNodes(nodes: NodeList|Node[]): void {
for (const node of getTreeNodes(nodes)) {
if (TreeViewTreeElement.get(node)) {
continue; // Not sure this can happen
}
const parent = this.#getParentTreeElement(node);
if (!parent) {
continue;
}
if (parent.treeElement.childCount() === 0) {
parent.treeElement.childrenListElement.classList.add(...parent.classes.values());
for (const attr of parent.attributes || []) {
if (attr.name !== 'role' && attr.name !== 'class') {
parent.treeElement.childrenListElement.setAttribute(attr.name, attr.value);
}
}
}
let nextElement: TreeElement|null = null;
for (let e: Element|null = node.nextElementSibling; e; e = e.nextElementSibling) {
const nextTreeEl = TreeViewTreeElement.get(e);
if (nextTreeEl) {
nextElement = nextTreeEl;
break;
}
if (e instanceof TreeElementWrapper && e.treeElement && e.treeElement.parent === parent.treeElement) {
nextElement = e.treeElement;
break;
}
}
const index = nextElement ? parent.treeElement.indexOfChild(nextElement) : parent.treeElement.children().length;
let treeElement;
if (node instanceof HTMLLIElement) {
treeElement = new TreeViewTreeElement(this.#treeOutline, node);
const expandable = Boolean(node.querySelector(':scope > ul[role="group"]'));
treeElement.setExpandable(expandable);
treeElement.updateExpansionFromAttribute();
} else {
treeElement = node.treeElement;
}
if (treeElement) {
if (treeElement.parent) {
removeNode(treeElement);
}
parent.treeElement.insertChild(treeElement, index);
if (parent.treeElement instanceof TreeViewTreeElement) {
parent.treeElement.updateExpansionFromAttribute();
}
if (hasBooleanAttribute(node, 'selected')) {
treeElement.revealAndSelect(true);
}
if (parent.expanded) {
parent.treeElement.expand();
}
}
}
for (const element of new Set(getStyleElements(nodes))) {
this.#treeOutline.shadowRoot.appendChild(element.cloneNode(true));
}
}
protected override removeNodes(nodes: NodeList|Node[]): void {
for (const node of getTreeNodes(nodes)) {
if (node instanceof HTMLLIElement) {
TreeViewTreeElement.get(node)?.remove();
} else if (node.treeElement) {
removeNode(node.treeElement,
Boolean(node.treeElement.parent &&
(node.treeElement.parent as TreeViewTreeElement)
.configElement?.querySelector(':scope > ul[role="group"]')));
}
}
}
set hideOverflow(hide: boolean) {
this.toggleAttribute('hide-overflow', hide);
}
get hideOverflow(): boolean {
return hasBooleanAttribute(this, 'hide-overflow');
}
set navgiationVariant(navigationVariant: boolean) {
this.toggleAttribute('navigation-variant', navigationVariant);
}
get navigationVariant(): boolean {
return hasBooleanAttribute(this, 'navigation-variant');
}
set dense(dense: boolean) {
this.toggleAttribute('dense', dense);
}
get dense(): boolean {
return hasBooleanAttribute(this, 'dense');
}
attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {
if (oldValue === newValue) {
return;
}
const booleanValueIsTrue = newValue !== null && newValue !== 'false';
switch (name) {
case 'navigation-variant':
this.#treeOutline.setVariant(booleanValueIsTrue ? TreeVariant.NAVIGATION_TREE : TreeVariant.OTHER);
break;
case 'hide-overflow':
this.#treeOutline.setHideOverflow(booleanValueIsTrue);
break;
case 'dense':
this.#treeOutline.setDense(booleanValueIsTrue);
break;
case 'show-selection-on-keyboard-focus':
this.#treeOutline.setShowSelectionOnKeyboardFocus(booleanValueIsTrue);
break;
}
}
}
export namespace TreeViewElement {
export class SelectEvent extends CustomEvent<void> {
constructor() {
super('select');
}
}
export class ExpandEvent extends CustomEvent<{expanded: boolean}> {
constructor(detail: {expanded: boolean}) {
super('expand', {detail});
}
}
/**
* @deprecated
*/
export class TreeElementExpandEvent extends CustomEvent<{treeElement: TreeElement, expanded: boolean}> {
constructor(treeElement: TreeElement, expanded: boolean) {
super('treeelementexpand', {detail: {treeElement, expanded}});
}
}
}
class IfExpandedDirective extends Lit.Directive.Directive {
#partInfo: {type: Lit.Directive.PartType, startNode: Node};
constructor(partInfo: Lit.Directive.PartInfo) {
if (partInfo.type !== Lit.Directive.PartType.CHILD) {
throw new Error('ifExpanded directive must be used in a child node');
}
super(partInfo);
this.#partInfo = partInfo as {type: Lit.Directive.PartType, startNode: Node};
}
render(content: Lit.LitTemplate|Iterable<Lit.LitTemplate>): Lit.LitTemplate|Iterable<Lit.LitTemplate> {
return this.#isInExpandedRow(this.#partInfo.startNode) ? content : Lit.nothing;
}
#isInExpandedRow(element: Node|null|undefined): boolean {
if (!element) {
return false;
}
if (!(element instanceof HTMLElement)) {
element = element.parentNode;
}
if (!(element instanceof HTMLElement)) {
return false;
}
element = element.closest('li[role="treeitem"]') ?? undefined;
if (!(element instanceof HTMLLIElement)) {
return false;
}
if (hasBooleanAttribute(element, 'open')) {
return true;
}
const node = TreeViewTreeElement.get(element);
if (!node) {
return false;
}
return node.expanded;
}
}
export const ifExpanded = Lit.Directive.directive(IfExpandedDirective);
export class TreeElementWrapper extends HTMLElement {
#treeElement?: TreeElement;
set treeElement(treeElement: TreeElement) {
if (this.#treeElement === treeElement) {
return;
}
if (this.#treeElement?.parent) {
const parent = this.#treeElement.parent;
const index = parent.indexOfChild(this.#treeElement);
parent.removeChildAtIndex(index);
parent.insertChild(treeElement, index);
}
this.#treeElement = treeElement;
}
get treeElement(): TreeElement|undefined {
return this.#treeElement;
}
}
customElements.define('devtools-tree', TreeViewElement);
customElements.define('devtools-tree-wrapper', TreeElementWrapper);
declare global {
interface HTMLElementTagNameMap {
'devtools-tree': TreeViewElement;
'devtools-tree-wrapper': TreeElementWrapper;
}
}
function loggingParentProvider(e: Element): Element|undefined {
const treeElement = TreeElement.getTreeElementBylistItemNode(e);
const parentElement = treeElement?.parent?.listItemElement;
return parentElement?.isConnected && parentElement || treeElement?.treeOutline?.contentElement;
}
VisualLogging.registerParentProvider('parentTreeItem', loggingParentProvider);