Bug 1487857 - Part I: Rearranging devices setting view to new design. r=gl,flod
authorMicah Tigley <mtigley@mozilla.com>
Tue, 19 Mar 2019 18:59:24 +0000
changeset 465126 fbdd97d8e41dd07bdcef93eae73a9c7e5fd72713
parent 465125 5e2b6b60b5a2af562a2b5e061ea8336348fb3c9c
child 465127 74d9f16af0bf752632cba2b70d12fcf00b8cbab0
push id35732
push useropoprus@mozilla.com
push dateWed, 20 Mar 2019 10:52:37 +0000
treeherdermozilla-central@708979f9c3f3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgl, flod
bugs1487857
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1487857 - Part I: Rearranging devices setting view to new design. r=gl,flod This is part 1 of implementing the redesigned device settings panel. In this patch we are rearranging the existing device settings view to match the new design. Differential Revision: https://phabricator.services.mozilla.com/D15734
devtools/client/locales/en-US/responsive.properties
devtools/client/responsive.html/components/Device.js
devtools/client/responsive.html/components/DeviceForm.js
devtools/client/responsive.html/components/DeviceList.js
devtools/client/responsive.html/components/DeviceModal.js
devtools/client/responsive.html/components/moz.build
devtools/client/responsive.html/index.css
devtools/client/responsive.html/test/browser/browser_device_custom.js
devtools/client/responsive.html/test/browser/browser_device_custom_remove.js
devtools/client/responsive.html/test/browser/head.js
--- a/devtools/client/locales/en-US/responsive.properties
+++ b/devtools/client/locales/en-US/responsive.properties
@@ -65,51 +65,48 @@ responsive.devicePixelRatio.auto=Device 
 # device).
 responsive.customDeviceName=Custom Device
 
 # LOCALIZATION NOTE (responsive.customDeviceNameFromBase): Default value in a
 # form to add a custom device based on the properties of another.  %1$S is the
 # name of the device we're staring from, such as "Apple iPhone 6".
 responsive.customDeviceNameFromBase=%1$S (Custom)
 
-# LOCALIZATION NOTE (responsive.addDevice): Button text that reveals a form to
+# LOCALIZATION NOTE (responsive.addDevice2): Button text that reveals a form to
 # be used for adding custom devices.
-responsive.addDevice=Add Device
+responsive.addDevice2=Add Custom Deviceā€¦
 
 # LOCALIZATION NOTE (responsive.deviceAdderName): Label of form field for the
-# name of a new device.  The available width is very low, so you might see
-# overlapping text if the length is much longer than 5 or so characters.
+# name of a new device.
 responsive.deviceAdderName=Name
 
 # LOCALIZATION NOTE (responsive.deviceAdderSize): Label of form field for the
-# size of a new device.  The available width is very low, so you might see
-# overlapping text if the length is much longer than 5 or so characters.
+# size of a new device.
 responsive.deviceAdderSize=Size
 
-# LOCALIZATION NOTE (responsive.deviceAdderPixelRatio): Label of form field for
-# the device pixel ratio of a new device.  The available width is very low, so you
-# might see overlapping text if the length is much longer than 5 or so
-# characters.
-responsive.deviceAdderPixelRatio=DPR
+# LOCALIZATION NOTE (responsive.deviceAdderPixelRatio2): Label of form field for
+# the device pixel ratio of a new device.
+responsive.deviceAdderPixelRatio2=Device Pixel Ratio
 
-# LOCALIZATION NOTE (responsive.deviceAdderUserAgent): Label of form field for
-# the user agent of a new device.  The available width is very low, so you might
-# see overlapping text if the length is much longer than 5 or so characters.
-responsive.deviceAdderUserAgent=UA
+# LOCALIZATION NOTE (responsive.deviceAdderUserAgent2): Label of form field for
+# the user agent of a new device.
+responsive.deviceAdderUserAgent2=User Agent String
 
-# LOCALIZATION NOTE (responsive.deviceAdderTouch): Label of form field for the
-# touch input support of a new device.  The available width is very low, so you
-# might see overlapping text if the length is much longer than 5 or so
-# characters.
-responsive.deviceAdderTouch=Touch
+# LOCALIZATION NOTE (responsive.deviceAdderTouch2): Label of form field for the
+# touch input support of a new device.
+responsive.deviceAdderTouch2=Touch Screen
 
 # LOCALIZATION NOTE (responsive.deviceAdderSave): Button text that submits a
 # form to add a new device.
 responsive.deviceAdderSave=Save
 
+# LOCALIZATION NOTE (responsive.deviceAdderCancel): Button text that cancels a
+# form to add a new device.
+responsive.deviceAdderCancel=Cancel
+
 # LOCALIZATION NOTE (responsive.deviceDetails): Tooltip that appears when
 # hovering on a device in the device modal.  %1$S is the width of the device.
 # %2$S is the height of the device.  %3$S is the device pixel ratio value of the
 # device.  %4$S is the user agent of the device.  %5$S is a boolean value
 # noting whether touch input is supported.
 responsive.deviceDetails=Size: %1$S x %2$S\nDPR: %3$S\nUA: %4$S\nTouch: %5$S
 
 # LOCALIZATION NOTE (responsive.devicePixelRatioOption): UI option in a menu to configure
