chrome-devtools-frontend
Version:
Chrome DevTools UI
463 lines (426 loc) • 21.5 kB
text/typescript
// Copyright (c) 2015 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 type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {ElementsPanel} from './ElementsPanel.js';
import elementStatePaneWidgetStyles from './elementStatePaneWidget.css.js';
const UIStrings = {
/**
* @description Title of a section in the Element State Pane Widget of the Elements panel. The
* controls in this section allow users to force a particular state on the selected element, e.g. a
* focused state via :focus or a hover state via :hover.
*/
forceElementState: 'Force element state',
/**
* @description Tooltip text in Element State Pane Widget of the Elements panel. For a button that
* opens a tool that toggles the various states of the selected element on/off.
*/
toggleElementState: 'Toggle Element State',
/**
* @description The name of a checkbox setting in the Element & Page State Pane Widget of the Elements panel.. This setting
* emulates/pretends that the webpage is focused.
*/
emulateFocusedPage: 'Emulate a focused page',
/**
* @description Explanation text for the 'Emulate a focused page' setting in the Rendering tool.
*/
emulatesAFocusedPage: 'Keep page focused. Commonly used for debugging disappearing elements.',
/**
* @description Similar with forceElementState but allows users to force specific state of the selected element.
*/
forceElementSpecificStates: 'Force specific element state',
/**
*@description Text that is usually a hyperlink to more documentation
*/
learnMore: 'Learn more',
};
const str_ = i18n.i18n.registerUIStrings('panels/elements/ElementStatePaneWidget.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
enum SpecificPseudoStates {
ENABLED = 'enabled',
DISABLED = 'disabled',
VALID = 'valid',
INVALID = 'invalid',
USER_VALID = 'user-valid',
USER_INVALID = 'user-invalid',
REQUIRED = 'required',
OPTIONAL = 'optional',
READ_ONLY = 'read-only',
READ_WRITE = 'read-write',
IN_RANGE = 'in-range',
OUT_OF_RANGE = 'out-of-range',
VISITED = 'visited',
LINK = 'link',
CHECKED = 'checked',
INDETERMINATE = 'indeterminate',
PLACEHOLDER_SHOWN = 'placeholder-shown',
AUTOFILL = 'autofill',
OPEN = 'open',
}
export class ElementStatePaneWidget extends UI.Widget.Widget {
private readonly inputs: HTMLInputElement[];
private readonly inputStates: WeakMap<HTMLInputElement, string>;
private readonly duals: Map<SpecificPseudoStates, SpecificPseudoStates>;
private cssModel?: SDK.CSSModel.CSSModel|null;
private specificPseudoStateDivs: Map<SpecificPseudoStates, HTMLDivElement>;
private specificHeader: HTMLDetailsElement;
private readonly throttler: Common.Throttler.Throttler;
constructor() {
super(true);
this.registerRequiredCSS(elementStatePaneWidgetStyles);
this.contentElement.className = 'styles-element-state-pane';
this.contentElement.setAttribute('jslog', `${VisualLogging.pane('element-states')}`);
const inputs: HTMLInputElement[] = [];
this.inputs = inputs;
this.inputStates = new WeakMap();
this.duals = new Map();
const createSectionHeader = (title: string): HTMLDivElement => {
const sectionHeaderContainer = document.createElement('div');
sectionHeaderContainer.classList.add('section-header');
UI.UIUtils.createTextChild(sectionHeaderContainer.createChild('span'), title);
return sectionHeaderContainer;
};
const clickListener = (event: MouseEvent): void => {
const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
if (!node || !(event.target instanceof HTMLInputElement)) {
return;
}
const state = this.inputStates.get(event.target);
if (!state) {
return;
}
const checked = event.target.checked;
const dual = this.duals.get(state as SpecificPseudoStates);
if (checked && dual) {
node.domModel().cssModel().forcePseudoState(node, dual, false);
}
node.domModel().cssModel().forcePseudoState(node, state, checked);
};
const createElementStateCheckbox = (state: string): HTMLDivElement => {
const div = document.createElement('div');
div.id = state;
const label =
UI.UIUtils.CheckboxLabel.createWithStringLiteral(':' + state, undefined, undefined, undefined, true);
const input = label.checkboxElement;
this.inputStates.set(input, state);
input.addEventListener('click', (clickListener as EventListener), false);
input.setAttribute('jslog', `${VisualLogging.toggle().track({change: true}).context(state)}`);
inputs.push(input);
div.appendChild(label);
return div;
};
const setDualStateCheckboxes = (first: SpecificPseudoStates, second: SpecificPseudoStates): void => {
this.duals.set(first, second);
this.duals.set(second, first);
};
const createEmulateFocusedPageCheckbox = (): Element => {
const div = document.createElement('div');
div.classList.add('page-state-checkbox');
const label = UI.UIUtils.CheckboxLabel.create(
i18nString(UIStrings.emulateFocusedPage), undefined, undefined, 'emulate-page-focus', true);
UI.SettingsUI.bindCheckbox(
label.checkboxElement, Common.Settings.Settings.instance().moduleSetting('emulate-page-focus'), {
enable: Host.UserMetrics.Action.ToggleEmulateFocusedPageFromStylesPaneOn,
disable: Host.UserMetrics.Action.ToggleEmulateFocusedPageFromStylesPaneOff,
});
UI.Tooltip.Tooltip.install(label.textElement, i18nString(UIStrings.emulatesAFocusedPage));
const learnMoreButton = new Buttons.Button.Button();
learnMoreButton.data = {
variant: Buttons.Button.Variant.ICON,
iconName: 'help',
size: Buttons.Button.Size.SMALL,
jslogContext: 'learn-more',
title: i18nString(UIStrings.learnMore),
};
learnMoreButton.addEventListener(
'click',
() => Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(
'https://goo.gle/devtools-emulate-focused-page' as Platform.DevToolsPath.UrlString));
div.appendChild(label);
div.appendChild(learnMoreButton);
return div;
};
this.contentElement.className = 'styles-element-state-pane';
// Populate page states
const keepPageFocusedCheckbox = createEmulateFocusedPageCheckbox();
this.contentElement.appendChild(keepPageFocusedCheckbox);
// Populate element states
this.contentElement.appendChild(createSectionHeader(i18nString(UIStrings.forceElementState)));
const persistentContainer = document.createElement('div');
persistentContainer.classList.add('source-code');
persistentContainer.classList.add('pseudo-states-container');
UI.ARIAUtils.markAsPresentation(persistentContainer);
persistentContainer.appendChild(createElementStateCheckbox('active'));
persistentContainer.appendChild(createElementStateCheckbox('hover'));
persistentContainer.appendChild(createElementStateCheckbox('focus'));
persistentContainer.appendChild(createElementStateCheckbox('focus-within'));
persistentContainer.appendChild(createElementStateCheckbox('focus-visible'));
persistentContainer.appendChild(createElementStateCheckbox('target'));
this.contentElement.appendChild(persistentContainer);
const elementSpecificContainer = document.createElement('div');
elementSpecificContainer.classList.add('source-code');
elementSpecificContainer.classList.add('pseudo-states-container');
elementSpecificContainer.classList.add('specific-pseudo-states');
UI.ARIAUtils.markAsPresentation(elementSpecificContainer);
this.specificPseudoStateDivs = new Map<SpecificPseudoStates, HTMLDivElement>();
this.specificPseudoStateDivs.set(
SpecificPseudoStates.ENABLED, createElementStateCheckbox(SpecificPseudoStates.ENABLED));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.DISABLED, createElementStateCheckbox(SpecificPseudoStates.DISABLED));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.VALID, createElementStateCheckbox(SpecificPseudoStates.VALID));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.INVALID, createElementStateCheckbox(SpecificPseudoStates.INVALID));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.USER_VALID, createElementStateCheckbox(SpecificPseudoStates.USER_VALID));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.USER_INVALID, createElementStateCheckbox(SpecificPseudoStates.USER_INVALID));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.REQUIRED, createElementStateCheckbox(SpecificPseudoStates.REQUIRED));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.OPTIONAL, createElementStateCheckbox(SpecificPseudoStates.OPTIONAL));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.READ_ONLY, createElementStateCheckbox(SpecificPseudoStates.READ_ONLY));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.READ_WRITE, createElementStateCheckbox(SpecificPseudoStates.READ_WRITE));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.IN_RANGE, createElementStateCheckbox(SpecificPseudoStates.IN_RANGE));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.OUT_OF_RANGE, createElementStateCheckbox(SpecificPseudoStates.OUT_OF_RANGE));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.VISITED, createElementStateCheckbox(SpecificPseudoStates.VISITED));
this.specificPseudoStateDivs.set(SpecificPseudoStates.LINK, createElementStateCheckbox(SpecificPseudoStates.LINK));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.CHECKED, createElementStateCheckbox(SpecificPseudoStates.CHECKED));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.INDETERMINATE, createElementStateCheckbox(SpecificPseudoStates.INDETERMINATE));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.PLACEHOLDER_SHOWN, createElementStateCheckbox(SpecificPseudoStates.PLACEHOLDER_SHOWN));
this.specificPseudoStateDivs.set(
SpecificPseudoStates.AUTOFILL, createElementStateCheckbox(SpecificPseudoStates.AUTOFILL));
this.specificPseudoStateDivs.set(SpecificPseudoStates.OPEN, createElementStateCheckbox(SpecificPseudoStates.OPEN));
this.specificPseudoStateDivs.forEach(div => {
elementSpecificContainer.appendChild(div);
});
setDualStateCheckboxes(SpecificPseudoStates.VALID, SpecificPseudoStates.INVALID);
setDualStateCheckboxes(SpecificPseudoStates.USER_VALID, SpecificPseudoStates.USER_INVALID);
setDualStateCheckboxes(SpecificPseudoStates.READ_ONLY, SpecificPseudoStates.READ_WRITE);
setDualStateCheckboxes(SpecificPseudoStates.IN_RANGE, SpecificPseudoStates.OUT_OF_RANGE);
setDualStateCheckboxes(SpecificPseudoStates.ENABLED, SpecificPseudoStates.DISABLED);
setDualStateCheckboxes(SpecificPseudoStates.VISITED, SpecificPseudoStates.LINK);
this.specificHeader = document.createElement('details');
this.specificHeader.classList.add('specific-details');
const sectionHeaderContainer = document.createElement('summary');
sectionHeaderContainer.classList.add('force-specific-element-header');
sectionHeaderContainer.classList.add('section-header');
UI.UIUtils.createTextChild(
sectionHeaderContainer.createChild('span'), i18nString(UIStrings.forceElementSpecificStates));
this.specificHeader.appendChild(sectionHeaderContainer);
this.specificHeader.appendChild(elementSpecificContainer);
this.contentElement.appendChild(this.specificHeader);
this.throttler = new Common.Throttler.Throttler(100);
UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this.update, this);
}
private updateModel(cssModel: SDK.CSSModel.CSSModel|null): void {
if (this.cssModel === cssModel) {
return;
}
if (this.cssModel) {
this.cssModel.removeEventListener(SDK.CSSModel.Events.PseudoStateForced, this.update, this);
}
this.cssModel = cssModel;
if (this.cssModel) {
this.cssModel.addEventListener(SDK.CSSModel.Events.PseudoStateForced, this.update, this);
}
}
override wasShown(): void {
super.wasShown();
this.update();
}
update(): void {
let node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
if (node) {
node = node.enclosingElementOrSelf();
}
this.updateModel(node ? node.domModel().cssModel() : null);
if (node) {
const nodePseudoState = node.domModel().cssModel().pseudoState(node);
for (const input of this.inputs) {
input.disabled = Boolean(node.pseudoType());
const state = this.inputStates.get(input);
input.checked = nodePseudoState && state !== undefined ? nodePseudoState.indexOf(state) >= 0 : false;
}
} else {
for (const input of this.inputs) {
input.disabled = true;
input.checked = false;
}
}
void this.throttler.schedule(this.updateElementSpecificStatesTable.bind(this, node));
ButtonProvider.instance().item().setToggled(this.inputs.some(input => input.checked));
}
private async updateElementSpecificStatesTable(node: SDK.DOMModel.DOMNode|null = null): Promise<void> {
if (!node || node.nodeType() !== Node.ELEMENT_NODE) {
this.specificHeader.hidden = true;
this.updateElementSpecificStatesTableForTest();
return;
}
let showedACheckbox = false;
const hideSpecificCheckbox = (pseudoClass: SpecificPseudoStates, hide: boolean): void => {
const checkbox = this.specificPseudoStateDivs.get(pseudoClass);
if (checkbox) {
checkbox.hidden = hide;
}
showedACheckbox = showedACheckbox || !hide;
};
const isElementOfTypes = (node: SDK.DOMModel.DOMNode, types: string[]): boolean => {
return types.includes(node.nodeName()?.toLowerCase());
};
const isInputWithTypeRadioOrCheckbox = (node: SDK.DOMModel.DOMNode): boolean => {
return isElementOfTypes(node, ['input']) &&
(node.getAttribute('type') === 'checkbox' || node.getAttribute('type') === 'radio');
};
const isContentEditable = (node: SDK.DOMModel.DOMNode): boolean => {
return node.getAttribute('contenteditable') !== undefined ||
Boolean(node.parentNode && isContentEditable(node.parentNode));
};
const isDisabled = (node: SDK.DOMModel.DOMNode): boolean => {
return node.getAttribute('disabled') !== undefined;
};
const isMutable = (node: SDK.DOMModel.DOMNode): boolean => {
if (isElementOfTypes(node, ['input', 'textarea'])) {
return node.getAttribute('readonly') === undefined && !isDisabled(node);
}
return isContentEditable(node);
};
// An autonomous custom element is called a form-associated custom element if the element is associated with a custom element definition whose form-associated field is set to true.
// https://html.spec.whatwg.org/multipage/custom-elements.html#form-associated-custom-element
const isFormAssociatedCustomElement = async(node: SDK.DOMModel.DOMNode): Promise<boolean> => {
function getFormAssociatedField(this: HTMLElement): boolean {
return ('formAssociated' in this.constructor && this.constructor.formAssociated === true);
}
const response = await node.callFunction(getFormAssociatedField);
return response ? response.value : false;
};
const isFormAssociated = await isFormAssociatedCustomElement(node);
if (isElementOfTypes(node, ['button', 'input', 'select', 'textarea', 'optgroup', 'option', 'fieldset']) ||
isFormAssociated) {
hideSpecificCheckbox(SpecificPseudoStates.ENABLED, !isDisabled(node));
hideSpecificCheckbox(SpecificPseudoStates.DISABLED, isDisabled(node));
} else {
hideSpecificCheckbox(SpecificPseudoStates.ENABLED, true);
hideSpecificCheckbox(SpecificPseudoStates.DISABLED, true);
}
if (isElementOfTypes(node, ['button', 'fieldset', 'input', 'object', 'output', 'select', 'textarea', 'img']) ||
isFormAssociated) {
hideSpecificCheckbox(SpecificPseudoStates.VALID, false);
hideSpecificCheckbox(SpecificPseudoStates.INVALID, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.VALID, true);
hideSpecificCheckbox(SpecificPseudoStates.INVALID, true);
}
if (isElementOfTypes(node, ['input', 'select', 'textarea'])) {
hideSpecificCheckbox(SpecificPseudoStates.USER_VALID, false);
hideSpecificCheckbox(SpecificPseudoStates.USER_INVALID, false);
if (node.getAttribute('required') === undefined) {
hideSpecificCheckbox(SpecificPseudoStates.REQUIRED, false);
hideSpecificCheckbox(SpecificPseudoStates.OPTIONAL, true);
} else {
hideSpecificCheckbox(SpecificPseudoStates.REQUIRED, true);
hideSpecificCheckbox(SpecificPseudoStates.OPTIONAL, false);
}
} else {
hideSpecificCheckbox(SpecificPseudoStates.USER_VALID, true);
hideSpecificCheckbox(SpecificPseudoStates.USER_INVALID, true);
hideSpecificCheckbox(SpecificPseudoStates.REQUIRED, true);
hideSpecificCheckbox(SpecificPseudoStates.OPTIONAL, true);
}
if (isMutable(node)) {
hideSpecificCheckbox(SpecificPseudoStates.READ_WRITE, true);
hideSpecificCheckbox(SpecificPseudoStates.READ_ONLY, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.READ_WRITE, false);
hideSpecificCheckbox(SpecificPseudoStates.READ_ONLY, true);
}
if (isElementOfTypes(node, ['input']) &&
(node.getAttribute('min') !== undefined || node.getAttribute('max') !== undefined)) {
hideSpecificCheckbox(SpecificPseudoStates.IN_RANGE, false);
hideSpecificCheckbox(SpecificPseudoStates.OUT_OF_RANGE, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.IN_RANGE, true);
hideSpecificCheckbox(SpecificPseudoStates.OUT_OF_RANGE, true);
}
if (isElementOfTypes(node, ['a', 'area']) && node.getAttribute('href') !== undefined) {
hideSpecificCheckbox(SpecificPseudoStates.VISITED, false);
hideSpecificCheckbox(SpecificPseudoStates.LINK, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.VISITED, true);
hideSpecificCheckbox(SpecificPseudoStates.LINK, true);
}
if (isInputWithTypeRadioOrCheckbox(node) || isElementOfTypes(node, ['option'])) {
hideSpecificCheckbox(SpecificPseudoStates.CHECKED, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.CHECKED, true);
}
if (isInputWithTypeRadioOrCheckbox(node) || isElementOfTypes(node, ['progress'])) {
hideSpecificCheckbox(SpecificPseudoStates.INDETERMINATE, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.INDETERMINATE, true);
}
if (isElementOfTypes(node, ['input', 'textarea'])) {
hideSpecificCheckbox(SpecificPseudoStates.PLACEHOLDER_SHOWN, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.PLACEHOLDER_SHOWN, true);
}
if (isElementOfTypes(node, ['input'])) {
hideSpecificCheckbox(SpecificPseudoStates.AUTOFILL, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.AUTOFILL, true);
}
if (isElementOfTypes(node, ['input', 'select', 'dialog', 'details'])) {
hideSpecificCheckbox(SpecificPseudoStates.OPEN, false);
} else {
hideSpecificCheckbox(SpecificPseudoStates.OPEN, true);
}
this.specificHeader.hidden = showedACheckbox ? false : true;
this.updateElementSpecificStatesTableForTest();
}
updateElementSpecificStatesTableForTest(): void {
}
}
let buttonProviderInstance: ButtonProvider;
export class ButtonProvider implements UI.Toolbar.Provider {
private readonly button: UI.Toolbar.ToolbarToggle;
private view: ElementStatePaneWidget;
private constructor() {
this.button = new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.toggleElementState), 'hover');
this.button.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, this.clicked, this);
this.button.element.classList.add('element-state');
this.button.element.setAttribute('jslog', `${VisualLogging.toggleSubpane('element-states').track({click: true})}`);
this.button.element.style.setProperty('--dot-toggle-top', '12px');
this.button.element.style.setProperty('--dot-toggle-left', '18px');
this.view = new ElementStatePaneWidget();
}
static instance(opts: {
forceNew: boolean|null,
} = {forceNew: null}): ButtonProvider {
const {forceNew} = opts;
if (!buttonProviderInstance || forceNew) {
buttonProviderInstance = new ButtonProvider();
}
return buttonProviderInstance;
}
private clicked(): void {
ElementsPanel.instance().showToolbarPane(!this.view.isShowing() ? this.view : null, this.button);
}
item(): UI.Toolbar.ToolbarToggle {
return this.button;
}
}