UNPKG

chrome-devtools-frontend

Version:
641 lines (556 loc) • 20.6 kB
// Copyright 2016 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 Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Root from '../../core/root/root.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Feedback from '../../ui/components/panel_feedback/panel_feedback.js'; import * as UI from '../../ui/legacy/legacy.js'; import axBreadcrumbsStyles from './axBreadcrumbs.css.js'; import type * as Protocol from '../../generated/protocol.js'; import {type AccessibilitySidebarView} from './AccessibilitySidebarView.js'; import {AccessibilitySubPane} from './AccessibilitySubPane.js'; const UIStrings = { /** *@description Text in AXBreadcrumbs Pane of the Accessibility panel */ accessibilityTree: 'Accessibility Tree', /** *@description Text to scroll the displayed content into view */ scrollIntoView: 'Scroll into view', /** *@description Ignored node element text content in AXBreadcrumbs Pane of the Accessibility panel */ ignored: 'Ignored', /** *@description Name for experimental tree toggle. */ fullTreeExperimentName: 'Enable full-page accessibility tree', /** *@description Description text for experimental tree toggle. */ fullTreeExperimentDescription: 'The accessibility tree moved to the top right corner of the DOM tree.', /** *@description Message saying that DevTools must be restarted before the experiment is enabled. */ reloadRequired: 'Reload required before the change takes effect.', }; const str_ = i18n.i18n.registerUIStrings('panels/accessibility/AXBreadcrumbsPane.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class AXBreadcrumbsPane extends AccessibilitySubPane { private readonly axSidebarView: AccessibilitySidebarView; private preselectedBreadcrumb: AXBreadcrumb|null; private inspectedNodeBreadcrumb: AXBreadcrumb|null; private collapsingBreadcrumbId: number; private hoveredBreadcrumb: AXBreadcrumb|null; private readonly rootElement: HTMLElement; #legacyTreeDisabled = false; constructor(axSidebarView: AccessibilitySidebarView) { super(i18nString(UIStrings.accessibilityTree)); this.element.classList.add('ax-subpane'); this.element.tabIndex = -1; this.axSidebarView = axSidebarView; this.preselectedBreadcrumb = null; this.inspectedNodeBreadcrumb = null; this.collapsingBreadcrumbId = -1; this.rootElement = this.element.createChild('div', 'ax-breadcrumbs'); this.hoveredBreadcrumb = null; const previewToggle = new Feedback.PreviewToggle.PreviewToggle(); const name = i18nString(UIStrings.fullTreeExperimentName); const experiment = Root.Runtime.ExperimentName.FULL_ACCESSIBILITY_TREE; const onChangeCallback: (checked: boolean) => void = checked => { Host.userMetrics.experimentChanged(experiment, checked); UI.InspectorView.InspectorView.instance().displayReloadRequiredWarning(i18nString(UIStrings.reloadRequired)); }; if (Root.Runtime.experiments.isEnabled(experiment)) { this.#legacyTreeDisabled = true; const feedbackURL = 'https://g.co/devtools/a11y-tree-feedback'; previewToggle.data = { name, helperText: i18nString(UIStrings.fullTreeExperimentDescription), feedbackURL, experiment, onChangeCallback, }; this.element.appendChild(previewToggle); return; } previewToggle.data = {name, helperText: null, feedbackURL: null, experiment, onChangeCallback}; this.element.prepend(previewToggle); UI.ARIAUtils.markAsTree(this.rootElement); this.rootElement.addEventListener('keydown', this.onKeyDown.bind(this), true); this.rootElement.addEventListener('mousemove', this.onMouseMove.bind(this), false); this.rootElement.addEventListener('mouseleave', this.onMouseLeave.bind(this), false); this.rootElement.addEventListener('click', this.onClick.bind(this), false); this.rootElement.addEventListener('contextmenu', this.contextMenuEventFired.bind(this), false); this.rootElement.addEventListener('focusout', this.onFocusOut.bind(this), false); } override focus(): void { if (this.inspectedNodeBreadcrumb) { this.inspectedNodeBreadcrumb.nodeElement().focus(); } else { this.element.focus(); } } override setAXNode(axNode: SDK.AccessibilityModel.AccessibilityNode|null): void { if (this.#legacyTreeDisabled) { return; } const hadFocus = this.element.hasFocus(); super.setAXNode(axNode); this.rootElement.removeChildren(); if (!axNode) { return; } const ancestorChain = []; let ancestor: (SDK.AccessibilityModel.AccessibilityNode|null)|SDK.AccessibilityModel.AccessibilityNode = axNode; while (ancestor) { ancestorChain.push(ancestor); ancestor = ancestor.parentNode(); } ancestorChain.reverse(); let depth = 0; let parent: AXBreadcrumb|null = null; this.inspectedNodeBreadcrumb = null; for (ancestor of ancestorChain) { if (ancestor !== axNode && ancestor.ignored() && ancestor.parentNode()) { continue; } const breadcrumb = new AXBreadcrumb(ancestor, depth, (ancestor === axNode)); if (parent) { parent.appendChild(breadcrumb); } else { this.rootElement.appendChild(breadcrumb.element()); } parent = breadcrumb; depth++; this.inspectedNodeBreadcrumb = breadcrumb; } if (this.inspectedNodeBreadcrumb) { this.inspectedNodeBreadcrumb.setPreselected(true, hadFocus); } this.setPreselectedBreadcrumb(this.inspectedNodeBreadcrumb); function append( parentBreadcrumb: AXBreadcrumb, axNode: SDK.AccessibilityModel.AccessibilityNode, localDepth: number): void { if (axNode.ignored()) { axNode.children().map(child => append(parentBreadcrumb, child, localDepth)); return; } const childBreadcrumb = new AXBreadcrumb(axNode, localDepth, false); parentBreadcrumb.appendChild(childBreadcrumb); // In most cases there will be no children here, but there are some special cases. for (const child of axNode.children()) { append(childBreadcrumb, child, localDepth + 1); } } if (this.inspectedNodeBreadcrumb && !axNode.ignored()) { for (const child of axNode.children()) { append(this.inspectedNodeBreadcrumb, child, depth); if (child.backendDOMNodeId() === this.collapsingBreadcrumbId) { this.setPreselectedBreadcrumb(this.inspectedNodeBreadcrumb.lastChild()); } } } this.collapsingBreadcrumbId = -1; } override willHide(): void { this.setPreselectedBreadcrumb(null); } private onKeyDown(event: Event): void { const preselectedBreadcrumb = this.preselectedBreadcrumb; if (!preselectedBreadcrumb) { return; } const keyboardEvent = event as KeyboardEvent; if (!keyboardEvent.composedPath().some(element => element === preselectedBreadcrumb.element())) { return; } if (keyboardEvent.shiftKey || keyboardEvent.metaKey || keyboardEvent.ctrlKey) { return; } let handled = false; if (keyboardEvent.key === 'ArrowUp' && !keyboardEvent.altKey) { handled = this.preselectPrevious(); } else if ((keyboardEvent.key === 'ArrowDown') && !keyboardEvent.altKey) { handled = this.preselectNext(); } else if (keyboardEvent.key === 'ArrowLeft' && !keyboardEvent.altKey) { if (preselectedBreadcrumb.hasExpandedChildren()) { this.collapseBreadcrumb(preselectedBreadcrumb); } else { handled = this.preselectParent(); } } else if ((keyboardEvent.key === 'Enter' || (keyboardEvent.key === 'ArrowRight' && !keyboardEvent.altKey && preselectedBreadcrumb.axNode().hasOnlyUnloadedChildren()))) { handled = this.inspectDOMNode(preselectedBreadcrumb.axNode()); } if (handled) { keyboardEvent.consume(true); } } private preselectPrevious(): boolean { if (!this.preselectedBreadcrumb) { return false; } const previousBreadcrumb = this.preselectedBreadcrumb.previousBreadcrumb(); if (!previousBreadcrumb) { return false; } this.setPreselectedBreadcrumb(previousBreadcrumb); return true; } private preselectNext(): boolean { if (!this.preselectedBreadcrumb) { return false; } const nextBreadcrumb = this.preselectedBreadcrumb.nextBreadcrumb(); if (!nextBreadcrumb) { return false; } this.setPreselectedBreadcrumb(nextBreadcrumb); return true; } private preselectParent(): boolean { if (!this.preselectedBreadcrumb) { return false; } const parentBreadcrumb = this.preselectedBreadcrumb.parentBreadcrumb(); if (!parentBreadcrumb) { return false; } this.setPreselectedBreadcrumb(parentBreadcrumb); return true; } private setPreselectedBreadcrumb(breadcrumb: AXBreadcrumb|null): void { if (breadcrumb === this.preselectedBreadcrumb) { return; } const hadFocus = this.element.hasFocus(); if (this.preselectedBreadcrumb) { this.preselectedBreadcrumb.setPreselected(false, hadFocus); } if (breadcrumb) { this.preselectedBreadcrumb = breadcrumb; } else { this.preselectedBreadcrumb = this.inspectedNodeBreadcrumb; } if (this.preselectedBreadcrumb) { this.preselectedBreadcrumb.setPreselected(true, hadFocus); } if (!breadcrumb && hadFocus) { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } } private collapseBreadcrumb(breadcrumb: AXBreadcrumb): void { if (!breadcrumb.parentBreadcrumb()) { return; } const backendNodeId = breadcrumb.axNode().backendDOMNodeId(); if (backendNodeId !== null) { this.collapsingBreadcrumbId = backendNodeId; } const parentBreadcrumb = breadcrumb.parentBreadcrumb(); if (parentBreadcrumb) { this.inspectDOMNode(parentBreadcrumb.axNode()); } } private onMouseLeave(_event: Event): void { this.setHoveredBreadcrumb(null); } private onMouseMove(event: Event): void { const target = event.target as Element | null; if (!target) { return; } const breadcrumbElement = target.enclosingNodeOrSelfWithClass('ax-breadcrumb'); if (!breadcrumbElement) { this.setHoveredBreadcrumb(null); return; } const breadcrumb = elementsToAXBreadcrumb.get(breadcrumbElement); if (!breadcrumb || !breadcrumb.isDOMNode()) { return; } this.setHoveredBreadcrumb(breadcrumb); } private onFocusOut(event: Event): void { if (!this.preselectedBreadcrumb || event.target !== this.preselectedBreadcrumb.nodeElement()) { return; } this.setPreselectedBreadcrumb(null); } private onClick(event: Event): void { const target = event.target as Element | null; if (!target) { return; } const breadcrumbElement = target.enclosingNodeOrSelfWithClass('ax-breadcrumb'); if (!breadcrumbElement) { this.setHoveredBreadcrumb(null); return; } const breadcrumb = elementsToAXBreadcrumb.get(breadcrumbElement); if (!breadcrumb) { return; } if (breadcrumb.inspected()) { // This will collapse and preselect/focus the breadcrumb. this.collapseBreadcrumb(breadcrumb); breadcrumb.nodeElement().focus(); return; } if (!breadcrumb.isDOMNode()) { return; } this.inspectDOMNode(breadcrumb.axNode()); } private setHoveredBreadcrumb(breadcrumb: AXBreadcrumb|null): void { if (breadcrumb === this.hoveredBreadcrumb) { return; } if (this.hoveredBreadcrumb) { this.hoveredBreadcrumb.setHovered(false); } const node = this.node(); if (breadcrumb) { breadcrumb.setHovered(true); } else if (node && node.id) { // Highlight and scroll into view the currently inspected node. node.domModel().overlayModel().nodeHighlightRequested({nodeId: node.id}); } this.hoveredBreadcrumb = breadcrumb; } private inspectDOMNode(axNode: SDK.AccessibilityModel.AccessibilityNode): boolean { if (!axNode.isDOMNode()) { return false; } const deferredNode = axNode.deferredDOMNode(); if (deferredNode) { deferredNode.resolve(domNode => { this.axSidebarView.setNode(domNode, true /* fromAXTree */); void Common.Revealer.reveal(domNode, true /* omitFocus */); }); } return true; } private contextMenuEventFired(event: Event): void { const target = event.target as Element | null; if (!target) { return; } const breadcrumbElement = target.enclosingNodeOrSelfWithClass('ax-breadcrumb'); if (!breadcrumbElement) { return; } const breadcrumb = elementsToAXBreadcrumb.get(breadcrumbElement); if (!breadcrumb) { return; } const axNode = breadcrumb.axNode(); if (!axNode.isDOMNode() || !axNode.deferredDOMNode()) { return; } const contextMenu = new UI.ContextMenu.ContextMenu(event); contextMenu.viewSection().appendItem(i18nString(UIStrings.scrollIntoView), () => { const deferredNode = axNode.deferredDOMNode(); if (!deferredNode) { return; } void deferredNode.resolvePromise().then(domNode => { if (!domNode) { return; } void domNode.scrollIntoView(); }); }); const deferredNode = axNode.deferredDOMNode(); if (deferredNode) { contextMenu.appendApplicableItems(deferredNode); } void contextMenu.show(); } override wasShown(): void { super.wasShown(); this.registerCSSFiles([axBreadcrumbsStyles]); } } const elementsToAXBreadcrumb = new WeakMap<Element, AXBreadcrumb>(); export class AXBreadcrumb { private readonly axNodeInternal: SDK.AccessibilityModel.AccessibilityNode; private readonly elementInternal: HTMLDivElement; private nodeElementInternal: HTMLDivElement; private readonly nodeWrapper: HTMLDivElement; private readonly selectionElement: HTMLDivElement; private readonly childrenGroupElement: HTMLDivElement; private readonly children: AXBreadcrumb[]; private hovered: boolean; private preselectedInternal: boolean; private parent: AXBreadcrumb|null; private inspectedInternal: boolean; constructor(axNode: SDK.AccessibilityModel.AccessibilityNode, depth: number, inspected: boolean) { this.axNodeInternal = axNode; this.elementInternal = document.createElement('div'); this.elementInternal.classList.add('ax-breadcrumb'); elementsToAXBreadcrumb.set(this.elementInternal, this); this.nodeElementInternal = document.createElement('div'); this.nodeElementInternal.classList.add('ax-node'); UI.ARIAUtils.markAsTreeitem(this.nodeElementInternal); this.nodeElementInternal.tabIndex = -1; this.elementInternal.appendChild(this.nodeElementInternal); this.nodeWrapper = document.createElement('div'); this.nodeWrapper.classList.add('wrapper'); this.nodeElementInternal.appendChild(this.nodeWrapper); this.selectionElement = document.createElement('div'); this.selectionElement.classList.add('selection'); this.selectionElement.classList.add('fill'); this.nodeElementInternal.appendChild(this.selectionElement); this.childrenGroupElement = document.createElement('div'); this.childrenGroupElement.classList.add('children'); UI.ARIAUtils.markAsGroup(this.childrenGroupElement); this.elementInternal.appendChild(this.childrenGroupElement); this.children = []; this.hovered = false; this.preselectedInternal = false; this.parent = null; this.inspectedInternal = inspected; this.nodeElementInternal.classList.toggle('inspected', inspected); this.nodeElementInternal.style.paddingLeft = (16 * depth + 4) + 'px'; if (this.axNodeInternal.ignored()) { this.appendIgnoredNodeElement(); } else { this.appendRoleElement(this.axNodeInternal.role()); const axNodeName = this.axNodeInternal.name(); if (axNodeName && axNodeName.value) { this.nodeWrapper.createChild('span', 'separator').textContent = '\xA0'; this.appendNameElement(axNodeName.value as string); } } if (!this.axNodeInternal.ignored() && this.axNodeInternal.hasOnlyUnloadedChildren()) { this.nodeElementInternal.classList.add('children-unloaded'); UI.ARIAUtils.setExpanded(this.nodeElementInternal, false); } if (!this.axNodeInternal.isDOMNode()) { this.nodeElementInternal.classList.add('no-dom-node'); } } element(): HTMLElement { return this.elementInternal; } nodeElement(): HTMLElement { return this.nodeElementInternal; } appendChild(breadcrumb: AXBreadcrumb): void { this.children.push(breadcrumb); breadcrumb.setParent(this); this.nodeElementInternal.classList.add('parent'); UI.ARIAUtils.setExpanded(this.nodeElementInternal, true); this.childrenGroupElement.appendChild(breadcrumb.element()); } hasExpandedChildren(): number { return this.children.length; } setParent(breadcrumb: AXBreadcrumb): void { this.parent = breadcrumb; } preselected(): boolean { return this.preselectedInternal; } setPreselected(preselected: boolean, selectedByUser: boolean): void { if (this.preselectedInternal === preselected) { return; } this.preselectedInternal = preselected; this.nodeElementInternal.classList.toggle('preselected', preselected); if (preselected) { this.nodeElementInternal.tabIndex = 0; } else { this.nodeElementInternal.tabIndex = -1; } if (this.preselectedInternal) { if (selectedByUser) { this.nodeElementInternal.focus(); } if (!this.inspectedInternal) { this.axNodeInternal.highlightDOMNode(); } else { SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight(); } } } setHovered(hovered: boolean): void { if (this.hovered === hovered) { return; } this.hovered = hovered; this.nodeElementInternal.classList.toggle('hovered', hovered); if (this.hovered) { this.nodeElementInternal.classList.toggle('hovered', true); this.axNodeInternal.highlightDOMNode(); } } axNode(): SDK.AccessibilityModel.AccessibilityNode { return this.axNodeInternal; } inspected(): boolean { return this.inspectedInternal; } isDOMNode(): boolean { return this.axNodeInternal.isDOMNode(); } nextBreadcrumb(): AXBreadcrumb|null { if (this.children.length) { return this.children[0]; } const nextSibling = this.element().nextSibling; if (nextSibling) { return elementsToAXBreadcrumb.get(nextSibling as HTMLElement) || null; } return null; } previousBreadcrumb(): AXBreadcrumb|null { const previousSibling = this.element().previousSibling; if (previousSibling) { return elementsToAXBreadcrumb.get(previousSibling as HTMLElement) || null; } return this.parent; } parentBreadcrumb(): AXBreadcrumb|null { return this.parent; } lastChild(): AXBreadcrumb { return this.children[this.children.length - 1]; } private appendNameElement(name: string): void { const nameElement = document.createElement('span'); nameElement.textContent = '"' + name + '"'; nameElement.classList.add('ax-readable-string'); this.nodeWrapper.appendChild(nameElement); } private appendRoleElement(role: Protocol.Accessibility.AXValue|null): void { if (!role) { return; } const roleElement = document.createElement('span'); roleElement.classList.add('monospace'); roleElement.classList.add(RoleStyles[role.type]); roleElement.setTextContentTruncatedIfNeeded(role.value || ''); this.nodeWrapper.appendChild(roleElement); } private appendIgnoredNodeElement(): void { const ignoredNodeElement = document.createElement('span'); ignoredNodeElement.classList.add('monospace'); ignoredNodeElement.textContent = i18nString(UIStrings.ignored); ignoredNodeElement.classList.add('ax-breadcrumbs-ignored-node'); this.nodeWrapper.appendChild(ignoredNodeElement); } } type RoleStyles = { [type: string]: string, }; export const RoleStyles: RoleStyles = { internalRole: 'ax-internal-role', role: 'ax-role', };