@@ -137,8 +134,16 @@ responsive.leftAlignViewport=Left-align 
 # Responsive Design Mode.
 responsive.settingOnboarding.content=New: Change to left-alignment or edit reload behavior here.
 
 # LOCALIZATION NOTE (responsive.customUserAgent): This is the placeholder for the user
 # agent input in the responsive design mode toolbar.
 responsive.customUserAgent=Custom User Agent
 
 responsive.showUserAgentInput=Show user agent
+
+# LOCALIZATION NOTE (responsive.deviceSettings): The header text for the device settings
+# view.
+responsive.deviceSettings=Device Settings
+
+# LOCALIZATION NOTE (responsive.deviceNameAlreadyInUse): This is the text shown when adding a new
+# device with an already existing device name.
+responsive.deviceNameAlreadyInUse=Device name already in use
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/components/Device.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const { PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const { getFormatStr } = require("../utils/l10n");
+const Types = require("../types");
+
+class Device extends PureComponent {
+  static get propTypes() {
+    return {
+      // props.children are the buttons rendered as part of custom device label.
+      children: PropTypes.oneOfType([
+        PropTypes.arrayOf(PropTypes.node),
+        PropTypes.node,
+      ]),
+      device: PropTypes.shape(Types.devices).isRequired,
+      onDeviceCheckboxChange: PropTypes.func.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      // Whether or not the device's input label is checked.
+      isChecked: this.props.device.isChecked,
+    };
+
+    this.onCheckboxChanged = this.onCheckboxChanged.bind(this);
+  }
+
+  onCheckboxChanged(e) {
+    this.setState(prevState => {
+      return { isChecked: !prevState.isChecked };
+    });
+
+    this.props.onDeviceCheckboxChange(e);
+  }
+
+  render() {
+    const { children, device } = this.props;
+    const details = getFormatStr(
+      "responsive.deviceDetails", device.width, device.height,
+      device.pixelRatio, device.userAgent, device.touch
+    );
+
+    return (
+      dom.label(
+        {
+          className: "device-label",
+          key: device.name,
+          title: details,
+        },
+        dom.input({
+          className: "device-input-checkbox",
+          name: device.name,
+          type: "checkbox",
+          value: device.name,
+          checked: device.isChecked,
+          onChange: this.onCheckboxChanged,
+        }),
+        dom.span({ className: "device-name" },
+          device.name
+        ),
+        children
+      )
+    );
+  }
+}
+
+module.exports = Device;
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/components/DeviceForm.js
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const { createFactory, createRef, PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const ViewportDimension = createFactory(require("./ViewportDimension"));
+
+const { getStr } = require("../utils/l10n");
+const Types = require("../types");
+
+class DeviceForm extends PureComponent {
+  static get propTypes() {
+    return {
+      buttonText: PropTypes.string,
+      formType: PropTypes.string.isRequired,
+      device: PropTypes.shape(Types.device).isRequired,
+      onSave: PropTypes.func.isRequired,
+      validateName: PropTypes.func.isRequired,
+      viewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+      onDeviceFormHide: PropTypes.func.isRequired,
+      onDeviceFormShow: PropTypes.func.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    const {
+      height,
+      width,
+    } = this.props.viewportTemplate;
+
+    this.state = {
+      isShown: false,
+      height,
+      width,
+    };
+
+    this.nameInputRef = createRef();
+    this.pixelRatioInputRef = createRef();
+    this.touchInputRef = createRef();
+    this.userAgentInputRef = createRef();
+
+    this.onChangeSize = this.onChangeSize.bind(this);
+    this.onDeviceFormHide  = this.onDeviceFormHide.bind(this);
+    this.onDeviceFormShow = this.onDeviceFormShow.bind(this);
+    this.onDeviceFormSave = this.onDeviceFormSave.bind(this);
+  }
+
+  onChangeSize(_, width, height) {
+    this.setState({
+      width,
+      height,
+    });
+  }
+
+  onDeviceFormSave() {
+    if (!this.pixelRatioInputRef.current.checkValidity()) {
+      return;
+    }
+
+    if (!this.props.validateName(this.nameInputRef.current.value)) {
+      this.nameInputRef.current
+        .setCustomValidity(getStr("responsive.deviceNameAlreadyInUse"));
+      return;
+    }
+
+    this.props.onSave({
+      name: this.nameInputRef.current.value.trim(),
+      width: this.state.width,
+      height: this.state.height,
+      pixelRatio: parseFloat(this.pixelRatioInputRef.current.value),
+      userAgent: this.userAgentInputRef.current.value,
+      touch: this.touchInputRef.current.checked,
+    });
+
+    this.onDeviceFormHide();
+  }
+
+  onDeviceFormHide() {
+    this.setState({ isShown: false });
+
+    // Ensure that we have onDeviceFormHide before calling it.
+    if (this.props.onDeviceFormHide) {
+      this.props.onDeviceFormHide();
+    }
+  }
+
+  onDeviceFormShow() {
+    this.setState({ isShown: true });
+
+    // Ensure that we have onDeviceFormShow before calling it.
+    if (this.props.onDeviceFormShow) {
+      this.props.onDeviceFormShow();
+    }
+  }
+
+  render() {
+    const { buttonText, device, formType } = this.props;
+    const { isShown, width, height } = this.state;
+
+    if (!isShown) {
+      return (
+        dom.button(
+          {
+            id: `device-${formType}-button`,
+            className: "devtools-button",
+            onClick: this.onDeviceFormShow,
+          },
+          buttonText,
+        )
+      );
+    }
+
+    return (
+      dom.form({ id: "device-form" },
+        dom.label({ id: "device-form-name", className: formType },
+          dom.span({ className: "device-form-label" },
+            getStr("responsive.deviceAdderName")
+          ),
+          dom.input({
+            defaultValue: device.name,
+            ref: this.nameInputRef,
+          })
+        ),
+        dom.label({ id: "device-form-size" },
+          dom.span({ className: "device-form-label" },
+            getStr("responsive.deviceAdderSize")
+          ),
+          ViewportDimension({
+            viewport: { width, height },
+            doResizeViewport: this.onChangeSize,
+            onRemoveDeviceAssociation: () => {},
+          })
+        ),
+        dom.label({ id: "device-form-pixel-ratio" },
+          dom.span({ className: "device-form-label" },
+            getStr("responsive.deviceAdderPixelRatio2")
+          ),
+          dom.input({
+            type: "number",
+            step: "any",
+            defaultValue: device.pixelRatio,
+            ref: this.pixelRatioInputRef,
+          })
+        ),
+        dom.label({ id: "device-form-user-agent" },
+          dom.span({ className: "device-form-label" },
+            getStr("responsive.deviceAdderUserAgent2")
+          ),
+          dom.input({
+            defaultValue: device.userAgent,
+            ref: this.userAgentInputRef,
+          })
+        ),
+        dom.label({ id: "device-form-touch" },
+          dom.input({
+            defaultChecked: device.touch,
+            type: "checkbox",
+            ref: this.touchInputRef,
+          }),
+          dom.span({ className: "device-form-label" },
+            getStr("responsive.deviceAdderTouch2")
+          )
+        ),
+        dom.div({ className: "device-form-buttons" },
+          dom.button({ id: "device-form-save", onClick: this.onDeviceFormSave },
+            getStr("responsive.deviceAdderSave")
+          ),
+          dom.button({ id: "device-form-cancel", onClick: this.onDeviceFormHide },
+            getStr("responsive.deviceAdderCancel")
+          )
+        )
+      )
+    );
+  }
+}
+
+module.exports = DeviceForm;
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/components/DeviceList.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const Types = require("../types");
+
+const Device = createFactory(require("./Device"));
+
+class DeviceList extends PureComponent {
+  static get propTypes() {
+    return {
+      devices: PropTypes.shape(Types.devices).isRequired,
+      onDeviceCheckboxChange: PropTypes.func.isRequired,
+      onRemoveCustomDevice: PropTypes.func.isRequired,
+      type: PropTypes.string.isRequired,
+    };
+  }
+
+  renderCustomDevice(device) {
+    const { onRemoveCustomDevice, onDeviceCheckboxChange, type } = this.props;
+
+    // Show a remove button for custom devices.
+    const removeDeviceButton = dom.button({
+        id: "device-editor-remove",
+        className: "device-remove-button devtools-button",
+        onClick: () => onRemoveCustomDevice(device),
+    });
+
+    return Device(
+      {
+        device,
+        key: device.name,
+        type,
+        onDeviceCheckboxChange,
+      },
+      removeDeviceButton
+    );
+  }
+
+  render() {
+    const { devices, type, onDeviceCheckboxChange } = this.props;
+
+    return (
+      dom.div({ className: "device-list"},
+        devices[type].map(device => {
+          if (type === "custom") {
+            return this.renderCustomDevice(device);
+          }
+
+          return Device({
+            device,
+            key: device.name,
+            type,
+            onDeviceCheckboxChange,
+          });
+        })
+      )
+    );
+  }
+}
+
+module.exports = DeviceList;
--- a/devtools/client/responsive.html/components/DeviceModal.js
+++ b/devtools/client/responsive.html/components/DeviceModal.js
@@ -5,19 +5,20 @@
 /* eslint-env browser */
 
 "use strict";
 
 const { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
-const DeviceAdder = createFactory(require("./DeviceAdder"));
+const DeviceForm = createFactory(require("./DeviceForm"));
+const DeviceList = createFactory(require("./DeviceList"));
 
-const { getStr, getFormatStr } = require("../utils/l10n");
+const { getFormatStr, getStr } = require("../utils/l10n");
 const Types = require("../types");
 
 class DeviceModal extends PureComponent {
   static get propTypes() {
     return {
       deviceAdderViewportTemplate: PropTypes.shape(Types.viewport).isRequired,
       devices: PropTypes.shape(Types.devices).isRequired,
       onAddCustomDevice: PropTypes.func.isRequired,
@@ -26,27 +27,32 @@ class DeviceModal extends PureComponent 
       onUpdateDeviceDisplayed: PropTypes.func.isRequired,
       onUpdateDeviceModal: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
-    this.state = {};
+    this.state = {
+      isDeviceFormShown: false,
+    };
     for (const type of this.props.devices.types) {
       for (const device of this.props.devices[type]) {
         this.state[device.name] = device.displayed;
       }
     }
 
     this.onAddCustomDevice = this.onAddCustomDevice.bind(this);
     this.onDeviceCheckboxChange = this.onDeviceCheckboxChange.bind(this);
+    this.onDeviceFormShow = this.onDeviceFormShow.bind(this);
+    this.onDeviceFormHide = this.onDeviceFormHide.bind(this);
     this.onDeviceModalSubmit = this.onDeviceModalSubmit.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
+    this.validateAddDeviceFormNameField = this.validateAddDeviceFormNameField.bind(this);
   }
 
   componentDidMount() {
     window.addEventListener("keydown", this.onKeyDown, true);
   }
 
   componentWillUnmount() {
     window.removeEventListener("keydown", this.onKeyDown, true);
@@ -64,16 +70,24 @@ class DeviceModal extends PureComponent 
     if (button !== 0) {
       return;
     }
     this.setState({
       [target.value]: !this.state[target.value],
     });
   }
 
+  onDeviceFormShow() {
+    this.setState({ isDeviceFormShown: true });
+  }
+
+  onDeviceFormHide() {
+    this.setState({ isDeviceFormShown: false });
+  }
+
   onDeviceModalSubmit() {
     const {
       devices,
       onDeviceListUpdate,
       onUpdateDeviceDisplayed,
       onUpdateDeviceModal,
     } = this.props;
 
@@ -110,100 +124,137 @@ class DeviceModal extends PureComponent 
     if (event.keyCode === 27) {
       const {
         onUpdateDeviceModal,
       } = this.props;
       onUpdateDeviceModal(false);
     }
   }
 
+  renderAddDeviceForm(devices, viewportTemplate) {
+    // If a device is currently selected, fold its attributes into a single object for use
+    // as the starting values of the form.  If no device is selected, use the values for
+    // the current window.
+    const deviceTemplate = viewportTemplate;
+    if (viewportTemplate.device) {
+      const device = devices[viewportTemplate.deviceType].find(d => {
+        return d.name == viewportTemplate.device;
+      });
+      Object.assign(deviceTemplate, {
+        pixelRatio: device.pixelRatio,
+        userAgent: device.userAgent,
+        touch: device.touch,
+        name: getFormatStr("responsive.customDeviceNameFromBase", device.name),
+      });
+    } else {
+      Object.assign(deviceTemplate, {
+        pixelRatio: window.devicePixelRatio,
+        userAgent: navigator.userAgent,
+        touch: false,
+        name: getStr("responsive.customDeviceName"),
+      });
+    }
+
+    return (
+      DeviceForm({
+        formType: "add",
+        buttonText: getStr("responsive.addDevice2"),
+        device: deviceTemplate,
+        onDeviceFormHide: this.onDeviceFormHide,
+        onDeviceFormShow: this.onDeviceFormShow,
+        onSave: this.onAddCustomDevice,
+        validateName: this.validateAddDeviceFormNameField,
+        viewportTemplate,
+      })
+    );
+  }
+
+  renderDevices() {
+    const sortedDevices = {};
+    for (const type of this.props.devices.types) {
+      sortedDevices[type] = this.props.devices[type]
+        .sort((a, b) => a.name.localeCompare(b.name));
+
+      sortedDevices[type].forEach(device => {
+        device.isChecked = this.state[device.name];
+      });
+    }
+
+    return (
+      this.props.devices.types.map(type => {
+        return sortedDevices[type].length ?
+          dom.div(
+            {
+              className: `device-type device-type-${type}`,
+              key: type,
+            },
+            dom.header({ className: "device-header" }, type),
+            DeviceList({
+              devices: sortedDevices,
+              type,
+              onDeviceCheckboxChange: this.onDeviceCheckboxChange,
+              onRemoveCustomDevice: this.props.onRemoveCustomDevice,
+            })
+          )
+          :
+          null;
+      })
+    );
+  }
+
+  /**
+   * Validates the name field's value by checking if the added device's name already
+   * exists in the custom devices list.
+   *
+   * @param  {String} value
+   *         The input field value for the device name.
+   * @return {Boolean} true if device name is valid, false otherwise.
+   */
+  validateAddDeviceFormNameField(value) {
+    const { devices } = this.props;
+    const nameFieldValue = value.trim();
+    const deviceFound = devices.custom.find(device => device.name == nameFieldValue);
+
+    return !deviceFound;
+  }
+
   render() {
     const {
       deviceAdderViewportTemplate,
       devices,
-      onRemoveCustomDevice,
       onUpdateDeviceModal,
     } = this.props;
 
-    const {
-      onAddCustomDevice,
-    } = this;
-
-    const sortedDevices = {};
-    for (const type of devices.types) {
-      sortedDevices[type] = Object.assign([], devices[type])
-        .sort((a, b) => a.name.localeCompare(b.name));
-    }
-
     return (
       dom.div(
         {
           id: "device-modal-wrapper",
           className: this.props.devices.isModalOpen ? "opened" : "closed",
         },
         dom.div({ className: "device-modal" },
-          dom.button({
-            id: "device-close-button",
-            className: "devtools-button",
-            onClick: () => onUpdateDeviceModal(false),
-          }),
-          dom.div({ className: "device-modal-content" },
-            devices.types.map(type => {
-              return dom.div(
-                {
-                  className: `device-type device-type-${type}`,
-                  key: type,
-                },
-                dom.header({ className: "device-header" },
-                  type
-                ),
-                sortedDevices[type].map(device => {
-                  const details = getFormatStr(
-                    "responsive.deviceDetails", device.width, device.height,
-                    device.pixelRatio, device.userAgent, device.touch
-                  );
-
-                  let removeDeviceButton;
-                  if (type == "custom") {
-                    removeDeviceButton = dom.button({
-                      className: "device-remove-button devtools-button",
-                      onClick: () => onRemoveCustomDevice(device),
-                    });
-                  }
-
-                  return dom.label(
-                    {
-                      className: "device-label",
-                      key: device.name,
-                      title: details,
-                    },
-                    dom.input({
-                      className: "device-input-checkbox",
-                      type: "checkbox",
-                      value: device.name,
-                      checked: this.state[device.name],
-                      onChange: this.onDeviceCheckboxChange,
-                    }),
-                    dom.span(
-                      {
-                        className: "device-name",
-                      },
-                      device.name
-                    ),
-                    removeDeviceButton
-                  );
+          dom.div({ className: "device-modal-header" },
+            !this.state.isDeviceFormShown ?
+              dom.header({ className: "device-modal-title" },
+                getStr("responsive.deviceSettings"),
+                dom.button({
+                  id: "device-close-button",
+                  className: "devtools-button",
+                  onClick: () => onUpdateDeviceModal(false),
                 })
-              );
-            })
+              )
+              :
+              null,
+            this.renderAddDeviceForm(devices, deviceAdderViewportTemplate)
           ),
-          DeviceAdder({
-            devices,
-            viewportTemplate: deviceAdderViewportTemplate,
-            onAddCustomDevice,
-          }),
+          dom.div({
+            className: `device-modal-content
+              ${this.state.isDeviceFormShown ? " form-shown" : ""}`,
+          },
+          this.renderDevices()
+          ),
           dom.button(
             {
               id: "device-submit-button",
               onClick: this.onDeviceModalSubmit,
             },
             getStr("responsive.done")
           )
         ),
--- a/devtools/client/responsive.html/components/moz.build
+++ b/devtools/client/responsive.html/components/moz.build
@@ -2,17 +2,19 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
     'App.js',
     'Browser.js',
-    'DeviceAdder.js',
+    'Device.js',
+    'DeviceForm.js',
+    'DeviceList.js',
     'DeviceModal.js',
     'DevicePixelRatioMenu.js',
     'DeviceSelector.js',
     'ResizableViewport.js',
     'SettingsMenu.js',
     'Toolbar.js',
     'UserAgentInput.js',
     'ViewportDimension.js',
--- a/devtools/client/responsive.html/index.css
+++ b/devtools/client/responsive.html/index.css
@@ -6,24 +6,26 @@
  */
 
 :root {
   --rdm-box-shadow: 0 4px 4px 0 rgba(155, 155, 155, 0.26);
   --submit-button-active-background-color: rgba(0,0,0,0.12);
   --submit-button-active-color: var(--theme-body-color);
   --viewport-active-color: #3b3b3b;
   --input-invalid-border-color: var(--red-60);
+  --custom-device-button-hover: var(--grey-30);
 }
 
 :root.theme-dark {
   --rdm-box-shadow: 0 4px 4px 0 rgba(105, 105, 105, 0.26);
   --submit-button-active-background-color: var(--theme-toolbar-hover-active);
   --submit-button-active-color: var(--theme-selection-color);
   --viewport-active-color: #fcfcfc;
   --input-invalid-border-color: var(--red-50);
+  --custom-device-button-hover: var(--grey-10-a20)
 }
 
 * {
   box-sizing: border-box;
 }
 
 :root,
 input,
@@ -390,30 +392,34 @@ body,
   100% {
     opacity: 0;
     transform: translateY(5px);
     visibility: hidden;
   }
 }
 
 .device-modal {
+  display: grid;
+  grid-template-rows: minmax(80px, auto) auto 20px;
   background-color: var(--theme-toolbar-background);
   border: 1px solid var(--theme-splitter-color);
   border-radius: 2px;
   box-shadow: var(--rdm-box-shadow);
   position: absolute;
   margin: auto;
   top: 0;
   bottom: 0;
   left: 0;
   right: 0;
-  width: 800px;
-  max-width: 90%;
-  height: 650px;
+  width: 90%;
+  height: 90%;
+  max-width: 750px;
+  max-height: 730px;
   z-index: 1;
+  overflow: hidden;
 }
 
 /* Handles the opening/closing of the modal */
 #device-modal-wrapper.opened .device-modal {
   animation: fade-in-and-up 0.3s ease forwards;
 }
 
 #device-modal-wrapper.closed .device-modal {
@@ -427,59 +433,182 @@ body,
   left: 0;
   height: 100%;
   width: 100%;
   z-index: 0;
   opacity: 0.5;
 }
 
 .device-modal-content {
-  display: flex;
-  flex-direction: column;
-  flex-wrap: wrap;
+  display: grid;
+  grid-row-gap: 30px;
   overflow: auto;
-  height: 515px;
-  margin: 20px 20px 0;
+  height: 100%;
+  padding: 10px 32px 50px 32px;
+}
+
+.device-modal-content.form-shown {
+  padding-top: 30px;
+}
+
+/* On screens that are >750px*/
+@media (min-width: 750px) {
+  #device-form {
+    grid-template-areas: "name size dpr"
+                         "user-agent touch buttons";
+  }
+
+  #device-form-name input,
+  #device-form-user-agent input {
+    width: 350px;
+  }
+
+  .device-modal-content {
+    grid-template-columns: 1fr 1fr 1fr;
+    grid-template-areas: "phone phone custom"
+                         "tablet laptop tv";
+  }
+}
+
+/* On screens that are between 450px and 749px */
+@media (min-width: 450px) and (max-width: 749px) {
+  #device-form {
+    grid-template-areas: "name size"
+                         "user-agent dpr"
+                         "touch buttons";
+    grid-template-columns: 2fr 1fr;
+  }
+
+  #device-form-name {
+    grid-area: name;
+  }
+
+  #device-form-name input,
+  #device-form-user-agent input {
+    width: 100%;
+  }
+
+  .device-modal-content {
+    grid-template-columns: 1fr 1fr;
+    grid-template-areas: "phone phone"
+                         "tablet laptop"
+                         "tv custom";
+  }
+}
+
+/* On screens that are <450px */
+@media (max-width: 449px) {
+  #device-form {
+    grid-template-areas: "name"
+                         "size"
+                         "dpr"
+                         "user-agent"
+                         "touch"
+                         "buttons";
+  }
+
+  #device-form-name input,
+  #device-form-user-agent input {
+    width: 90%;
+  }
+
+  #device-form-size {
+    justify-self: unset;
+  }
+
+  .device-modal-content {
+    grid-template-areas: "phone"
+                         "phone"
+                         "tablet"
+                         "laptop"
+                         "tv"
+                         "custom";
+  }
+
+  .device-type.device-type-phones .device-list {
+    grid-template-columns: 1fr;
+  }
+
+  .device-modal-header {
+    flex-direction: column;
+  }
 }
 
 #device-close-button {
   position: absolute;
   top: 5px;
   right: 2px;
 }
 
 #device-close-button::before {
   background-image: url("chrome://devtools/skin/images/close.svg");
 }
 
 .device-type {
   display: flex;
   flex-direction: column;
-  padding: 10px;
 }
 
 .device-header {
-  font-weight: bold;
+  font-size: 17px;
+  margin-bottom: 7px;
+  height: 20px;
   text-transform: capitalize;
-  padding: 0 0 3px 23px;
 }
 
 .device-label {
   color: var(--theme-body-color);
-  padding-bottom: 3px;
+  padding-bottom: 5px;
+  padding-top: 5px;
   display: flex;
   align-items: center;
-  /* Largest size without horizontal scrollbars */
-  max-width: 181px;
+}
+
+.device-label > button {
+  visibility: hidden;
+}
+
+.device-label:focus-within > button,
+.device-label:hover > button {
+  visibility: visible;
+}
+
+.device-label:focus-within,
+.device-label:hover {
+  background-color: var(--toolbarbutton-hover-background);
+}
+
+.device-modal-header {
+  display: flex;
+  justify-content: space-between;
+  z-index: 1;
+}
+
+.device-modal-header > #device-add-button {
+  margin: 30px 75px 0 30px;
+}
+
+.device-modal-header > #device-form {
+  margin-top: 15px;
+}
+
+.device-list {
+  display: grid;
+  font-size: 13px;
 }
 
 .device-input-checkbox {
   margin-right: 5px;
 }
 
