Bug 1241714 - Implement a selectable device list that applies settings to the viewport r=jryans
authorGabriel Luong <gabriel.luong@gmail.com>
Mon, 21 Mar 2016 14:48:31 -0400
changeset 313373 4eea3b603b0aca9c157dc2e9f3bb3c0d9f23b1c3
parent 313372 3ce5d23d337f45ae96100c00ab994b53d202d7c4
child 313374 dd6593bfbb5962c3efd3382c121f1a965959cb5b
push id9480
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 17:12:58 +0000
treeherdermozilla-aurora@0d6a91c76a9e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjryans
bugs1241714
milestone48.0a1
Bug 1241714 - Implement a selectable device list that applies settings to the viewport r=jryans
browser/base/content/test/general/browser_parsable_css.js
devtools/client/locales/en-US/responsive.properties
devtools/client/responsive.html/actions/devices.js
devtools/client/responsive.html/actions/index.js
devtools/client/responsive.html/actions/moz.build
devtools/client/responsive.html/actions/viewports.js
devtools/client/responsive.html/app.js
devtools/client/responsive.html/components/device-selector.js
devtools/client/responsive.html/components/moz.build
devtools/client/responsive.html/components/resizable-viewport.js
devtools/client/responsive.html/components/viewport-dimension.js
devtools/client/responsive.html/components/viewport-toolbar.js
devtools/client/responsive.html/components/viewport.js
devtools/client/responsive.html/components/viewports.js
devtools/client/responsive.html/images/moz.build
devtools/client/responsive.html/images/select-arrow.svg
devtools/client/responsive.html/index.css
devtools/client/responsive.html/index.js
devtools/client/responsive.html/moz.build
devtools/client/responsive.html/reducers.js
devtools/client/responsive.html/reducers/devices.js
devtools/client/responsive.html/reducers/moz.build
devtools/client/responsive.html/reducers/viewports.js
devtools/client/responsive.html/responsive-ua.css
devtools/client/responsive.html/test/browser/browser.ini
devtools/client/responsive.html/test/browser/browser_devices.json
devtools/client/responsive.html/test/browser/head.js
devtools/client/responsive.html/test/unit/test_add_device.js
devtools/client/responsive.html/test/unit/test_add_device_type.js
devtools/client/responsive.html/test/unit/test_change_viewport_device.js
devtools/client/responsive.html/test/unit/xpcshell.ini
devtools/client/responsive.html/types.js
--- a/browser/base/content/test/general/browser_parsable_css.js
+++ b/browser/base/content/test/general/browser_parsable_css.js
@@ -22,16 +22,19 @@ const kWhitelist = [
   // Loop standalone client CSS uses placeholder cross browser pseudo-element
   {sourceName: /loop\/.*\.css$/i,
    errorMessage: /Unknown pseudo-class.*placeholder/i},
   {sourceName: /loop\/.*shared\/css\/common.css$/i,
    errorMessage: /Unknown property 'user-select'/i},
   // Highlighter CSS uses a UA-only pseudo-class, see bug 985597.
   {sourceName: /highlighters\.css$/i,
    errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i},
+  // Responsive Design Mode CSS uses a UA-only pseudo-class, see Bug 1241714.
+  {sourceName: /responsive-ua\.css$/i,
+   errorMessage: /Unknown pseudo-class.*moz-dropdown-list/i},
 ];
 
 var moduleLocation = gTestPath.replace(/\/[^\/]*$/i, "/parsingTestHelpers.jsm");
 var {generateURIsFromDirTree} = Cu.import(moduleLocation, {});
 
 // Add suffix to stylesheets' URI so that we always load them here and
 // have them parsed. Add a random number so that even if we run this
 // test multiple times, it would be unlikely to affect each other.
--- a/devtools/client/locales/en-US/responsive.properties
+++ b/devtools/client/locales/en-US/responsive.properties
@@ -12,8 +12,12 @@
 # documentation on web development on the web.
 
 # LOCALIZATION NOTE  (responsive.title): the title displayed in the global
 # toolbar
 responsive.title=Responsive Design Mode
 
 # LOCALIZATION NOTE (responsive.exit): tooltip text of the exit button.
 responsive.exit=Close Responsive Design Mode
