mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-09 21:28:43 +08:00
This CL contains the following changes: - replaces children, numChildren, hasOnlyUnloadedChildren and loadChildren() with children() and hasChildren() in the AXNode interface. - children will be loaded when the element is expanded, and then cached. - the responsibility to expand preloaded nodes belongs to AccessibilityTree now. I think previously it'd expand indefinitely given that the nodes are preloaded. Bug: 887173 Change-Id: I6c16ca169fa58be0554199c9367024b78b43fa09 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2692207 Commit-Queue: Alex Rudenko <alexrudenko@chromium.org> Reviewed-by: Meredith Lane <meredithl@chromium.org>
420 lines
10 KiB
TypeScript
420 lines
10 KiB
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 {ls} from '../platform/platform.js';
|
|
import * as LitHtml from '../third_party/lit-html/lit-html.js';
|
|
|
|
import {AccessibilityTree} from './AccessibilityTree.js';
|
|
import {AXNode} from './AccessibilityTreeUtils.js';
|
|
|
|
export interface AccessibilityNodeData {
|
|
axNode: AXNode;
|
|
}
|
|
|
|
export class AccessibilityNode extends HTMLElement {
|
|
private readonly shadow = this.attachShadow({
|
|
mode: 'open',
|
|
delegatesFocus: false,
|
|
});
|
|
private axNode: AXNode|null = null;
|
|
// Loaded child nodes.
|
|
private childAxNodes: AXNode[] = [];
|
|
private axTree: AccessibilityTree|null = null;
|
|
// Only elements that contain child nodes can be expanded.
|
|
private expanded: boolean = false;
|
|
private selected: boolean = false;
|
|
|
|
constructor() {
|
|
super();
|
|
this.addEventListener('click', this.onClick.bind(this));
|
|
this.addEventListener('mouseover', this.onMouseOver.bind(this));
|
|
this.addEventListener('mouseleave', this.onMouseLeave.bind(this));
|
|
this.tabIndex = -1;
|
|
this.setAttribute('role', 'treeitem');
|
|
}
|
|
|
|
set data(data: AccessibilityNodeData) {
|
|
this.axNode = data.axNode;
|
|
this.axTree = this.axNode.axTree;
|
|
if (this.axNode.hasChildren()) {
|
|
this.classList.add('parent');
|
|
} else {
|
|
this.classList.add('no-children');
|
|
}
|
|
if (this.axTree) {
|
|
this.axTree.appendToNodeMap(this.axNode.id, this);
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
async expand(): Promise<void> {
|
|
if (!this.axNode?.hasChildren()) {
|
|
return;
|
|
}
|
|
this.childAxNodes = await this.axNode?.children();
|
|
this.expanded = true;
|
|
this.classList.add('expanded');
|
|
this.render();
|
|
}
|
|
|
|
collapse(): void {
|
|
if (!this.axNode?.hasChildren()) {
|
|
return;
|
|
}
|
|
this.expanded = false;
|
|
this.classList.remove('expanded');
|
|
}
|
|
|
|
select(): void {
|
|
this.selected = true;
|
|
this.render();
|
|
this.focus();
|
|
}
|
|
|
|
deselect(): void {
|
|
this.selected = false;
|
|
this.render();
|
|
}
|
|
|
|
get isExpanded(): boolean {
|
|
return this.expanded;
|
|
}
|
|
|
|
selectPreviousNode(): void {
|
|
if (!this.axTree) {
|
|
return;
|
|
}
|
|
|
|
const previousNode = this.getPreviousSibling();
|
|
if (previousNode) {
|
|
this.axTree.selectedAXNode = previousNode;
|
|
}
|
|
}
|
|
|
|
selectNextNode(): void {
|
|
if (!this.axTree) {
|
|
return;
|
|
}
|
|
|
|
if (this.expanded) {
|
|
this.selectFirstChild();
|
|
return;
|
|
}
|
|
|
|
const nextNode = this.getNextSibling();
|
|
if (nextNode) {
|
|
this.axTree.selectedAXNode = nextNode;
|
|
}
|
|
}
|
|
|
|
selectParent(): void {
|
|
if (!this.axTree) {
|
|
return;
|
|
}
|
|
|
|
const parent = this.getParentNode();
|
|
if (parent) {
|
|
this.axTree.selectedAXNode = parent;
|
|
}
|
|
}
|
|
|
|
selectFirstChild(): void {
|
|
const firstChild = this.getFirstChild();
|
|
if (this.axTree && firstChild) {
|
|
this.axTree.selectedAXNode = firstChild;
|
|
}
|
|
}
|
|
|
|
defaultAction(): void {
|
|
this.toggleChildren();
|
|
}
|
|
|
|
getLastChild(): AccessibilityNode|null {
|
|
if (!this.axTree || !this.axNode) {
|
|
return null;
|
|
}
|
|
|
|
if (this.expanded) {
|
|
const lastChildIndex = this.childAxNodes.length - 1;
|
|
const lastChild = this.axTree.getNodeByAXID(this.childAxNodes[lastChildIndex].id);
|
|
if (lastChild) {
|
|
return lastChild;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private getNextSibling(): AccessibilityNode|null {
|
|
const parent = this.getParentNode();
|
|
if (!parent || !parent.axNode) {
|
|
return null;
|
|
}
|
|
|
|
const indexInParent = parent.indexOf(this);
|
|
if (indexInParent + 1 < parent.childAxNodes.length) {
|
|
return parent.getChild(indexInParent + 1);
|
|
}
|
|
|
|
return parent.getNextSibling();
|
|
}
|
|
|
|
private getPreviousSibling(): AccessibilityNode|null {
|
|
const parent = this.getParentNode();
|
|
if (!parent) {
|
|
return null;
|
|
}
|
|
|
|
const indexInParent = parent.indexOf(this);
|
|
if (indexInParent === 0) {
|
|
return parent;
|
|
}
|
|
|
|
let previousSibling = parent.getChild(indexInParent - 1);
|
|
while (previousSibling && previousSibling.expanded) {
|
|
previousSibling = previousSibling.getLastChild();
|
|
}
|
|
|
|
return previousSibling;
|
|
}
|
|
|
|
private getChild(index: number): AccessibilityNode|null {
|
|
if (!this.axNode || !this.axNode.hasChildren() || !this.axTree) {
|
|
return null;
|
|
}
|
|
|
|
return this.axTree.getNodeByAXID(this.childAxNodes[index].id);
|
|
}
|
|
|
|
private getFirstChild(): AccessibilityNode|null {
|
|
if (!this.axNode || !this.axNode.hasChildren() || !this.axTree) {
|
|
return null;
|
|
}
|
|
|
|
return this.axTree.getNodeByAXID(this.childAxNodes[0].id);
|
|
}
|
|
|
|
private getParentNode(): AccessibilityNode|null {
|
|
if (!this.axNode || !this.axNode.parent || !this.axTree) {
|
|
return null;
|
|
}
|
|
|
|
return this.axTree.getNodeByAXID(this.axNode.parent.id);
|
|
}
|
|
|
|
private indexOf(node: AccessibilityNode): number {
|
|
const childAXNode = node.axNode;
|
|
if (!this.axNode || !childAXNode) {
|
|
return -1;
|
|
}
|
|
|
|
return this.childAxNodes.indexOf(childAXNode);
|
|
}
|
|
|
|
private onClick(e: MouseEvent): void {
|
|
if (this.axTree) {
|
|
this.axTree.selectedAXNode = this;
|
|
}
|
|
|
|
e.stopPropagation();
|
|
this.toggleChildren();
|
|
}
|
|
|
|
private onMouseOver(): void {
|
|
this.axNode?.highlightNode();
|
|
}
|
|
|
|
private onMouseLeave(): void {
|
|
this.axNode?.clearHighlight();
|
|
}
|
|
|
|
private async toggleChildren(): Promise<void> {
|
|
if (!this.axNode || !this.axNode.hasChildren()) {
|
|
return;
|
|
}
|
|
|
|
if (this.expanded) {
|
|
this.collapse();
|
|
} else {
|
|
await this.expand();
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
private renderChildren(node: AXNode): LitHtml.TemplateResult {
|
|
if (!node) {
|
|
return LitHtml.html``;
|
|
}
|
|
|
|
const children = [];
|
|
for (const child of this.childAxNodes) {
|
|
const childTemplate = LitHtml.html`
|
|
<devtools-accessibility-node .data=${{
|
|
axNode: child,
|
|
} as AccessibilityNodeData}>
|
|
</devtools-accessibility-node>
|
|
`;
|
|
children.push(childTemplate);
|
|
}
|
|
|
|
return LitHtml.html`<div role='group' class='children'>${children}</div>`;
|
|
}
|
|
|
|
// This function is a variant of setTextContentTruncatedIfNeeded found in DOMExtension.
|
|
private truncateTextIfNeeded(text: string): string {
|
|
const maxTextContentLength = 10000;
|
|
|
|
if (text.length > maxTextContentLength) {
|
|
return Platform.StringUtilities.trimMiddle(text, maxTextContentLength);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
private renderNodeContent(): LitHtml.TemplateResult[] {
|
|
const nodeContent: LitHtml.TemplateResult[] = [];
|
|
if (!this.axNode) {
|
|
return nodeContent;
|
|
}
|
|
|
|
const role = this.axNode.role;
|
|
if (!role) {
|
|
return nodeContent;
|
|
}
|
|
|
|
const roleElement = LitHtml.html`<span class='monospace'>${this.truncateTextIfNeeded(role || '')}</span>`;
|
|
nodeContent.push(LitHtml.html`${roleElement}`);
|
|
|
|
const name = this.axNode.name;
|
|
if (name) {
|
|
nodeContent.push(LitHtml.html`<span class='separator'>\xA0</span>`);
|
|
nodeContent.push(LitHtml.html`<span class='ax-readable-string'>"${name}"</span>`);
|
|
}
|
|
|
|
return nodeContent;
|
|
}
|
|
|
|
private render(): void {
|
|
if (!this.axNode) {
|
|
return;
|
|
}
|
|
|
|
const parts: LitHtml.TemplateResult[] = [];
|
|
// TODO(annabelzhou): Ignored nodes (and their potential children) to be handled in the future.
|
|
if (this.axNode.ignored) {
|
|
parts.push(LitHtml.html`<span class='monospace ignored-node'>${ls`Ignored`}</span>`);
|
|
} else {
|
|
const nodeContent = this.renderNodeContent();
|
|
const classes = LitHtml.Directives.classMap({
|
|
'wrapper': true,
|
|
'selected': this.selected,
|
|
});
|
|
parts.push(LitHtml.html`<div class=${classes}>${nodeContent}</div>`);
|
|
}
|
|
|
|
const children = this.renderChildren(this.axNode);
|
|
parts.push(children);
|
|
|
|
// clang-format off
|
|
const output = LitHtml.html`
|
|
<style>
|
|
.ax-readable-string {
|
|
font-style: italic;
|
|
}
|
|
|
|
.monospace {
|
|
font-family: var(--monospace-font-family);
|
|
font-size: var(--monospace-font-size);
|
|
}
|
|
|
|
.ignored-node {
|
|
font-style: italic;
|
|
opacity: 70%;
|
|
}
|
|
|
|
:host {
|
|
align-items: center;
|
|
display: block;
|
|
margin: 0;
|
|
min-height: 16px;
|
|
overflow-x: hidden;
|
|
padding-left: 4px;
|
|
padding-right: 4px;
|
|
position: relative;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
span {
|
|
flex-shrink: 0;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.children {
|
|
padding-inline-start: 12px;
|
|
}
|
|
|
|
:focus {
|
|
outline: none;
|
|
}
|
|
|
|
:host(:not(.expanded)) .children {
|
|
display: none;
|
|
}
|
|
|
|
:host(.no-children) {
|
|
margin-left: 16px;
|
|
}
|
|
|
|
:host(.parent)::before {
|
|
box-sizing: border-box;
|
|
user-select: none;
|
|
-webkit-mask-image: url(Images/treeoutlineTriangles.svg);
|
|
-webkit-mask-size: 32px 24px;
|
|
content: '\A0';
|
|
color: transparent;
|
|
text-shadow: none;
|
|
margin-right: -3px;
|
|
-webkit-mask-position: 0 0;
|
|
background-color: var(--color-syntax-7);
|
|
}
|
|
|
|
:host(.parent.expanded)::before {
|
|
-webkit-mask-position: -16px 0;
|
|
}
|
|
|
|
.wrapper {
|
|
display: inline-block;
|
|
width: 96%;
|
|
}
|
|
|
|
.wrapper:hover {
|
|
background: var(--color-background-elevation-2);
|
|
}
|
|
|
|
.wrapper.selected {
|
|
outline: none;
|
|
background: var(--selection-bg-color);
|
|
}
|
|
</style>
|
|
${parts}
|
|
`;
|
|
// clang-format on
|
|
LitHtml.render(output, this.shadow);
|
|
}
|
|
}
|
|
|
|
if (!customElements.get('devtools-accessibility-node')) {
|
|
customElements.define('devtools-accessibility-node', AccessibilityNode);
|
|
}
|
|
|
|
declare global {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
interface HTMLElementTagNameMap {
|
|
'devtools-accessibility-node': AccessibilityNode;
|
|
}
|
|
}
|