+.device-modal-title {
+  font-size: 22px;
+  margin: 30px 0 0px 30px;
+}
+
 .device-name {
   flex: 1;
 }
 
 .device-remove-button:empty::before {
   background-image: url("chrome://devtools/skin/images/close.svg");
 }
 
@@ -501,90 +630,173 @@ body,
 }
 
 #device-submit-button:hover:active {
   background-color: var(--submit-button-active-background-color);
   color: var(--submit-button-active-color);
 }
 
 /**
- * Device Adder
+ * Device Form
  */
 
-#device-adder {
-  display: flex;
-  flex-direction: column;
-  margin: 0 20px;
+#device-form {
+  display: grid;
+  width: 100%;
+  background-color: var(--theme-toolbar-background);
+  min-height: 150px;
+  padding-left: 20px;
+  padding-bottom: 10px;
+  border-bottom: 1px solid var(--theme-splitter-color);
+  overflow: auto;
 }
 
-#device-adder-content {
-  display: flex;
+#device-add-button {
+  margin-right: 70px;
 }
 
-#device-adder-column-1 {
-  flex: 1;
-  margin-right: 10px;
+#device-add-button,
+#device-form button {
+  background-color: rgba(12, 12, 13, 0.1);
+  border: 1px solid var(--theme-splitter-color);
+  border-radius: 2px;
+  cursor: pointer;
+  width: 167px;
+  height: 32px;
 }
 