+
+# LOCALIZATION NOTE (responsive.noDeviceSelected): placeholder text for the
+# device selector
+responsive.noDeviceSelected=no device selected
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/actions/devices.js
@@ -0,0 +1,29 @@
+/* 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/. */
+
+"use strict";
+
+const {
+  ADD_DEVICE,
+  ADD_DEVICE_TYPE,
+} = require("./index");
+
+module.exports = {
+
+  addDevice(device, deviceType) {
+    return {
+      type: ADD_DEVICE,
+      device,
+      deviceType,
+    };
+  },
+
+  addDeviceType(deviceType) {
+    return {
+      type: ADD_DEVICE_TYPE,
+      deviceType,
+    };
+  },
+
+};
--- a/devtools/client/responsive.html/actions/index.js
+++ b/devtools/client/responsive.html/actions/index.js
@@ -5,23 +5,32 @@
 "use strict";
 
 // This file lists all of the actions available in responsive design.  This
 // central list of constants makes it easy to see all possible action names at
 // a glance.  Please add a comment with each new action type.
 
 createEnum([
 
+  // Add a new device.
+  "ADD_DEVICE",
+
+  // Add a new device type.
+  "ADD_DEVICE_TYPE",
+
+  // Add an additional viewport to display the document.
+  "ADD_VIEWPORT",
+
+  // Change the device displayed in the viewport.
+  "CHANGE_DEVICE",
+
   // The location of the page has changed.  This may be triggered by the user
   // directly entering a new URL, navigating with links, etc.
   "CHANGE_LOCATION",
 
-  // Add an additional viewport to display the document.
-  "ADD_VIEWPORT",
-
   // Resize the viewport.
   "RESIZE_VIEWPORT",
 
   // Rotate the viewport.
   "ROTATE_VIEWPORT",
 
 ], module.exports);
 
--- a/devtools/client/responsive.html/actions/moz.build
+++ b/devtools/client/responsive.html/actions/moz.build
@@ -1,11 +1,12 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # 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(
+    'devices.js',
     'index.js',
     'location.js',
     'viewports.js',
 )
--- a/devtools/client/responsive.html/actions/viewports.js
+++ b/devtools/client/responsive.html/actions/viewports.js
@@ -1,32 +1,44 @@
 /* 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/. */
 
 "use strict";
 
 const {
   ADD_VIEWPORT,
+  CHANGE_DEVICE,
   RESIZE_VIEWPORT,
   ROTATE_VIEWPORT
 } = require("./index");
 
 module.exports = {
 
   /**
    * Add an additional viewport to display the document.
    */
   addViewport() {
     return {
       type: ADD_VIEWPORT,
     };
   },
 
   /**
+   * Change the viewport device.
+   */
+  changeDevice(id, device) {
+    return {
+      type: CHANGE_DEVICE,
+      id,
+      device,
+    };
+  },
+
+  /**
    * Resize the viewport.
    */
   resizeViewport(id, width, height) {
     return {
       type: RESIZE_VIEWPORT,
       id,
       width,
       height,
--- a/devtools/client/responsive.html/app.js
+++ b/devtools/client/responsive.html/app.js
@@ -3,61 +3,74 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { createClass, createFactory, PropTypes, DOM: dom } =
   require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 
-const { resizeViewport, rotateViewport } = require("./actions/viewports");
+const {
+  changeDevice,
+  resizeViewport,
+  rotateViewport
+} = require("./actions/viewports");
 const Types = require("./types");
 const Viewports = createFactory(require("./components/viewports"));
 const GlobalToolbar = createFactory(require("./components/global-toolbar"));
 
 let App = createClass({
 
   displayName: "App",
 
   propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
     onExit: PropTypes.func.isRequired,
   },
 
+  onChangeViewportDevice(id, device) {
+    this.props.dispatch(changeDevice(id, device));
+  },
+
+  onResizeViewport(id, width, height) {
+    this.props.dispatch(resizeViewport(id, width, height));
+  },
+
   onRotateViewport(id) {
     this.props.dispatch(rotateViewport(id));
   },
 
-  onResizeViewport(id, width, height) {
-    this.props.dispatch(resizeViewport(id, width, height));
-  },
-
   render() {
     let {
+      devices,
       location,
       viewports,
       onExit,
     } = this.props;
 
     let {
+      onChangeViewportDevice,
+      onResizeViewport,
       onRotateViewport,
-      onResizeViewport,
     } = this;
 
     return dom.div(
       {
         id: "app",
       },
       GlobalToolbar({
         onExit,
       }),
       Viewports({
+        devices,
         location,
         viewports,
+        onChangeViewportDevice,
         onRotateViewport,
         onResizeViewport,
       })
     );
   },
 
 });
 
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/components/device-selector.js
@@ -0,0 +1,83 @@
+/* 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/. */
+
+"use strict";
+
+const { getStr } = require("./utils/l10n");
+const { DOM: dom, createClass, PropTypes, addons } =
+  require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+
+module.exports = createClass({
+
+  displayName: "DeviceSelector",
+
+  propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
+    selectedDevice: PropTypes.string.isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
+    onResizeViewport: PropTypes.func.isRequired,
+  },
+
+  mixins: [ addons.PureRenderMixin ],
+
+  onSelectChange({ target }) {
+    let {
+      devices,
+      onChangeViewportDevice,
+      onResizeViewport,
+    } = this.props;
+
+    for (let type of devices.types) {
+      for (let device of devices[type]) {
+        if (device.name === target.value) {
+          onResizeViewport(device.width, device.height);
+          break;
+        }
+      }
+    }
+
+    onChangeViewportDevice(target.value);
+  },
+
+  render() {
+    let {
+      devices,
+      selectedDevice,
+    } = this.props;
+
+    let options = [];
+    for (let type of devices.types) {
+      for (let device of devices[type]) {
+        options.push(device);
+      }
+    }
+
+    let selectClass = "viewport-device-selector";
+    if (selectedDevice) {
+      selectClass += " selected";
+    }
+
+    return dom.select(
+      {
+        className: selectClass,
+        value: selectedDevice,
+        onChange: this.onSelectChange,
+      },
+      dom.option({
+        value: "",
+        disabled: true,
+        hidden: true,
+      }, getStr("responsive.noDeviceSelected")),
+      options.map(device => {
+        return dom.option({
+          key: device.name,
+          value: device.name,
+        }, device.name);
+      })
+    );
+  },
+
+});
--- a/devtools/client/responsive.html/components/moz.build
+++ b/devtools/client/responsive.html/components/moz.build
@@ -5,15 +5,16 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
     'utils',
 ]
 
 DevToolsModules(
     'browser.js',
+    'device-selector.js',
     'global-toolbar.js',
     'resizable-viewport.js',
     'viewport-dimension.js',
     'viewport-toolbar.js',
     'viewport.js',
     'viewports.js',
 )
