An adorner to force-open popovers
Show an adorner for popover elements. When clicked, the popover is
forced open and can only be closed by toggling the adorner again. In
particular, this will prevent closing the popover by the page via
javascript or events.
Fixed: 413580647
Change-Id: Ia1c9b727b48adaebd0e66fae0c4cac4cc5f827bf
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6488092
Commit-Queue: Philip Pfaffe <[email protected]>
Reviewed-by: Changhao Han <[email protected]>
diff --git a/front_end/core/root/Runtime.ts b/front_end/core/root/Runtime.ts
index 6c19a8e..62086dc 100644
--- a/front_end/core/root/Runtime.ts
+++ b/front_end/core/root/Runtime.ts
@@ -437,6 +437,10 @@
enabled: boolean;
}
+interface AllowPopoverForcing {
+ enabled: boolean;
+}
+
/**
* The host configuration that we expect from the DevTools back-end.
*
@@ -471,6 +475,7 @@
devToolsAnimationStylesInStylesTab: HostConfigAnimationStylesInStylesTab,
thirdPartyCookieControls: HostConfigThirdPartyCookieControls,
devToolsAiGeneratedTimelineLabels: AiGeneratedTimelineLabels,
+ devToolsAllowPopoverForcing: AllowPopoverForcing,
}>;
/**
diff --git a/front_end/panels/elements/ElementsTreeElement.test.ts b/front_end/panels/elements/ElementsTreeElement.test.ts
index 3a9d774..083a6a0 100644
--- a/front_end/panels/elements/ElementsTreeElement.test.ts
+++ b/front_end/panels/elements/ElementsTreeElement.test.ts
@@ -2,7 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import * as SDK from '../../core/sdk/sdk.js';
+import type * as Protocol from '../../generated/protocol.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
+import {createTarget, updateHostConfig} from '../../testing/EnvironmentHelpers.js';
+import {spyCall} from '../../testing/ExpectStubCall.js';
+import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js';
import * as Elements from './elements.js';
@@ -40,3 +45,75 @@
});
});
});
+
+describeWithMockConnection('ElementsTreeElement', () => {
+ let nodeIdCounter = 0;
+ function getTreeElement(model: SDK.DOMModel.DOMModel, treeOutline: Elements.ElementsTreeOutline.ElementsTreeOutline) {
+ const node = new SDK.DOMModel.DOMNode(model);
+ node.id = nodeIdCounter++ as Protocol.DOM.NodeId;
+ model.registerNode(node);
+ const treeElement = new Elements.ElementsTreeElement.ElementsTreeElement(node);
+ node.setAttributesPayload(['popover', 'manual']);
+ treeOutline.bindTreeElement(treeElement);
+ return treeElement;
+ }
+
+ async function getAdorner(treeElement: Elements.ElementsTreeElement.ElementsTreeElement) {
+ await treeElement.updateStyleAdorners();
+ const {tagTypeContext} = treeElement;
+ const adorners = 'adorners' in tagTypeContext ? tagTypeContext.adorners : undefined;
+ assert.exists(adorners);
+ assert.lengthOf(adorners, 1);
+ const {value} = adorners.values().next();
+ assert.exists(value);
+ assert.strictEqual(value.name, 'popover');
+ return value;
+ }
+
+ beforeEach(() => {
+ updateHostConfig({devToolsAllowPopoverForcing: {enabled: true}});
+ setMockConnectionResponseHandler('CSS.enable', () => ({}));
+ setMockConnectionResponseHandler('CSS.getComputedStyleForNode', () => ({}));
+ });
+ it('popoverAdorner supports force-opening popovers', async () => {
+ const model = new SDK.DOMModel.DOMModel(createTarget());
+
+ const responseHandlerStub = sinon.stub<[Protocol.DOM.ForceShowPopoverRequest]>();
+ setMockConnectionResponseHandler('DOM.forceShowPopover', responseHandlerStub);
+
+ const treeElement = getTreeElement(model, new Elements.ElementsTreeOutline.ElementsTreeOutline());
+ const adorner = await getAdorner(treeElement);
+ adorner.dispatchEvent(new MouseEvent('click'));
+ sinon.assert.calledOnce(responseHandlerStub);
+ assert.isTrue(responseHandlerStub.args[0][0].enable);
+ assert.strictEqual(responseHandlerStub.args[0][0].nodeId, treeElement.node().id);
+
+ adorner.dispatchEvent(new MouseEvent('click'));
+ sinon.assert.calledTwice(responseHandlerStub);
+ assert.isFalse(responseHandlerStub.args[1][0].enable);
+ assert.strictEqual(responseHandlerStub.args[1][0].nodeId, treeElement.node().id);
+ });
+
+ it('popoverAdorner gets toggled off when a popover is force-closed by another forceShowPopover call', async () => {
+ const model = new SDK.DOMModel.DOMModel(createTarget());
+ const treeOutline = new Elements.ElementsTreeOutline.ElementsTreeOutline();
+ const treeElement1 = getTreeElement(model, treeOutline);
+ const treeElement2 = getTreeElement(model, treeOutline);
+
+ const adorner1 = await getAdorner(treeElement1);
+ const adorner2 = await getAdorner(treeElement2);
+
+ setMockConnectionResponseHandler(
+ 'DOM.forceShowPopover', () => ({nodeIds: adorner2.isActive() ? [treeElement2.node().id] : []}));
+
+ adorner2.dispatchEvent(new MouseEvent('click'));
+ assert.isTrue(adorner2.isActive());
+
+ const toggleStub = spyCall(adorner2, 'toggle');
+
+ adorner1.dispatchEvent(new MouseEvent('click'));
+ await toggleStub;
+ assert.isTrue(adorner1.isActive());
+ assert.isFalse(adorner2.isActive());
+ });
+});
diff --git a/front_end/panels/elements/ElementsTreeElement.ts b/front_end/panels/elements/ElementsTreeElement.ts
index 1fa50e5..2155f01 100644
--- a/front_end/panels/elements/ElementsTreeElement.ts
+++ b/front_end/panels/elements/ElementsTreeElement.ts
@@ -37,6 +37,7 @@
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 Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import type * as Elements from '../../models/elements/elements.js';
@@ -185,6 +186,14 @@
*/
disableGridMode: 'Disable grid mode',
/**
+ * @description ARIA label for an elements tree adorner
+ */
+ forceOpenPopover: 'Keep this popover open',
+ /**
+ * @description ARIA label for an elements tree adorner
+ */
+ stopForceOpenPopover: 'Stop keeping this popover open',
+ /**
*@description Label of the adorner for flex elements in the Elements panel
*/
enableFlexMode: 'Enable flex mode',
@@ -2269,26 +2278,60 @@
for (const styleAdorner of this.tagTypeContext.styleAdorners) {
this.removeAdorner(styleAdorner, this.tagTypeContext);
}
- if (!layout) {
- return;
- }
-
- if (layout.isGrid) {
- this.pushGridAdorner(this.tagTypeContext, layout.isSubgrid);
- }
- if (layout.isFlex) {
- this.pushFlexAdorner(this.tagTypeContext);
- }
- if (layout.hasScroll) {
- this.pushScrollSnapAdorner(this.tagTypeContext);
- }
- if (layout.isContainer) {
- this.pushContainerAdorner(this.tagTypeContext);
+ if (layout) {
+ if (layout.isGrid) {
+ this.pushGridAdorner(this.tagTypeContext, layout.isSubgrid);
+ }
+ if (layout.isFlex) {
+ this.pushFlexAdorner(this.tagTypeContext);
+ }
+ if (layout.hasScroll) {
+ this.pushScrollSnapAdorner(this.tagTypeContext);
+ }
+ if (layout.isContainer) {
+ this.pushContainerAdorner(this.tagTypeContext);
+ }
}
if (node.isMediaNode()) {
this.pushMediaAdorner(this.tagTypeContext);
}
+
+ if (node.attributes().find(attr => attr.name === 'popover')) {
+ this.pushPopoverAdorner(this.tagTypeContext);
+ }
+ }
+
+ pushPopoverAdorner(context: OpeningTagContext): void {
+ if (!Root.Runtime.hostConfig.devToolsAllowPopoverForcing?.enabled) {
+ return;
+ }
+ const node = this.node();
+ const nodeId = node.id;
+
+ const config = ElementsComponents.AdornerManager.getRegisteredAdorner(
+ ElementsComponents.AdornerManager.RegisteredAdorners.POPOVER);
+ const adorner = this.adorn(config);
+ const onClick = async(): Promise<void> => {
+ const {nodeIds} = await node.domModel().agent.invoke_forceShowPopover({nodeId, enable: adorner.isActive()});
+ for (const closedPopoverNodeId of nodeIds) {
+ const node = this.node().domModel().nodeForId(closedPopoverNodeId);
+ const treeElement = node && this.treeOutline?.treeElementByNode.get(node);
+ if (!treeElement || !isOpeningTag(treeElement.tagTypeContext)) {
+ return;
+ }
+ const adorner = treeElement.tagTypeContext.adorners.values().find(adorner => adorner.name === config.name);
+ adorner?.toggle(false);
+ }
+ };
+ adorner.addInteraction(onClick, {
+ isToggle: true,
+ shouldPropagateOnKeydown: false,
+ ariaLabelDefault: i18nString(UIStrings.forceOpenPopover),
+ ariaLabelActive: i18nString(UIStrings.stopForceOpenPopover),
+ });
+
+ context.styleAdorners.add(adorner);
}
pushGridAdorner(context: OpeningTagContext, isSubgrid: boolean): void {
diff --git a/front_end/panels/elements/components/AdornerManager.ts b/front_end/panels/elements/components/AdornerManager.ts
index 2de0c02..47ce50d 100644
--- a/front_end/panels/elements/components/AdornerManager.ts
+++ b/front_end/panels/elements/components/AdornerManager.ts
@@ -33,6 +33,7 @@
REVEAL = 'reveal',
MEDIA = 'media',
SCROLL = 'scroll',
+ POPOVER = 'popover',
}
// This enum-like const object serves as the authoritative registry for all the
@@ -105,6 +106,13 @@
category: AdornerCategories.LAYOUT,
enabledByDefault: true,
};
+ case RegisteredAdorners.POPOVER: {
+ return {
+ name: 'popover',
+ category: AdornerCategories.LAYOUT,
+ enabledByDefault: true,
+ };
+ }
}
}
diff --git a/front_end/ui/visual_logging/KnownContextValues.ts b/front_end/ui/visual_logging/KnownContextValues.ts
index d81e5b6..3e2d566 100644
--- a/front_end/ui/visual_logging/KnownContextValues.ts
+++ b/front_end/ui/visual_logging/KnownContextValues.ts
@@ -2761,6 +2761,7 @@
'pointerover',
'pointerrawupdate',
'pointerup',
+ 'popover',
'popover-hide-delay',
'popover-show-delay',
'popstate',