-#device-adder-column-2 {
-  flex: 2;
+#device-editor-remove {
+  cursor: pointer;
 }
 
-#device-adder button {
-  background-color: var(--theme-tab-toolbar-background);
-  border: 1px solid var(--theme-splitter-color);
-  border-radius: 2px;
-  color: var(--theme-body-color);
-  margin: 0 auto;
+#device-editor-remove.device-remove-button:focus-within,
+#device-editor-remove.device-remove-button:hover {
+  background-color: var(--custom-device-button-hover);
 }
 
-#device-adder label {
+#device-form label {
   display: flex;
-  margin-bottom: 5px;
+  flex-direction: column;
+  margin: 5px;
+}
+
+#device-form label > .viewport-dimension {
+  color: var(--theme-body-color-inactive);
+  display: flex;
   align-items: center;
 }
 
-#device-adder label > input,
-#device-adder label > .viewport-dimension {
-  flex: 1;
-  margin: 0;
-}
-
-#device-adder label > .viewport-dimension {
-  border-bottom: 1px solid transparent;
-  color: var(--theme-body-color-inactive);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: all 0.25s ease;
-}
-
-#device-adder label > .viewport-dimension.editing {
-  border-bottom-color: var(--theme-selection-background);
-}
-
-#device-adder label > .viewport-dimension.editing.invalid {
-  border-bottom-color: #d92215;
-}
-
-#device-adder input {
+#device-form input {
   background: transparent;
