chrome-devtools-frontend
Version:
Chrome DevTools UI
661 lines (598 loc) • 25.6 kB
text/typescript
/*
* Copyright (C) 2011 Google 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:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * 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.
* * Neither the name of Google Inc. 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 THE COPYRIGHT HOLDERS AND 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 THE COPYRIGHT
* OWNER OR 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.
*/
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as ARIAUtils from './ARIAUtils.js';
import {AnchorBehavior, GlassPane, MarginBehavior, PointerEventsBehavior, SizeBehavior} from './GlassPane.js';
import {InspectorView} from './InspectorView.js';
import softContextMenuStyles from './softContextMenu.css.js';
import {Tooltip} from './Tooltip.js';
import {createTextChild, ElementFocusRestorer} from './UIUtils.js';
const UIStrings = {
/**
*@description Text exposed to screen readers on checked items.
*/
checked: 'checked',
/**
*@description Accessible text exposed to screen readers when the screen reader encounters an unchecked checkbox.
*/
unchecked: 'unchecked',
/**
*@description Accessibility label for checkable SoftContextMenuItems with shortcuts
*@example {Open File} PH1
*@example {Ctrl + P} PH2
*@example {checked} PH3
*/
sSS: '{PH1}, {PH2}, {PH3}',
/**
*@description Generic text with two placeholders separated by a comma
*@example {1 613 680} PH1
*@example {44 %} PH2
*/
sS: '{PH1}, {PH2}',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/SoftContextMenu.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class SoftContextMenu {
private items: SoftContextMenuDescriptor[];
private itemSelectedCallback: (arg0: number) => void;
private parentMenu: SoftContextMenu|undefined;
private highlightedMenuItemElement: HTMLElement|null;
detailsForElementMap: WeakMap<HTMLElement, ElementMenuDetails>;
private document?: Document;
private glassPane?: GlassPane;
private contextMenuElement?: HTMLElement;
private focusRestorer?: ElementFocusRestorer;
private hideOnUserMouseDownUnlessInMenu?: ((event: Event) => void);
private activeSubMenuElement?: HTMLElement;
private subMenu?: SoftContextMenu;
private onMenuClosed?: () => void;
private focusOnTheFirstItem = true;
private keepOpen: boolean;
private loggableParent: Element|null;
constructor(
items: SoftContextMenuDescriptor[], itemSelectedCallback: (arg0: number) => void, keepOpen: boolean,
parentMenu?: SoftContextMenu, onMenuClosed?: () => void, loggableParent?: Element|null) {
this.items = items;
this.itemSelectedCallback = itemSelectedCallback;
this.parentMenu = parentMenu;
this.highlightedMenuItemElement = null;
this.detailsForElementMap = new WeakMap();
this.onMenuClosed = onMenuClosed;
this.keepOpen = keepOpen;
this.loggableParent = loggableParent || null;
}
getItems(): SoftContextMenuDescriptor[] {
return this.items;
}
show(document: Document, anchorBox: AnchorBox): void {
if (!this.items.length) {
return;
}
this.document = document;
this.glassPane = new GlassPane();
this.glassPane.setPointerEventsBehavior(
this.parentMenu ? PointerEventsBehavior.PIERCE_GLASS_PANE : PointerEventsBehavior.BLOCKED_BY_GLASS_PANE);
this.glassPane.registerRequiredCSS(softContextMenuStyles);
this.glassPane.setContentAnchorBox(anchorBox);
this.glassPane.setSizeBehavior(SizeBehavior.MEASURE_CONTENT);
this.glassPane.setMarginBehavior(MarginBehavior.NO_MARGIN);
this.glassPane.setAnchorBehavior(this.parentMenu ? AnchorBehavior.PREFER_RIGHT : AnchorBehavior.PREFER_BOTTOM);
this.contextMenuElement = this.glassPane.contentElement.createChild('div', 'soft-context-menu');
this.contextMenuElement.setAttribute('jslog', `${VisualLogging.menu().track({resize: true}).parent('mapped').track({
keydown: 'ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Enter|Space|Escape',
})}`);
if (this.loggableParent) {
VisualLogging.setMappedParent(this.contextMenuElement, this.loggableParent);
}
this.contextMenuElement.tabIndex = -1;
ARIAUtils.markAsMenu(this.contextMenuElement);
this.contextMenuElement.addEventListener('mouseup', e => e.consume(), false);
this.contextMenuElement.addEventListener('keydown', this.menuKeyDown.bind(this), false);
const menuContainsCheckbox = this.items.find(item => item.type === 'checkbox') ? true : false;
for (let i = 0; i < this.items.length; ++i) {
this.contextMenuElement.appendChild(this.createMenuItem(this.items[i], menuContainsCheckbox));
}
this.glassPane.show(document);
this.focusRestorer = new ElementFocusRestorer(this.contextMenuElement);
if (!this.parentMenu) {
this.hideOnUserMouseDownUnlessInMenu = (event: Event) => {
// If a user clicks on any submenu, prevent the menu system from closing.
let subMenu: (SoftContextMenu|undefined) = this.subMenu;
while (subMenu) {
if (subMenu.contextMenuElement === event.composedPath()[0]) {
return;
}
subMenu = subMenu.subMenu;
}
this.discard();
event.consume(true);
};
this.document.body.addEventListener('mousedown', this.hideOnUserMouseDownUnlessInMenu, false);
// To reliably get resize events when 1) the browser window is resized,
// 2) DevTools is undocked and resized and 3) DevTools is docked &
// resized, we have to use ResizeObserver.
const devToolsElem = InspectorView.maybeGetInspectorViewInstance()?.element;
if (devToolsElem) {
// The resize-observer will fire immediately upon starting observation.
// So we have to ignore that first fire, and then the moment we get a
// second, we know that it's been resized so we can act accordingly.
let firedOnce = false;
const observer = new ResizeObserver(() => {
if (firedOnce) {
observer.disconnect();
this.discard();
return;
}
firedOnce = true;
});
observer.observe(devToolsElem);
}
// focus on the first menu item
if (this.contextMenuElement.children && this.focusOnTheFirstItem) {
const focusElement = this.contextMenuElement.children[0] as HTMLElement;
this.highlightMenuItem(focusElement, /* scheduleSubMenu */ false);
}
}
}
setContextMenuElementLabel(label: string): void {
if (this.contextMenuElement) {
ARIAUtils.setLabel(this.contextMenuElement, label);
}
}
discard(): void {
if (this.subMenu) {
this.subMenu.discard();
}
if (this.focusRestorer) {
this.focusRestorer.restore();
}
if (this.glassPane) {
this.glassPane.hide();
delete this.glassPane;
if (this.hideOnUserMouseDownUnlessInMenu) {
if (this.document) {
this.document.body.removeEventListener('mousedown', this.hideOnUserMouseDownUnlessInMenu, false);
}
delete this.hideOnUserMouseDownUnlessInMenu;
}
}
if (this.parentMenu) {
delete this.parentMenu.subMenu;
if (this.parentMenu.activeSubMenuElement) {
ARIAUtils.setExpanded(this.parentMenu.activeSubMenuElement, false);
delete this.parentMenu.activeSubMenuElement;
}
}
this.onMenuClosed?.();
}
private createMenuItem(item: SoftContextMenuDescriptor, menuContainsCheckbox: boolean): HTMLElement {
if (item.type === 'separator') {
return this.createSeparator();
}
if (item.type === 'subMenu') {
return this.createSubMenu(item, menuContainsCheckbox);
}
const menuItemElement = document.createElement('div');
menuItemElement.classList.add('soft-context-menu-item');
menuItemElement.tabIndex = -1;
ARIAUtils.markAsMenuItem(menuItemElement);
if (item.checked) {
menuItemElement.setAttribute('checked', '');
}
if (item.id !== undefined) {
menuItemElement.setAttribute('data-action-id', item.id.toString());
}
// If the menu contains a checkbox, add checkbox space in front of the label to align the items
if (menuContainsCheckbox) {
const checkMarkElement = IconButton.Icon.create('checkmark', 'checkmark');
menuItemElement.appendChild(checkMarkElement);
}
if (item.tooltip) {
Tooltip.install(menuItemElement, item.tooltip);
}
const detailsForElement: ElementMenuDetails = {
actionId: undefined,
isSeparator: undefined,
customElement: undefined,
subItems: undefined,
subMenuTimer: undefined,
};
if (item.jslogContext && !item.element?.hasAttribute('jslog')) {
if (item.type === 'checkbox') {
menuItemElement.setAttribute(
'jslog', `${VisualLogging.toggle().track({click: true}).context(item.jslogContext)}`);
} else {
menuItemElement.setAttribute(
'jslog', `${VisualLogging.action().track({click: true}).context(item.jslogContext)}`);
}
}
if (item.element && !item.label) {
const wrapper = menuItemElement.createChild('div', 'soft-context-menu-custom-item');
wrapper.appendChild(item.element);
if (item.element?.classList.contains('location-menu')) {
const label = item.element.ariaLabel || '';
item.element.ariaLabel = '';
ARIAUtils.setLabel(menuItemElement, label);
}
detailsForElement.customElement = (item.element as HTMLElement);
this.detailsForElementMap.set(menuItemElement, detailsForElement);
return menuItemElement;
}
if (!item.enabled) {
menuItemElement.classList.add('soft-context-menu-disabled');
}
createTextChild(menuItemElement, item.label || '');
if (item.element) {
menuItemElement.appendChild(item.element);
}
menuItemElement.createChild('span', 'soft-context-menu-shortcut').textContent = item.shortcut || '';
menuItemElement.addEventListener('mousedown', this.menuItemMouseDown.bind(this), false);
menuItemElement.addEventListener('mouseup', this.menuItemMouseUp.bind(this), false);
// Manually manage hover highlight since :hover does not work in case of click-and-hold menu invocation.
menuItemElement.addEventListener('mouseover', this.menuItemMouseOver.bind(this), false);
menuItemElement.addEventListener('mouseleave', (this.menuItemMouseLeave.bind(this) as EventListener), false);
detailsForElement.actionId = item.id;
let accessibleName: Platform.UIString.LocalizedString|string = item.label || '';
if (item.type === 'checkbox') {
const checkedState = item.checked ? i18nString(UIStrings.checked) : i18nString(UIStrings.unchecked);
if (item.shortcut) {
accessibleName = i18nString(UIStrings.sSS, {PH1: String(item.label), PH2: item.shortcut, PH3: checkedState});
} else {
accessibleName = i18nString(UIStrings.sS, {PH1: String(item.label), PH2: checkedState});
}
} else if (item.shortcut) {
accessibleName = i18nString(UIStrings.sS, {PH1: String(item.label), PH2: item.shortcut});
}
ARIAUtils.setLabel(menuItemElement, accessibleName);
this.detailsForElementMap.set(menuItemElement, detailsForElement);
return menuItemElement;
}
private createSubMenu(item: SoftContextMenuDescriptor, menuContainsCheckbox: boolean): HTMLElement {
const menuItemElement = document.createElement('div');
menuItemElement.classList.add('soft-context-menu-item');
menuItemElement.tabIndex = -1;
ARIAUtils.markAsMenuItemSubMenu(menuItemElement);
this.detailsForElementMap.set(menuItemElement, {
subItems: item.subItems,
actionId: undefined,
isSeparator: undefined,
customElement: undefined,
subMenuTimer: undefined,
});
// If the menu contains a checkbox, add checkbox space in front of the label to align the items
if (menuContainsCheckbox) {
const checkMarkElement = IconButton.Icon.create('checkmark', 'checkmark soft-context-menu-item-checkmark');
menuItemElement.appendChild(checkMarkElement);
}
createTextChild(menuItemElement, item.label || '');
ARIAUtils.setExpanded(menuItemElement, false);
const subMenuArrowElement = IconButton.Icon.create('keyboard-arrow-right', 'soft-context-menu-item-submenu-arrow');
menuItemElement.appendChild(subMenuArrowElement);
menuItemElement.addEventListener('mousedown', this.menuItemMouseDown.bind(this), false);
menuItemElement.addEventListener('mouseup', this.menuItemMouseUp.bind(this), false);
// Manually manage hover highlight since :hover does not work in case of click-and-hold menu invocation.
menuItemElement.addEventListener('mouseover', this.menuItemMouseOver.bind(this), false);
menuItemElement.addEventListener('mouseleave', (this.menuItemMouseLeave.bind(this) as EventListener), false);
if (item.jslogContext) {
menuItemElement.setAttribute('jslog', `${VisualLogging.item().context(item.jslogContext)}`);
}
return menuItemElement;
}
private createSeparator(): HTMLElement {
const separatorElement = document.createElement('div');
separatorElement.classList.add('soft-context-menu-separator');
this.detailsForElementMap.set(separatorElement, {
subItems: undefined,
actionId: undefined,
isSeparator: true,
customElement: undefined,
subMenuTimer: undefined,
});
separatorElement.createChild('div', 'separator-line');
return separatorElement;
}
private menuItemMouseDown(event: Event): void {
// Do not let separator's mouse down hit menu's handler - we need to receive mouse up!
event.consume(true);
}
private menuItemMouseUp(event: Event): void {
this.triggerAction((event.target as HTMLElement), event);
void VisualLogging.logClick(event.target as HTMLElement, event);
event.consume();
}
private root(): SoftContextMenu {
let root: SoftContextMenu = this;
while (root.parentMenu) {
root = root.parentMenu;
}
return root;
}
setChecked(item: SoftContextMenuDescriptor, checked: boolean): void {
item.checked = checked;
const element = this.contextMenuElement?.querySelector(`[data-action-id="${item.id}"]`);
if (!element) {
return;
}
if (checked) {
element.setAttribute('checked', '');
} else {
element.removeAttribute('checked');
}
const checkedState = item.checked ? i18nString(UIStrings.checked) : i18nString(UIStrings.unchecked);
const accessibleName = item.shortcut ?
i18nString(UIStrings.sSS, {PH1: String(item.label), PH2: item.shortcut, PH3: checkedState}) :
i18nString(UIStrings.sS, {PH1: String(item.label), PH2: checkedState});
ARIAUtils.setLabel(element, accessibleName);
}
private triggerAction(menuItemElement: HTMLElement, event: Event): void {
const detailsForElement = this.detailsForElementMap.get(menuItemElement);
if (!detailsForElement || detailsForElement.subItems) {
this.showSubMenu(menuItemElement);
event.consume();
return;
}
if (this.keepOpen) {
event.consume(true);
const item = this.items.find(item => item.id === detailsForElement.actionId);
if (item?.id !== undefined) {
this.setChecked(item, !item.checked);
this.itemSelectedCallback(item.id);
}
return;
}
this.root().discard();
event.consume(true);
if (typeof detailsForElement.actionId !== 'undefined') {
this.itemSelectedCallback(detailsForElement.actionId);
delete detailsForElement.actionId;
}
return;
}
private showSubMenu(menuItemElement: HTMLElement): void {
const detailsForElement = this.detailsForElementMap.get(menuItemElement);
if (!detailsForElement) {
return;
}
if (detailsForElement.subMenuTimer) {
window.clearTimeout(detailsForElement.subMenuTimer);
delete detailsForElement.subMenuTimer;
}
if (this.subMenu || !this.document) {
return;
}
this.activeSubMenuElement = menuItemElement;
ARIAUtils.setExpanded(menuItemElement, true);
if (!detailsForElement.subItems) {
return;
}
this.subMenu = new SoftContextMenu(detailsForElement.subItems, this.itemSelectedCallback, false, this);
const anchorBox = menuItemElement.boxInWindow();
// Adjust for padding.
anchorBox.y -= 9;
anchorBox.x += 3;
anchorBox.width -= 6;
anchorBox.height += 18;
this.subMenu.show(this.document, anchorBox);
}
private menuItemMouseOver(event: Event): void {
this.highlightMenuItem((event.target as HTMLElement), true);
}
private menuItemMouseLeave(event: MouseEvent): void {
if (!this.subMenu || !event.relatedTarget) {
this.highlightMenuItem(null, true);
return;
}
const relatedTarget = event.relatedTarget;
if (relatedTarget === this.contextMenuElement) {
this.highlightMenuItem(null, true);
}
}
private highlightMenuItem(menuItemElement: HTMLElement|null, scheduleSubMenu: boolean): void {
if (this.highlightedMenuItemElement === menuItemElement) {
return;
}
if (this.subMenu) {
this.subMenu.discard();
}
if (this.highlightedMenuItemElement) {
const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
this.highlightedMenuItemElement.classList.remove('force-white-icons');
this.highlightedMenuItemElement.classList.remove('soft-context-menu-item-mouse-over');
if (detailsForElement?.subItems && detailsForElement.subMenuTimer) {
window.clearTimeout(detailsForElement.subMenuTimer);
delete detailsForElement.subMenuTimer;
}
}
this.highlightedMenuItemElement = menuItemElement;
if (this.highlightedMenuItemElement) {
this.highlightedMenuItemElement.classList.add('force-white-icons');
this.highlightedMenuItemElement.classList.add('soft-context-menu-item-mouse-over');
const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
if (detailsForElement?.customElement && !detailsForElement.customElement.classList.contains('location-menu')) {
detailsForElement.customElement.focus();
} else {
this.highlightedMenuItemElement.focus();
}
if (scheduleSubMenu && detailsForElement?.subItems && !detailsForElement.subMenuTimer) {
detailsForElement.subMenuTimer =
window.setTimeout(this.showSubMenu.bind(this, this.highlightedMenuItemElement), 150);
}
}
if (this.contextMenuElement) {
ARIAUtils.setActiveDescendant(this.contextMenuElement, menuItemElement);
}
}
private highlightPrevious(): void {
let menuItemElement: (ChildNode|null) = this.highlightedMenuItemElement ?
this.highlightedMenuItemElement.previousSibling :
this.contextMenuElement ? this.contextMenuElement.lastChild :
null;
let menuItemDetails: (ElementMenuDetails|undefined) =
menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
while (menuItemElement && menuItemDetails &&
(menuItemDetails.isSeparator ||
(menuItemElement as HTMLElement).classList.contains('soft-context-menu-disabled'))) {
menuItemElement = menuItemElement.previousSibling;
menuItemDetails = menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
}
if (menuItemElement) {
this.highlightMenuItem((menuItemElement as HTMLElement), false);
}
}
private highlightNext(): void {
let menuItemElement: (ChildNode|null) = this.highlightedMenuItemElement ?
this.highlightedMenuItemElement.nextSibling :
this.contextMenuElement ? this.contextMenuElement.firstChild :
null;
let menuItemDetails: (ElementMenuDetails|undefined) =
menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
while (menuItemElement &&
(menuItemDetails?.isSeparator ||
(menuItemElement as HTMLElement).classList.contains('soft-context-menu-disabled'))) {
menuItemElement = menuItemElement.nextSibling;
menuItemDetails = menuItemElement ? this.detailsForElementMap.get((menuItemElement as HTMLElement)) : undefined;
}
if (menuItemElement) {
this.highlightMenuItem((menuItemElement as HTMLElement), false);
}
}
private menuKeyDown(keyboardEvent: KeyboardEvent): void {
function onEnterOrSpace(this: SoftContextMenu): void {
if (!this.highlightedMenuItemElement) {
return;
}
const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
if (!detailsForElement || detailsForElement.customElement) {
// The custom element will handle the event, so return early and do not consume it.
return;
}
VisualLogging.logClick(this.highlightedMenuItemElement, keyboardEvent);
this.triggerAction(this.highlightedMenuItemElement, keyboardEvent);
if (detailsForElement.subItems && this.subMenu) {
this.subMenu.highlightNext();
}
keyboardEvent.consume(true);
}
switch (keyboardEvent.key) {
case 'ArrowUp':
this.highlightPrevious();
keyboardEvent.consume(true);
break;
case 'ArrowDown':
this.highlightNext();
keyboardEvent.consume(true);
break;
case 'ArrowLeft':
if (this.parentMenu) {
this.highlightMenuItem(null, false);
this.discard();
}
keyboardEvent.consume(true);
break;
case 'ArrowRight': {
if (!this.highlightedMenuItemElement) {
break;
}
const detailsForElement = this.detailsForElementMap.get(this.highlightedMenuItemElement);
if (detailsForElement?.subItems) {
this.showSubMenu(this.highlightedMenuItemElement);
if (this.subMenu) {
this.subMenu.highlightNext();
}
}
if (detailsForElement?.customElement?.classList.contains('location-menu')) {
detailsForElement.customElement.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'}));
this.highlightMenuItem(null, true);
}
keyboardEvent.consume(true);
break;
}
case 'Escape':
this.discard();
keyboardEvent.consume(true);
break;
/**
* Important: we don't consume the event by default for `Enter` or `Space`
* key events, as if there's a custom sub menu we pass the event onto
* that.
*/
case 'Enter':
if (!(keyboardEvent.key === 'Enter')) {
return;
}
onEnterOrSpace.call(this);
break;
case ' ':
onEnterOrSpace.call(this);
break;
default:
keyboardEvent.consume(true);
}
}
markAsMenuItemCheckBox(): void {
if (!this.contextMenuElement) {
return;
}
for (const child of this.contextMenuElement.children) {
if (child.className !== 'soft-context-menu-separator') {
ARIAUtils.markAsMenuItemCheckBox(child);
}
}
}
setFocusOnTheFirstItem(focusOnTheFirstItem: boolean): void {
this.focusOnTheFirstItem = focusOnTheFirstItem;
}
}
export interface SoftContextMenuDescriptor {
type: 'checkbox'|'item'|'separator'|'subMenu';
id?: number;
label?: string;
accelerator?: {keyCode: number, modifiers: number};
isExperimentalFeature?: boolean;
enabled?: boolean;
checked?: boolean;
isDevToolsPerformanceMenuItem?: boolean;
subItems?: SoftContextMenuDescriptor[];
element?: Element;
shortcut?: string;
tooltip?: Platform.UIString.LocalizedString;
jslogContext?: string;
}
interface ElementMenuDetails {
customElement?: HTMLElement;
isSeparator?: boolean;
subMenuTimer?: number;
subItems?: SoftContextMenuDescriptor[];
actionId?: number;
}