diff --git a/all_devtools_modules.gni b/all_devtools_modules.gni
index 25757ceb43..d5c4aba8f5 100644
--- a/all_devtools_modules.gni
+++ b/all_devtools_modules.gni
@@ -757,6 +757,7 @@ all_typescript_module_sources = [
"ui/components/DataGridController.js",
"ui/components/DataGridRenderers.js",
"ui/components/DataGridUtils.js",
+ "ui/components/Linkifier.js",
"ui/components/components.js",
"ui/utils/append-style.js",
"ui/utils/create-shadow-root-with-core-styles.js",
diff --git a/devtools_grd_files.gni b/devtools_grd_files.gni
index 19acada63d..ca8c6507f0 100644
--- a/devtools_grd_files.gni
+++ b/devtools_grd_files.gni
@@ -1140,6 +1140,7 @@ grd_files_debug_sources = [
"front_end/ui/components/DataGridController.js",
"front_end/ui/components/DataGridRenderers.js",
"front_end/ui/components/DataGridUtils.js",
+ "front_end/ui/components/Linkifier.js",
"front_end/ui/utils/append-style.js",
"front_end/ui/utils/create-shadow-root-with-core-styles.js",
"front_end/ui/utils/focus-changed.js",
diff --git a/front_end/component_docs/BUILD.gn b/front_end/component_docs/BUILD.gn
index a36a1edde0..e224b2198b 100644
--- a/front_end/component_docs/BUILD.gn
+++ b/front_end/component_docs/BUILD.gn
@@ -17,6 +17,7 @@ group("component_docs") {
"./issue_survey_link",
"./layout_pane",
"./linear_memory_inspector",
+ "./linkifier",
"./perf_piechart",
"./theme_colors",
"./webvitals",
diff --git a/front_end/component_docs/linkifier/BUILD.gn b/front_end/component_docs/linkifier/BUILD.gn
new file mode 100644
index 0000000000..cd17369a0a
--- /dev/null
+++ b/front_end/component_docs/linkifier/BUILD.gn
@@ -0,0 +1,14 @@
+# 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("../../../scripts/build/ninja/copy.gni")
+
+copy_to_gen("linkifier") {
+ sources = [ "url.html" ]
+
+ deps = [
+ "../../component_helpers:bundle",
+ "../../ui/components:bundle",
+ ]
+}
diff --git a/front_end/component_docs/linkifier/url.html b/front_end/component_docs/linkifier/url.html
new file mode 100644
index 0000000000..ae55ab9c7f
--- /dev/null
+++ b/front_end/component_docs/linkifier/url.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ Linkifier example with URL
+
+
+
+
+
+
+
+
+
+
diff --git a/front_end/components/Linkifier.js b/front_end/components/Linkifier.js
index 87c64a4150..8dbb98e5be 100644
--- a/front_end/components/Linkifier.js
+++ b/front_end/components/Linkifier.js
@@ -744,6 +744,14 @@ export class Linkifier {
return Linkifier.invokeFirstAction(linkInfo);
}
+ /**
+ *
+ * @param {!_LinkInfo} linkInfo
+ */
+ static _handleClickFromNewComponentLand(linkInfo) {
+ Linkifier.invokeFirstAction(linkInfo);
+ }
+
/**
* @param {!_LinkInfo} linkInfo
* @return {boolean}
@@ -974,6 +982,29 @@ export class LinkHandlerSettingUI {
}
}
+let listeningToNewEvents = false;
+function listenForNewComponentLinkifierEvents() {
+ if (listeningToNewEvents) {
+ return;
+ }
+
+ listeningToNewEvents = true;
+
+ window.addEventListener(
+ 'linkifier-click',
+ /**
+ *
+ * @param {!Event} event
+ */
+ function(event) {
+ const unknownEvent = /** @type {?} */ (event);
+ const eventWithData = /** @type {!{data: !_LinkInfo}} */ (unknownEvent);
+ Linkifier._handleClickFromNewComponentLand(eventWithData.data);
+ });
+}
+
+listenForNewComponentLinkifierEvents();
+
/**
* @implements {UI.ContextMenu.Provider}
* @unrestricted
diff --git a/front_end/ui/components/BUILD.gn b/front_end/ui/components/BUILD.gn
index 23c65eb561..8734eb8fd0 100644
--- a/front_end/ui/components/BUILD.gn
+++ b/front_end/ui/components/BUILD.gn
@@ -11,8 +11,10 @@ devtools_module("components") {
"DataGridController.ts",
"DataGridRenderers.ts",
"DataGridUtils.ts",
+ "Linkifier.ts",
]
deps = [
+ "../../bindings:bundle",
"../../text_utils:bundle",
"../../third_party/lit-html:bundle",
]
diff --git a/front_end/ui/components/Linkifier.ts b/front_end/ui/components/Linkifier.ts
new file mode 100644
index 0000000000..8d537b3b48
--- /dev/null
+++ b/front_end/ui/components/Linkifier.ts
@@ -0,0 +1,88 @@
+// 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 Bindings from '../../bindings/bindings.js';
+import * as LitHtml from '../../third_party/lit-html/lit-html.js';
+
+export interface LinkifierData {
+ url: string;
+ lineNumber?: number;
+ columnNumber?: number;
+}
+
+export class LinkifierClick extends Event {
+ data: LinkifierData;
+
+ constructor(data: LinkifierData) {
+ super('linkifier-click', {
+ bubbles: true,
+ composed: true,
+ });
+ this.data = data;
+ }
+}
+
+export class Linkifier extends HTMLElement {
+ private readonly shadow = this.attachShadow({mode: 'open'});
+ private url: string = '';
+ private lineNumber?: number;
+ private columnNumber?: number;
+
+ set data(data: LinkifierData) {
+ this.url = data.url;
+ this.lineNumber = data.lineNumber;
+ this.columnNumber = data.columnNumber;
+
+ this.render();
+ }
+
+ private onLinkActivation(event: Event) {
+ event.preventDefault();
+ this.dispatchEvent(new LinkifierClick({
+ url: this.url,
+ lineNumber: this.lineNumber,
+ columnNumber: this.columnNumber,
+ }));
+ }
+
+ private linkText(): string {
+ if (this.url) {
+ const displayName = Bindings.ResourceUtils.displayNameForURL(this.url);
+ let text = `${displayName}`;
+ if (typeof this.lineNumber !== 'undefined') {
+ text += `:${this.lineNumber + 1}`;
+ }
+ return text;
+ }
+
+ throw new Error('New linkifier component error: don\'t know how to generate link text for given arguments');
+ }
+
+ private render() {
+ if (!this.url) {
+ throw new Error('Cannot construct a Linkifier without providing a valid string URL.');
+ }
+ // Disabled until https://crbug.com/1079231 is fixed.
+ // clang-format off
+ return LitHtml.render(LitHtml.html`
+
+ ${this.linkText()}
+ `, this.shadow, { eventContext: this});
+ // clang-format on
+ }
+}
+
+customElements.define('devtools-linkifier', Linkifier);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'devtools-linkifier': Linkifier;
+ }
+}
diff --git a/front_end/ui/components/components.ts b/front_end/ui/components/components.ts
index 9e7a697489..c896af171b 100644
--- a/front_end/ui/components/components.ts
+++ b/front_end/ui/components/components.ts
@@ -6,10 +6,12 @@ import * as DataGrid from './DataGrid.js';
import * as DataGridController from './DataGridController.js';
import * as DataGridRenderers from './DataGridRenderers.js';
import * as DataGridUtils from './DataGridUtils.js';
+import * as Linkifier from './Linkifier.js';
export {
DataGrid,
DataGridController,
DataGridRenderers,
DataGridUtils,
+ Linkifier,
};
diff --git a/test/unittests/front_end/helpers/DOMHelpers.ts b/test/unittests/front_end/helpers/DOMHelpers.ts
index 9587e2001f..6176c610f5 100644
--- a/test/unittests/front_end/helpers/DOMHelpers.ts
+++ b/test/unittests/front_end/helpers/DOMHelpers.ts
@@ -137,14 +137,11 @@ export function waitForScrollLeft(element: T, desiredScrollLe
}
/**
- * Dispatches a mouse click event. Errors if the event was not dispatched successfully.
+ * Dispatches a mouse click event.
*/
export function dispatchClickEvent(element: T, options: MouseEventInit = {}) {
const clickEvent = new MouseEvent('click', options);
- const success = element.dispatchEvent(clickEvent);
- if (!success) {
- assert.fail('Failed to trigger click event successfully.');
- }
+ element.dispatchEvent(clickEvent);
}
/**
diff --git a/test/unittests/front_end/ui/components/BUILD.gn b/test/unittests/front_end/ui/components/BUILD.gn
index a893dfb35c..59263c5fb0 100644
--- a/test/unittests/front_end/ui/components/BUILD.gn
+++ b/test/unittests/front_end/ui/components/BUILD.gn
@@ -6,6 +6,7 @@ ts_library("components") {
"DataGridController_test.ts",
"DataGridHelpers.ts",
"DataGrid_test.ts",
+ "Linkifier_test.ts",
]
deps = [
diff --git a/test/unittests/front_end/ui/components/Linkifier_test.ts b/test/unittests/front_end/ui/components/Linkifier_test.ts
new file mode 100644
index 0000000000..ab51e9dfe2
--- /dev/null
+++ b/test/unittests/front_end/ui/components/Linkifier_test.ts
@@ -0,0 +1,68 @@
+// 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 UIComponents from '../../../../../front_end/ui/components/components.js';
+
+import {assertElement, assertShadowRoot, dispatchClickEvent, getEventPromise, renderElementIntoDOM} from '../../helpers/DOMHelpers.js';
+const {assert} = chai;
+
+describe('Linkifier', () => {
+ it('renders a link when given a URL', () => {
+ const component = new UIComponents.Linkifier.Linkifier();
+ component.data = {
+ url: 'https://example.com',
+ };
+ renderElementIntoDOM(component);
+ assertShadowRoot(component.shadowRoot);
+ const link = component.shadowRoot.querySelector('a');
+ assertElement(link, HTMLAnchorElement);
+ assert.strictEqual(link.innerText, 'example.com');
+ });
+
+ it('throws when given an invalid URL', () => {
+ const component = new UIComponents.Linkifier.Linkifier();
+ assert.throws(() => {
+ component.data = {
+ url: '',
+ };
+ }, 'Cannot construct a Linkifier without providing a valid string URL.');
+ });
+
+ it('appends the line number to the URL if given, and adds one to deal with 0 indexing', () => {
+ const component = new UIComponents.Linkifier.Linkifier();
+ component.data = {
+ url: 'https://example.com',
+ lineNumber: 1,
+ };
+ renderElementIntoDOM(component);
+ assertShadowRoot(component.shadowRoot);
+ const link = component.shadowRoot.querySelector('a');
+ assertElement(link, HTMLAnchorElement);
+ assert.strictEqual(link.innerText, 'example.com:2');
+ });
+
+ it('emits an event when clicked', async () => {
+ const component = new UIComponents.Linkifier.Linkifier();
+ component.data = {
+ url: 'https://example.com',
+ lineNumber: 1,
+ columnNumber: 50,
+ };
+ renderElementIntoDOM(component);
+ assertShadowRoot(component.shadowRoot);
+ const link = component.shadowRoot.querySelector('a');
+ assertElement(link, HTMLAnchorElement);
+
+ const clickEventPromise = getEventPromise(component, 'linkifier-click');
+ dispatchClickEvent(link, {
+ cancelable: true,
+ });
+ const clickEvent = await clickEventPromise;
+ assert.deepEqual(clickEvent.data, {
+ url: 'https://example.com',
+ lineNumber: 1,
+ columnNumber: 50,
+ });
+ });
+});