-  border: 1px solid transparent;
+  border: 1px solid;
+  border-radius: 2px;
   text-align: center;
   color: var(--theme-body-color-inactive);
   transition: all 0.25s ease;
 }
 
-#device-adder input:focus {
-  color: var(--viewport-active-color);
+#device-form #device-form-name input,
+#device-form #device-form-user-agent input {
+  text-align: left;
+  padding-left: 12px;
+  padding-right: 12px;
 }
 
-#device-adder label > input:focus,
-#device-adder label > .viewport-dimension:focus  {
-  border-bottom: 1px solid var(--theme-selection-background);
+#device-form input:focus {
+  color: var(--viewport-active-color);
+  border-color: var(--blue-55);
+}
+
+#device-form label > input:focus,
+#device-form label > .viewport-dimension:focus  {
   outline: none;
 }
 
-.device-adder-label {
+#device-form-pixel-ratio {
+  grid-area: dpr;
+}
+
+#device-form-pixel-ratio input {
+  -moz-appearance: textfield;
+}
+
+#device-form-user-agent {
+  grid-area: user-agent;
+}
+
+#device-form-name input,
+#device-form-pixel-ratio input,
+#device-form-user-agent input,
+#device-form-size input {
+  height: 35px;
+}
+
+#device-form #device-form-touch {
+  flex-direction: row;
+  grid-area: touch;
+}
+
+#device-form-touch .device-form-label {
+  align-self: center;
+  margin-left: 5px;
+}
+
+#device-form #device-form-save {
+  background-color: #0060DF;
+  color: #fff;
+  border:1px solid #0060DF;
+  width: 50px;
+}
+
+#device-form-size {
+  grid-area: size;
+}
+
+#device-form-size input,
+#device-form #device-form-cancel {
+  width: 60px;
+}
+
+#device-form-save,
+#device-form-cancel {
+  align-self: center;
+}
+
+.device-form-buttons {
+  display: flex;
+  grid-area: buttons;
+  justify-content: space-evenly;
+  width: 154px;
+}
+
+.device-form-label {
   display: inline-block;
-  margin-right: 5px;
+  margin: 0 5px 5px 0;
   min-width: 35px;
+  font-size: 13px;
 }
