Create Button component
This component wraps around a <button> element and implements
common DevTools styles for primary and secondary buttons as
well as a button with an icon and text. Hover/focus/pressed
states are still in progress in the designs and the component
will be updated in the follow-ups to include those styles.
Screenshot: https://i.imgur.com/5EHND1v.png
Bug: none
Change-Id: Id0d11e49e678e1de007025b548fa1357f86ba8f2
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3071224
Reviewed-by: Jack Franklin <[email protected]>
Reviewed-by: Peter Müller <[email protected]>
Commit-Queue: Alex Rudenko <[email protected]>
diff --git a/front_end/ui/components/buttons/BUILD.gn b/front_end/ui/components/buttons/BUILD.gn
new file mode 100644
index 0000000..9292616
--- /dev/null
+++ b/front_end/ui/components/buttons/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright 2021 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/devtools_entrypoint.gni")
+import("../../../../scripts/build/ninja/devtools_module.gni")
+import("../../../../scripts/build/ninja/generate_css.gni")
+import("../visibility.gni")
+
+generate_css("css_files") {
+ sources = [ "button.css" ]
+}
+
+devtools_module("button") {
+ sources = [ "Button.ts" ]
+
+ deps = [
+ "../../../ui/lit-html:bundle",
+ "../helpers:bundle",
+ "../icon_button:bundle",
+ ]
+}
+
+devtools_entrypoint("bundle") {
+ entrypoint = "buttons.ts"
+
+ deps = [
+ ":button",
+ ":css_files",
+ ]
+
+ visibility = default_components_visibility
+}
diff --git a/front_end/ui/components/buttons/Button.ts b/front_end/ui/components/buttons/Button.ts
new file mode 100644
index 0000000..256d5fc
--- /dev/null
+++ b/front_end/ui/components/buttons/Button.ts
@@ -0,0 +1,93 @@
+// Copyright 2021 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 LitHtml from '../../lit-html/lit-html.js';
+import * as ComponentHelpers from '../helpers/helpers.js';
+import * as IconButton from '../icon_button/icon_button.js';
+
+import buttonStyles from './button.css.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'devtools-button': Button;
+ }
+}
+
+export const enum Variant {
+ PRIMARY = 'primary',
+ SECONDARY = 'secondary',
+}
+
+export interface ButtonData {
+ iconUrl?: string;
+ variant?: Variant;
+}
+
+export interface ButtonDataWithVariant extends ButtonData {
+ variant: Variant;
+}
+
+export class Button extends HTMLElement {
+ static readonly litTagName = LitHtml.literal`devtools-button`;
+ private readonly shadow = this.attachShadow({mode: 'open'});
+ private readonly boundRender = this.render.bind(this);
+ private readonly props: ButtonData = {};
+
+ constructor() {
+ super();
+ this.setAttribute('role', 'button');
+ }
+
+ /**
+ * Perfer using the .data= setter instead of setting the individual properties
+ * for increased type-safety.
+ */
+ set data(data: ButtonDataWithVariant) {
+ this.props.variant = data.variant;
+ this.props.iconUrl = data.iconUrl;
+ ComponentHelpers.ScheduledRender.scheduleRender(this, this.boundRender);
+ }
+
+ set iconUrl(iconUrl: string|undefined) {
+ this.props.iconUrl = iconUrl;
+ ComponentHelpers.ScheduledRender.scheduleRender(this, this.boundRender);
+ }
+
+ set variant(variant: Variant) {
+ this.props.variant = variant;
+ ComponentHelpers.ScheduledRender.scheduleRender(this, this.boundRender);
+ }
+
+ connectedCallback(): void {
+ this.shadow.adoptedStyleSheets = [buttonStyles];
+ ComponentHelpers.ScheduledRender.scheduleRender(this, this.boundRender);
+ }
+
+ private render(): void {
+ if (!this.props.variant) {
+ throw new Error('Button requires a variant to be defined');
+ }
+ const classes = {
+ primary: this.props.variant === Variant.PRIMARY,
+ 'with-icon': Boolean(this.props.iconUrl),
+ };
+ // clang-format off
+ LitHtml.render(
+ LitHtml.html`
+ <button class=${LitHtml.Directives.classMap(classes)}>
+ ${this.props.iconUrl ? LitHtml.html`<${IconButton.Icon.Icon.litTagName}
+ .data=${{
+ iconPath: this.props.iconUrl,
+ color: 'var(--color-background)',
+ } as IconButton.Icon.IconData}
+ >
+ </${IconButton.Icon.Icon.litTagName}>` : ''}
+ <slot></slot>
+ </button>
+ `, this.shadow);
+ // clang-format on
+ }
+}
+
+ComponentHelpers.CustomElements.defineComponent('devtools-button', Button);
diff --git a/front_end/ui/components/buttons/button.css b/front_end/ui/components/buttons/button.css
new file mode 100644
index 0000000..c8a2896
--- /dev/null
+++ b/front_end/ui/components/buttons/button.css
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2021 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.
+ */
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+*:focus,
+*:focus-visible {
+ outline: none;
+}
+
+button {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 4px;
+ border: 1px solid var(--color-details-hairline);
+ font-size: 12px;
+ height: 25px;
+ line-height: 14px;
+ padding: 5px 12px;
+ background: var(--color-background);
+ color: var(--color-primary);
+}
+
+button.primary {
+ border: 1px solid var(--color-primary);
+ background: var(--color-primary);
+ color: var(--color-background);
+}
+
+button.with-icon {
+ padding: 0 12px 0 4px;
+}
+
+button:hover {
+ cursor: pointer;
+}
+
+button devtools-icon {
+ width: 19px;
+ height: 19px;
+
+ --icon-color: var(--color-primary);
+}
+
+button.primary devtools-icon {
+ --icon-color: var(--color-background);
+}
diff --git a/front_end/ui/components/buttons/buttons.ts b/front_end/ui/components/buttons/buttons.ts
new file mode 100644
index 0000000..65e3687
--- /dev/null
+++ b/front_end/ui/components/buttons/buttons.ts
@@ -0,0 +1,9 @@
+// Copyright 2021 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 Button from './Button.js';
+
+export {
+ Button,
+};
diff --git a/front_end/ui/components/docs/BUILD.gn b/front_end/ui/components/docs/BUILD.gn
index 3b31a21..0b7050c 100644
--- a/front_end/ui/components/docs/BUILD.gn
+++ b/front_end/ui/components/docs/BUILD.gn
@@ -9,6 +9,7 @@
public_deps = [
":bundle",
"../../../Images",
+ "./button",
"./color_swatch",
"./computed_style_property",
"./computed_style_trace",
diff --git a/front_end/ui/components/docs/button/BUILD.gn b/front_end/ui/components/docs/button/BUILD.gn
new file mode 100644
index 0000000..e7434fb
--- /dev/null
+++ b/front_end/ui/components/docs/button/BUILD.gn
@@ -0,0 +1,23 @@
+# Copyright 2021 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")
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("ts") {
+ testonly = true
+ sources = [ "basic.ts" ]
+
+ deps = [
+ "../../../../../test/unittests/front_end/helpers",
+ "../../buttons:bundle",
+ ]
+}
+
+copy_to_gen("button") {
+ testonly = true
+ sources = [ "basic.html" ]
+
+ deps = [ ":ts" ]
+}
diff --git a/front_end/ui/components/docs/button/basic.html b/front_end/ui/components/docs/button/basic.html
new file mode 100644
index 0000000..e02704b
--- /dev/null
+++ b/front_end/ui/components/docs/button/basic.html
@@ -0,0 +1,28 @@
+<!--
+ Copyright 2021 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.
+-->
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width" />
+ <title>Button example</title>
+ <style>
+ #container {
+ padding: 25px;
+ display: flex;
+ align-items: center;
+ }
+ #container > * {
+ margin-right: 10px;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="container">
+ </div>
+ <script type="module" src="./basic.js"></script>
+ </body>
+</html>
diff --git a/front_end/ui/components/docs/button/basic.ts b/front_end/ui/components/docs/button/basic.ts
new file mode 100644
index 0000000..6100044
--- /dev/null
+++ b/front_end/ui/components/docs/button/basic.ts
@@ -0,0 +1,55 @@
+// Copyright 2021 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 FrontendHelpers from '../../../../../test/unittests/front_end/helpers/EnvironmentHelpers.js';
+import * as Buttons from '../../buttons/buttons.js';
+import * as ComponentHelpers from '../../helpers/helpers.js';
+
+await ComponentHelpers.ComponentServerSetup.setup();
+await FrontendHelpers.initializeGlobalVars();
+
+const testIcon =
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDZIMTFWMTEuNUg1LjVWMTIuNUgxMVYxOEgxMlYxMi41SDE3LjVWMTEuNUgxMlY2WiIgZmlsbD0iYmxhY2siLz4KPC9zdmc+Cg==';
+
+function appendButton(button: Buttons.Button.Button): void {
+ document.querySelector('#container')?.appendChild(button);
+}
+
+// Primary
+const primaryButton = new Buttons.Button.Button();
+primaryButton.data = {
+ variant: Buttons.Button.Variant.PRIMARY,
+};
+primaryButton.innerText = 'Click me';
+primaryButton.onclick = () => alert('clicked');
+appendButton(primaryButton);
+
+// Secondary
+const secondaryButton = new Buttons.Button.Button();
+secondaryButton.innerText = 'Click me';
+secondaryButton.onclick = () => alert('clicked');
+secondaryButton.data = {
+ variant: Buttons.Button.Variant.SECONDARY,
+};
+appendButton(secondaryButton);
+
+// Primary Icon
+const primaryIconButton = new Buttons.Button.Button();
+primaryIconButton.innerText = 'Click me';
+primaryIconButton.data = {
+ variant: Buttons.Button.Variant.PRIMARY,
+ iconUrl: testIcon,
+};
+primaryIconButton.onclick = () => alert('clicked');
+appendButton(primaryIconButton);
+
+// Secondary Icon
+const secondaryIconButton = new Buttons.Button.Button();
+secondaryIconButton.innerText = 'Click me';
+secondaryIconButton.onclick = () => alert('clicked');
+secondaryIconButton.data = {
+ variant: Buttons.Button.Variant.SECONDARY,
+ iconUrl: testIcon,
+};
+appendButton(secondaryIconButton);
diff --git a/test/unittests/front_end/ui/components/BUILD.gn b/test/unittests/front_end/ui/components/BUILD.gn
index 1ae9cd4..c65cd0b 100644
--- a/test/unittests/front_end/ui/components/BUILD.gn
+++ b/test/unittests/front_end/ui/components/BUILD.gn
@@ -34,5 +34,6 @@
"../../../../../front_end/ui/components/tree_outline:bundle",
"../../../../../front_end/ui/lit-html:bundle",
"../../helpers",
+ "./buttons",
]
}
diff --git a/test/unittests/front_end/ui/components/buttons/BUILD.gn b/test/unittests/front_end/ui/components/buttons/BUILD.gn
new file mode 100644
index 0000000..4d4588f
--- /dev/null
+++ b/test/unittests/front_end/ui/components/buttons/BUILD.gn
@@ -0,0 +1,16 @@
+# Copyright 2021 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("../../../../../../third_party/typescript/typescript.gni")
+
+ts_library("buttons") {
+ testonly = true
+ sources = [ "Button_test.ts" ]
+
+ deps = [
+ "../../../../../../front_end/ui/components/buttons:bundle",
+ "../../../../../../front_end/ui/components/render_coordinator:bundle",
+ "../../../helpers",
+ ]
+}
diff --git a/test/unittests/front_end/ui/components/buttons/Button_test.ts b/test/unittests/front_end/ui/components/buttons/Button_test.ts
new file mode 100644
index 0000000..7612a2b
--- /dev/null
+++ b/test/unittests/front_end/ui/components/buttons/Button_test.ts
@@ -0,0 +1,34 @@
+// Copyright 2021 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 Buttons from '../../../../../../front_end/ui/components/buttons/buttons.js';
+import * as Coordinator from '../../../../../../front_end/ui/components/render_coordinator/render_coordinator.js';
+import {assertElement, dispatchKeyDownEvent, renderElementIntoDOM} from '../../../helpers/DOMHelpers.js';
+
+const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
+
+const {assert} = chai;
+
+describe('Button', async () => {
+ it('can be clicked', async () => {
+ const button = new Buttons.Button.Button();
+ button.variant = Buttons.Button.Variant.PRIMARY;
+ button.innerText = 'Button';
+ renderElementIntoDOM(button);
+ await coordinator.done();
+
+ let clicks = 0;
+ button.onclick = () => clicks++;
+
+ const innerButton = button.shadowRoot?.querySelector('button') as HTMLButtonElement;
+ assertElement(innerButton, HTMLButtonElement);
+
+ innerButton.click();
+ dispatchKeyDownEvent(innerButton, {
+ key: 'Enter',
+ });
+
+ assert.strictEqual(clicks, 1);
+ });
+});