--- a/devtools/client/responsive.html/components/resizable-viewport.js
+++ b/devtools/client/responsive.html/components/resizable-viewport.js
@@ -17,18 +17,20 @@ const ViewportToolbar = createFactory(re
 const VIEWPORT_MIN_WIDTH = Constants.MIN_VIEWPORT_DIMENSION;
 const VIEWPORT_MIN_HEIGHT = Constants.MIN_VIEWPORT_DIMENSION;
 
 module.exports = createClass({
 
   displayName: "ResizableViewport",
 
   propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
   },
 
   getInitialState() {
     return {
       isResizing: false,
       lastClientX: 0,
@@ -92,35 +94,44 @@ module.exports = createClass({
     if (height < VIEWPORT_MIN_HEIGHT) {
       height = VIEWPORT_MIN_HEIGHT;
     } else {
       lastClientY = clientY;
     }
 
     // Update the viewport store with the new width and height.
     this.props.onResizeViewport(width, height);
+    // Change the device selector back to an unselected device
+    this.props.onChangeViewportDevice("");
 
     this.setState({
       lastClientX,
       lastClientY
     });
   },
 
   render() {
     let {
+      devices,
       location,
       viewport,
+      onChangeViewportDevice,
+      onResizeViewport,
       onRotateViewport,
     } = this.props;
 
     return dom.div(
       {
         className: "resizable-viewport",
       },
       ViewportToolbar({
+        devices,
+        selectedDevice: viewport.device,
+        onChangeViewportDevice,
+        onResizeViewport,
         onRotateViewport,
       }),
       Browser({
         location,
         width: viewport.width,
         height: viewport.height,
         isResizing: this.state.isResizing
       }),
--- a/devtools/client/responsive.html/components/viewport-dimension.js
+++ b/devtools/client/responsive.html/components/viewport-dimension.js
@@ -11,16 +11,17 @@ const Constants = require("../constants"
 const Types = require("../types");
 
 module.exports = createClass({
 
   displayName: "ViewportDimension",
 
   propTypes: {
     viewport: PropTypes.shape(Types.viewport).isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
   },
 
   getInitialState() {
     let { width, height } = this.props.viewport;
 
     return {
       width,
@@ -49,17 +50,21 @@ module.exports = createClass({
     }
 
     this.setState({
       isInvalid,
     });
   },
 
   onInputBlur() {
-    this.onInputSubmit();
+    let { width, height } = this.props.viewport;
+
+    if (this.state.width != width || this.state.height != height) {
+      this.onInputSubmit();
+    }
 
     this.setState({
       isEditing: false,
       inInvalid: false,
     });
   },
 
   onInputChange({ target }) {
@@ -104,16 +109,18 @@ module.exports = createClass({
         width,
         height,
         isInvalid: false,
       });
 
       return;
     }
 
+    // Change the device selector back to an unselected device
+    this.props.onChangeViewportDevice("");
     this.props.onResizeViewport(parseInt(this.state.width, 10),
                                 parseInt(this.state.height, 10));
   },
 
   render() {
     let editableClass = "viewport-dimension-editable";
     let inputClass = "viewport-dimension-input";
 
--- a/devtools/client/responsive.html/components/viewport-toolbar.js
+++ b/devtools/client/responsive.html/components/viewport-toolbar.js
@@ -1,36 +1,53 @@
 /* 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/. */
 
 "use strict";
 
-const { DOM: dom, createClass, PropTypes, addons } =
+const { DOM: dom, createClass, createFactory, PropTypes, addons } =
   require("devtools/client/shared/vendor/react");
 
+const Types = require("../types");
+const DeviceSelector = createFactory(require("./device-selector"));
+
 module.exports = createClass({
 
   displayName: "ViewportToolbar",
 
   mixins: [ addons.PureRenderMixin ],
 
   propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
+    selectedDevice: PropTypes.string.isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
+    onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
   },
 
   render() {
     let {
+      devices,
+      selectedDevice,
+      onChangeViewportDevice,
+      onResizeViewport,
       onRotateViewport,
     } = this.props;
 
     return dom.div(
       {
         className: "viewport-toolbar",
       },
+      DeviceSelector({
+        devices,
+        selectedDevice,
+        onChangeViewportDevice,
+        onResizeViewport,
+      }),
       dom.button({
         className: "viewport-rotate-button toolbar-button devtools-button",
         onClick: onRotateViewport,
       })
     );
   },
 
 });
--- a/devtools/client/responsive.html/components/viewport.js
+++ b/devtools/client/responsive.html/components/viewport.js
@@ -11,22 +11,33 @@ const Types = require("../types");
 const ResizableViewport = createFactory(require("./resizable-viewport"));
 const ViewportDimension = createFactory(require("./viewport-dimension"));
 
 module.exports = createClass({
 
   displayName: "Viewport",
 
   propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
   },
 
+  onChangeViewportDevice(device) {
+    let {
+      viewport,
+      onChangeViewportDevice,
+    } = this.props;
+
+    onChangeViewportDevice(viewport.id, device);
+  },
+
   onResizeViewport(width, height) {
     let {
       viewport,
       onResizeViewport,
     } = this.props;
 
     onResizeViewport(viewport.id, width, height);
   },
@@ -37,35 +48,40 @@ module.exports = createClass({
       onRotateViewport,
     } = this.props;
 
     onRotateViewport(viewport.id);
   },
 
   render() {
     let {
+      devices,
       location,
       viewport,
     } = this.props;
 
     let {
+      onChangeViewportDevice,
       onRotateViewport,
       onResizeViewport,
     } = this;
 
     return dom.div(
       {
         className: "viewport",
       },
       ResizableViewport({
+        devices,
         location,
         viewport,
+        onChangeViewportDevice,
         onResizeViewport,
         onRotateViewport,
       }),
       ViewportDimension({
         viewport,
+        onChangeViewportDevice,
         onResizeViewport,
       })
     );
   },
 
 });
--- a/devtools/client/responsive.html/components/viewports.js
+++ b/devtools/client/responsive.html/components/viewports.js
@@ -10,39 +10,45 @@ const { DOM: dom, createClass, createFac
 const Types = require("../types");
 const Viewport = createFactory(require("./viewport"));
 
 module.exports = createClass({
 
   displayName: "Viewports",
 
   propTypes: {
+    devices: PropTypes.shape(Types.devices).isRequired,
     location: Types.location.isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
+    onChangeViewportDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
   },
 
   render() {
     let {
+      devices,
       location,
       viewports,
+      onChangeViewportDevice,
       onResizeViewport,
       onRotateViewport,
     } = this.props;
 
     return dom.div(
       {
         id: "viewports",
       },
       viewports.map(viewport => {
         return Viewport({
           key: viewport.id,
+          devices,
           location,
           viewport,
+          onChangeViewportDevice,
           onResizeViewport,
           onRotateViewport,
         });
       })
     );
   },
 
 });
--- a/devtools/client/responsive.html/images/moz.build
+++ b/devtools/client/responsive.html/images/moz.build
@@ -3,9 +3,10 @@
 # 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(
     'close.svg',
     'grippers.svg',
     'rotate-viewport.svg',
+    'select-arrow.svg',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/images/select-arrow.svg
@@ -0,0 +1,29 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
+  <defs>
+    <style>
+      use:not(:target) {
+        display: none;
+      }
+      #light {
+        fill: #dde1e4; /* --theme-splitter-color */
+      }
+      #light-selected {
+        fill: #393f4c; /* --theme-body-color */
+      }
+      #dark {
+        fill: #8fa1b2; /* --theme-body-color */
+      }
+      #dark-selected {
+        fill: #f5f7fa; /* --theme-selection-color */
+      }
+    </style>
+    <path id="base-path" d="M7.9 16.3c-.3 0-.6-.1-.8-.4l-4-4.8c-.2-.3-.3-.5-.1-.8.1-.3.5-.3.9-.3h8c.4 0 .7 0 .9.3.2.4.1.6-.1.9l-4 4.8c-.2.3-.5.3-.8.3zM7.8 0c.3 0 .6.1.7.4L12.4 5c.2.3.3.4.1.7-.1.4-.5.3-.8.3H3.9c-.4 0-.8.1-.9-.2-.2-.4-.1-.6.1-.9L7 .3c.2-.3.5-.3.8-.3z"/>
+  </defs>
+  <use xlink:href="#base-path" id="light"/>
+  <use xlink:href="#base-path" id="light-selected"/>
+  <use xlink:href="#base-path" id="dark"/>
+  <use xlink:href="#base-path" id="dark-selected"/>
+</svg>
--- a/devtools/client/responsive.html/index.css
+++ b/devtools/client/responsive.html/index.css
@@ -2,24 +2,30 @@
  * React component group on how to best handle CSS. */
 
 /**
  * CSS Variables specific to the responsive design mode
  */
 
 .theme-light {
   --box-shadow: 0 4px 4px 0 rgba(155, 155, 155, 0.26);
-  --viewport-dimension-color: var(--theme-splitter-color);
-  --viewport-dimension-editing-color: var(--theme-body-color);
+  --viewport-color: var(--theme-splitter-color);
+  --viewport-active-color: var(--theme-body-color);
+  --viewport-selection-arrow: url("./images/select-arrow.svg#light");
+  --viewport-selection-arrow-selected:
+    url("./images/select-arrow.svg#light-selected");
 }
 
 .theme-dark {
   --box-shadow: 0 4px 4px 0 rgba(105, 105, 105, 0.26);
-  --viewport-dimension-color: var(--theme-body-color);
-  --viewport-dimension-editing-color: var(--theme-selection-color);
+  --viewport-color: var(--theme-body-color);
+  --viewport-active-color: var(--theme-selection-color);
+  --viewport-selection-arrow: url("./images/select-arrow.svg#dark");
+  --viewport-selection-arrow-selected:
+    url("./images/select-arrow.svg#dark-selected");
 }
 
 * {
   box-sizing: border-box;
 }
 
 #root,
 html, body {
@@ -130,20 +136,60 @@ body {
  */
 
 .viewport-toolbar {
   background-color: var(--theme-toolbar-background);
   border-bottom: 1px solid var(--theme-splitter-color);
   color: var(--theme-body-color);
   display: flex;
   flex-direction: row;
-  justify-content: flex-end;
+  justify-content: center;
   height: 18px;
 }
 
+.viewport-device-selector {
+  -moz-appearance: none;
+  background-color: var(--theme-toolbar-background);
+  background-image: var(--viewport-selection-arrow);
+  background-position: 136px;
+  background-repeat: no-repeat;
+  background-size: 7px;
+  border: none;
+  color: var(--viewport-color);
+  height: 100%;
+  padding: 0 16px;
+  text-align: center;
+  text-overflow: ellipsis;
+  width: 150px;
+}
+
+.viewport-device-selector.selected {
+  background-image: var(--viewport-selection-arrow-selected);
+  color: var(--viewport-active-color);
+}
+
+.viewport-device-selector:focus {
+  background-image: var(--viewport-selection-arrow-selected);
+  /* Remove the outline from the select box */
+  color: transparent;
+  text-shadow: 0 0 0 var(--viewport-active-color);
+}
+
+.viewport-device-selector > option {
+  background-color: var(--theme-toolbar-background);
+  color: var(--viewport-active-color);
+  text-align: left;
+  padding: 5px;
+}
+
+.viewport-rotate-button {
+  position: absolute;
+  right: 0;
+}
+
 .viewport-rotate-button::before {
   background-image: url("./images/rotate-viewport.svg");
 }
 
 /**
  * Viewport Browser
  */
 
@@ -204,23 +250,23 @@ body {
 }
 
 .viewport-dimension-editable {
   border-bottom: 1px solid transparent;
 }
 
 .viewport-dimension-editable,
 .viewport-dimension-input {
-  color: var(--viewport-dimension-color);
+  color: var(--viewport-color);
   transition: all 0.25s ease;
 }
 
 .viewport-dimension-editable.editing,
 .viewport-dimension-input.editing {
-  color: var(--viewport-dimension-editing-color);
+  color: var(--viewport-active-color);
 }
 
 .viewport-dimension-editable.editing {
   border-bottom: 1px solid var(--theme-selection-background);
 }
 
 .viewport-dimension-editable.editing.invalid {
   border-bottom: 1px solid #d92215;
--- a/devtools/client/responsive.html/index.js
+++ b/devtools/client/responsive.html/index.js
@@ -8,35 +8,43 @@
 
 const { utils: Cu } = Components;
 const { BrowserLoader } =
   Cu.import("resource://devtools/client/shared/browser-loader.js", {});
 const { require } = BrowserLoader({
   baseURI: "resource://devtools/client/responsive.html/",
   window: this
 });
+const { GetDevices } = require("devtools/client/shared/devices");
 const Telemetry = require("devtools/client/shared/telemetry");
 
 const { createFactory, createElement } =
   require("devtools/client/shared/vendor/react");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
 const App = createFactory(require("./app"));
 const Store = require("./store");
+const { addDevice, addDeviceType } = require("./actions/devices");
 const { changeLocation } = require("./actions/location");
 const { addViewport } = require("./actions/viewports");
+const { loadSheet } = require("sdk/stylesheet/utils");
 
 let bootstrap = {
 
   telemetry: new Telemetry(),
 
   store: null,
 
   init() {
+    // Load a special UA stylesheet to reset certain styles such as dropdown
+    // lists.
+    loadSheet(window,
+              "resource://devtools/client/responsive.html/responsive-ua.css",
+              "agent");
     this.telemetry.toolOpened("responsive");
     let store = this.store = Store();
     let app = App({
       onExit: () => window.postMessage({type: "exit"}, "*"),
     });
     let provider = createElement(Provider, { store }, app);
     ReactDOM.render(provider, document.querySelector("#root"));
   },
@@ -78,13 +86,25 @@ Object.defineProperty(window, "store", {
 });
 
 /**
  * Called by manager.js to add the initial viewport based on the original page.
  */
 window.addInitialViewport = contentURI => {
   try {
     bootstrap.dispatch(changeLocation(contentURI));
+
+    GetDevices().then(devices => {
+      for (let type of devices.TYPES) {
+        bootstrap.dispatch(addDeviceType(type));
+        for (let device of devices[type]) {
+          if (device.os != "fxos") {
+            bootstrap.dispatch(addDevice(device, type));
+          }
+        }
+      }
+    });
+
     bootstrap.dispatch(addViewport());
   } catch (e) {
     console.error(e);
   }
 };
--- a/devtools/client/responsive.html/moz.build
+++ b/devtools/client/responsive.html/moz.build
@@ -12,14 +12,15 @@ DIRS += [
 ]
 
 DevToolsModules(
     'app.js',
     'constants.js',
     'index.css',
     'manager.js',
     'reducers.js',
+    'responsive-ua.css',
     'store.js',
     'types.js',
 )
 
 XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
--- a/devtools/client/responsive.html/reducers.js
+++ b/devtools/client/responsive.html/reducers.js
@@ -1,8 +1,9 @@
 /* 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/. */
 
 "use strict";
 
+exports.devices = require("./reducers/devices");
 exports.location = require("./reducers/location");
 exports.viewports = require("./reducers/viewports");
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/devices.js
@@ -0,0 +1,39 @@
+/* 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/. */
+
+"use strict";
+
+const {
+  ADD_DEVICE,
+  ADD_DEVICE_TYPE,
+} = require("../actions/index");
+
+const INITIAL_DEVICES = {
+  types: [],
+};
+
+let reducers = {
+
+  [ADD_DEVICE](devices, { device, deviceType }) {
+    return Object.assign({}, devices, {
+      [deviceType]: [...devices[deviceType], device],
+    });
+  },
+
+  [ADD_DEVICE_TYPE](devices, { deviceType }) {
+    return Object.assign({}, devices, {
+      types: [...devices.types, deviceType],
+      [deviceType]: [],
+    });
+  },
+
+};
+
+module.exports = function(devices = INITIAL_DEVICES, action) {
+  let reducer = reducers[action.type];
+  if (!reducer) {
+    return devices;
+  }
+  return reducer(devices, action);
+};
--- a/devtools/client/responsive.html/reducers/moz.build
+++ b/devtools/client/responsive.html/reducers/moz.build
@@ -1,10 +1,11 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # 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(
+    'devices.js',
     'location.js',
     'viewports.js',
 )
--- a/devtools/client/responsive.html/reducers/viewports.js
+++ b/devtools/client/responsive.html/reducers/viewports.js
@@ -1,39 +1,53 @@
 /* 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/. */
 
 "use strict";
 
 const {
   ADD_VIEWPORT,
+  CHANGE_DEVICE,
   RESIZE_VIEWPORT,
   ROTATE_VIEWPORT,
 } = require("../actions/index");
 
 let nextViewportId = 0;
 
 const INITIAL_VIEWPORTS = [];
 const INITIAL_VIEWPORT = {
   id: nextViewportId++,
+  device: "",
   width: 320,
   height: 480,
 };
 
 let reducers = {
 
   [ADD_VIEWPORT](viewports) {
     // For the moment, there can be at most one viewport.
     if (viewports.length === 1) {
       return viewports;
     }
     return [...viewports, Object.assign({}, INITIAL_VIEWPORT)];
   },
 
+  [CHANGE_DEVICE](viewports, { id, device }) {
+    return viewports.map(viewport => {
+      if (viewport.id !== id) {
+        return viewport;
+      }
+
+      return Object.assign({}, viewport, {
+        device,
+      });
+    });
+  },
+
   [RESIZE_VIEWPORT](viewports, { id, width, height }) {
     return viewports.map(viewport => {
       if (viewport.id !== id) {
         return viewport;
       }
 
       return Object.assign({}, viewport, {
         width,
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/responsive-ua.css
@@ -0,0 +1,6 @@
+@namespace url(http://www.w3.org/1999/xhtml);
+
+/* Reset default UA styles for dropdown options */
+*|*::-moz-dropdown-list {
+  border: 0 !important;
+}
--- a/devtools/client/responsive.html/test/browser/browser.ini
+++ b/devtools/client/responsive.html/test/browser/browser.ini
@@ -1,8 +1,9 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
 support-files =
+  browser_devices.json
   head.js
 
 [browser_exit_button.js]
 [browser_viewport_basics.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_devices.json
@@ -0,0 +1,25 @@
+{
+  "TYPES": [ "phones" ],
+  "phones": [
+    {
+      "name": "Firefox OS Flame",
+      "width": 320,
+      "height": 570,
+      "pixelRatio": 1.5,
+      "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+      "touch": true,
+      "firefoxOS": true,
+      "os": "fxos"
+    },
+    {
+      "name": "Alcatel One Touch Fire",
+      "width": 320,
+      "height": 480,
+      "pixelRatio": 1,
+      "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0",
+      "touch": true,
+      "firefoxOS": true,
+      "os": "fxos"
+    },
+  ],
+}
--- a/devtools/client/responsive.html/test/browser/head.js
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -9,18 +9,26 @@
 
 Services.scriptloader.loadSubScript(
   "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js",
   this);
 Services.scriptloader.loadSubScript(
   "chrome://mochitests/content/browser/devtools/client/framework/test/shared-redux-head.js",
   this);
 
+const TEST_URI_ROOT = "http://example.com/browser/devtools/client/responsive.html/test/browser/";
+
+DevToolsUtils.testing = true;
+Services.prefs.setCharPref("devtools.devices.url",
+  TEST_URI_ROOT + "browser_devices.json");
 Services.prefs.setBoolPref("devtools.responsive.html.enabled", true);
+
 registerCleanupFunction(() => {
+  DevToolsUtils.testing = false;
+  Services.pref.clearUserPref("devtools.devices.url");
   Services.prefs.clearUserPref("devtools.responsive.html.enabled");
 });
 const { ResponsiveUIManager } = Cu.import("resource://devtools/client/responsivedesign/responsivedesign.jsm", {});
 
 /**
  * Open responsive design mode for the given tab.
  */
 var openRDM = Task.async(function*(tab) {
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_add_device.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding a new device.
+
+const {
+  addDevice,
+  addDeviceType,
+} = require("devtools/client/responsive.html/actions/devices");
+
+add_task(function*() {
+  let store = Store();
+  const { getState, dispatch } = store;
+
+  let device = {
+    "name": "Firefox OS Flame",
+    "width": 320,
+    "height": 570,
+    "pixelRatio": 1.5,
+    "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+    "touch": true,
+    "firefoxOS": true,
+    "os": "fxos"
+  };
+
+  dispatch(addDeviceType("phones"));
+  dispatch(addDevice(device, "phones"));
+
+  equal(getState().devices.phones.length, 1,
+    "Correct number of phones");
+  ok(getState().devices.phones.includes(device),
+    "Device phone list contains Firefox OS Flame");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_add_device_type.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding a new device type.
+
+const { addDeviceType } =
+  require("devtools/client/responsive.html/actions/devices");
+
+add_task(function*() {
+  let store = Store();
+  const { getState, dispatch } = store;
+
+  dispatch(addDeviceType("phones"));
+
+  equal(getState().devices.types.length, 1, "Correct number of device types");
+  equal(getState().devices.phones.length, 0,
+    "Defaults to an empty array of phones");
+  ok(getState().devices.types.includes("phones"),
+    "Device types contain phones");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_viewport_device.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the viewport device.
+
+const {
+  addDevice,
+  addDeviceType,
+} = require("devtools/client/responsive.html/actions/devices");
+const {
+  addViewport,
+  changeDevice,
+} = require("devtools/client/responsive.html/actions/viewports");
+
+add_task(function*() {
+  let store = Store();
+  const { getState, dispatch } = store;
+
+  dispatch(addDeviceType("phones"));
+  dispatch(addDevice({
+    "name": "Firefox OS Flame",
+    "width": 320,
+    "height": 570,
+    "pixelRatio": 1.5,
+    "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+    "touch": true,
+    "firefoxOS": true,
+    "os": "fxos"
+  }, "phones"));
+  dispatch(addViewport());
+
+  let viewport = getState().viewports[0];
+  equal(viewport.device, "", "Default device is unselected");
+
+  dispatch(changeDevice(0, "Firefox OS Flame"));
+
+  viewport = getState().viewports[0];
+  equal(viewport.device, "Firefox OS Flame",
+    "Changed to Firefox OS Flame device");
+});
--- a/devtools/client/responsive.html/test/unit/xpcshell.ini
+++ b/devtools/client/responsive.html/test/unit/xpcshell.ini
@@ -1,10 +1,13 @@
 [DEFAULT]
 tags = devtools
 head = head.js ../../../framework/test/shared-redux-head.js
 tail =
 firefox-appdir = browser
 
+[test_add_device.js]
+[test_add_device_type.js]
 [test_add_viewport.js]
 [test_change_location.js]
+[test_change_viewport_device.js]
 [test_resize_viewport.js]
 [test_rotate_viewport.js]
--- a/devtools/client/responsive.html/types.js
+++ b/devtools/client/responsive.html/types.js
@@ -5,27 +5,86 @@
 "use strict";
 
 const { PropTypes } = require("devtools/client/shared/vendor/react");
 
 // React PropTypes are used to describe the expected "shape" of various common
 // objects that get passed down as props to components.
 
 /**
+ * A single device that can be displayed in the viewport.
+ */
+const device = {
+
+  // The name of the device
+  name: PropTypes.string,
+
+  // The width of the device
+  width: PropTypes.number,
+
+  // The height of the device
+  height: PropTypes.number,
+
+  // The pixel ratio of the device
+  pixelRatio: PropTypes.number,
+
+  // The user agent string of the device
+  userAgent: PropTypes.string,
+
+  // Whether or not it is a touch device
+  touch: PropTypes.bool,
+
+  //  The operating system of the device
+  os: PropTypes.String,
+
+};
+
+/**
+ * A list of devices and their types that can be displayed in the viewport.
+ */
+exports.devices = {
+
+  // An array of device types
+  types: PropTypes.arrayOf(PropTypes.string),
+
+  // An array of phone devices
+  phones: PropTypes.arrayOf(PropTypes.shape(device)),
+
+  // An array of tablet devices
+  tablets: PropTypes.arrayOf(PropTypes.shape(device)),
+
+  // An array of laptop devices
+  laptops: PropTypes.arrayOf(PropTypes.shape(device)),
+
+  // An array of television devices
+  televisions: PropTypes.arrayOf(PropTypes.shape(device)),
+
+  // An array of console devices
+  consoles: PropTypes.arrayOf(PropTypes.shape(device)),
+
+  // An array of watch devices
+  watches: PropTypes.arrayOf(PropTypes.shape(device)),
+
+};
+
+/**
+ * The location of the document displayed in the viewport(s).
+ */
+exports.location = PropTypes.string;
+
+/**
  * A single viewport displaying a document.
  */
 exports.viewport = {
 
   // The id of the viewport
   id: PropTypes.number.isRequired,
 
+  // The currently selected device applied to the viewport.
+  device: PropTypes.string,
+
   // The width of the viewport
   width: PropTypes.number,
 
   // The height of the viewport
   height: PropTypes.number,
 
 };
-
-/**
- * The location of the document displayed in the viewport(s).
- */
-exports.location = PropTypes.string;