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 289599 4eea3b603b0aca9c157dc2e9f3bb3c0d9f23b1c3
parent 289598 3ce5d23d337f45ae96100c00ab994b53d202d7c4
child 289600 dd6593bfbb5962c3efd3382c121f1a965959cb5b
push id30107
push usercbook@mozilla.com
push dateTue, 22 Mar 2016 10:00:23 +0000
treeherdermozilla-central@3587b25bae30 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjryans
bugs1241714
milestone48.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 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;