+
+/* Device Types */
+
+.device-type-phones {
+  grid-area: phone;
+}
+
+.device-type-phones .device-list {
+  grid-template-columns: repeat(2, auto);
+}
+
+.device-type-custom {
+  grid-area: custom;
+  align-self: start;
+}
+
+.device-type-tablets {
+  grid-area: tablet;
+}
+
+.device-type-laptops {
+  grid-area: laptop;
+}
+
+.device-type-televisions {
+  grid-area: tv;
+}
--- a/devtools/client/responsive.html/test/browser/browser_device_custom.js
+++ b/devtools/client/responsive.html/test/browser/browser_device_custom.js
@@ -32,17 +32,17 @@ addRDMTask(TEST_URL, async function({ ui
 
   // Wait until the viewport has been added and the device list has been loaded
   await waitUntilState(store, state => state.viewports.length == 1
     && state.devices.listState == Types.loadableState.LOADED);
 
   await openDeviceModal(ui);
 
   info("Reveal device adder form, check that defaults match the viewport");
-  const adderShow = document.getElementById("device-adder-show");
+  const adderShow = document.getElementById("device-add-button");
   adderShow.click();
   testDeviceAdder(ui, {
     name: "Custom Device",
     width: 320,
     height: 480,
     pixelRatio: window.devicePixelRatio,
     userAgent: navigator.userAgent,
     touch: false,
@@ -77,17 +77,17 @@ addRDMTask(TEST_URL, async function({ ui
     && state.devices.listState == Types.loadableState.LOADED);
 
   info("Select existing device from the selector");
   await selectDevice(ui, "Test Device");
 
   await openDeviceModal(ui);
 
   info("Reveal device adder form, check that defaults are based on selected device");
-  const adderShow = document.getElementById("device-adder-show");
+  const adderShow = document.getElementById("device-add-button");
   adderShow.click();
   testDeviceAdder(ui, Object.assign({}, device, {
     name: "Test Device (Custom)",
   }));
 
   info("Remove previously added custom device");
   const deviceRemoveButton = document.querySelector(".device-remove-button");
   const submitButton = document.getElementById("device-submit-button");
@@ -121,17 +121,17 @@ addRDMTask(TEST_URL, async function({ ui
 
   // Wait until the viewport has been added and the device list has been loaded
   await waitUntilState(store, state => state.viewports.length == 1
     && state.devices.listState == Types.loadableState.LOADED);
 
   await openDeviceModal(ui);
 
   info("Reveal device adder form");
-  const adderShow = document.querySelector("#device-adder-show");
+  const adderShow = document.querySelector("#device-add-button");
   adderShow.click();
 
   info("Fill out device adder form by setting details to unicode device and save");
   await addDeviceInModal(ui, unicodeDevice);
 
   info("Verify unicode device defaults to enabled in modal");
   const deviceCb = [...document.querySelectorAll(".device-input-checkbox")].find(cb => {
     return cb.value == unicodeDevice.name;
@@ -166,22 +166,22 @@ addRDMTask(TEST_URL, async function({ ui
     const menuItem = items.find(i => i.getAttribute("label") === unicodeDevice.name);
     ok(menuItem, "Custom unicode device option present in device selector");
   });
 });
 
 function testDeviceAdder(ui, expected) {
   const { document } = ui.toolWindow;
 
-  const nameInput = document.querySelector("#device-adder-name input");
+  const nameInput = document.querySelector("#device-form-name input");
   const [ widthInput, heightInput ] =
-    document.querySelectorAll("#device-adder-size input");
-  const pixelRatioInput = document.querySelector("#device-adder-pixel-ratio input");
-  const userAgentInput = document.querySelector("#device-adder-user-agent input");
-  const touchInput = document.querySelector("#device-adder-touch input");
+    document.querySelectorAll("#device-form-size input");
+  const pixelRatioInput = document.querySelector("#device-form-pixel-ratio input");
+  const userAgentInput = document.querySelector("#device-form-user-agent input");
+  const touchInput = document.querySelector("#device-form-touch input");
 
   is(nameInput.value, expected.name, "Device name matches");
   is(parseInt(widthInput.value, 10), expected.width, "Width matches");
   is(parseInt(heightInput.value, 10), expected.height, "Height matches");
   is(parseFloat(pixelRatioInput.value), expected.pixelRatio,
      "devicePixelRatio matches");
   is(userAgentInput.value, expected.userAgent, "User agent matches");
   is(touchInput.checked, expected.touch, "Touch matches");
--- a/devtools/client/responsive.html/test/browser/browser_device_custom_remove.js
+++ b/devtools/client/responsive.html/test/browser/browser_device_custom_remove.js
@@ -34,24 +34,24 @@ addRDMTask(TEST_URL, async function({ ui
   await waitUntilState(store, state => state.viewports.length == 1
     && state.devices.listState == Types.loadableState.LOADED);
 
   const deviceSelector = document.getElementById("device-selector");
 
   await openDeviceModal(ui);
 
   info("Reveal device adder form");
-  let adderShow = document.querySelector("#device-adder-show");
+  let adderShow = document.querySelector("#device-add-button");
   adderShow.click();
 
   info("Add test device 1");
   await addDeviceInModal(ui, device1);
 
   info("Reveal device adder form");
-  adderShow = document.querySelector("#device-adder-show");
+  adderShow = document.querySelector("#device-add-button");
   adderShow.click();
 
   info("Add test device 2");
   await addDeviceInModal(ui, device2);
 
   info("Verify all custom devices default to enabled in modal");
   const submitButton = document.getElementById("device-submit-button");
   const deviceCbs =
--- a/devtools/client/responsive.html/test/browser/head.js
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -475,22 +475,22 @@ async function changeUserAgentInput(ui, 
  * Assuming the device modal is open and the device adder form is shown, this helper
  * function adds `device` via the form, saves it, and waits for it to appear in the store.
  */
 function addDeviceInModal(ui, device) {
   const { Simulate } =
     ui.toolWindow.require("devtools/client/shared/vendor/react-dom-test-utils");
   const { document, store } = ui.toolWindow;
 
-  const nameInput = document.querySelector("#device-adder-name input");
+  const nameInput = document.querySelector("#device-form-name input");
   const [ widthInput, heightInput ] =
-    document.querySelectorAll("#device-adder-size input");
-  const pixelRatioInput = document.querySelector("#device-adder-pixel-ratio input");
-  const userAgentInput = document.querySelector("#device-adder-user-agent input");
-  const touchInput = document.querySelector("#device-adder-touch input");
+    document.querySelectorAll("#device-form-size input");
+  const pixelRatioInput = document.querySelector("#device-form-pixel-ratio input");
+  const userAgentInput = document.querySelector("#device-form-user-agent input");
+  const touchInput = document.querySelector("#device-form-touch input");
 
   nameInput.value = device.name;
   Simulate.change(nameInput);
   widthInput.value = device.width;
   Simulate.change(widthInput);
   Simulate.blur(widthInput);
   heightInput.value = device.height;
   Simulate.change(heightInput);
@@ -498,17 +498,17 @@ function addDeviceInModal(ui, device) {
   pixelRatioInput.value = device.pixelRatio;
   Simulate.change(pixelRatioInput);
   userAgentInput.value = device.userAgent;
   Simulate.change(userAgentInput);
   touchInput.checked = device.touch;
   Simulate.change(touchInput);
 
   const existingCustomDevices = store.getState().devices.custom.length;
-  const adderSave = document.querySelector("#device-adder-save");
+  const adderSave = document.querySelector("#device-form-save");
   const saved = waitUntilState(store, state =>
     state.devices.custom.length == existingCustomDevices + 1
   );
   Simulate.click(adderSave);
   return saved;
 }
 
 function reloadOnUAChange(enabled) {