Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
new file mode 100644
index 0000000..a7870f6
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
@@ -0,0 +1,151 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+
+/**
+ * `<mr-change-columns>`
+ *
+ * Dialog where the user can change columns on the list view.
+ *
+ */
+export class MrChangeColumns extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .edit-actions {
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        .input-grid {
+          align-items: center;
+          width: 800px;
+          max-width: 100%;
+        }
+        input {
+          box-sizing: border-box;
+          padding: 0.25em 4px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">Change list columns</h3>
+        <form id="changeColumns" @submit=${this._save}>
+          <div class="input-grid">
+            <label for="columnsInput">Columns: </label>
+            <input
+              id="columnsInput"
+              placeholder="Edit columns..."
+              value=${this.columns.join(' ')}
+            />
+          </div>
+          <div class="edit-actions">
+            <chops-button
+              @click=${this.close}
+              class="de-emphasized discard-button"
+            >
+              Discard
+            </chops-button>
+            <chops-button
+              @click=${this._save}
+              class="emphasized"
+            >
+              Update columns
+            </chops-button>
+          </div>
+        </form>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of the currently configured issue columns, used to set
+       * the default value.
+       */
+      columns: {type: Array},
+      /**
+       * Parsed query params for the current page, to be used in
+       * navigation.
+       */
+      queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.columns = [];
+    this.queryParams = {};
+
+    this._page = page;
+  }
+
+  /**
+   * Abstract out the computation of the current page. Useful for testing.
+   */
+  get _currentPage() {
+    return window.location.pathname;
+  }
+
+  /** Updates the URL query params with the new columns. */
+  save() {
+    const input = this.shadowRoot.querySelector('#columnsInput');
+    const newColumns = parseColSpec(input.value);
+
+    const params = {...this.queryParams};
+    params.colspec = newColumns.join('+');
+
+    // TODO(zhangtiff): Create a shared function to change only
+    // query params in a URL.
+    this._page(`${this._currentPage}?${qs.stringify(params)}`);
+
+    this.close();
+  }
+
+  /**
+   * Handles form submit events.
+   * @param {Event} e A click or submit event.
+   */
+  _save(e) {
+    e.preventDefault();
+    this.save();
+  }
+
+  /** Opens and resets this dialog. */
+  open() {
+    this.reset();
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.open();
+  }
+
+  /** Closes this dialog. */
+  close() {
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.close();
+  }
+
+  /** Resets the form in this dialog. */
+  reset() {
+    this.shadowRoot.querySelector('form').reset();
+  }
+}
+
+customElements.define('mr-change-columns', MrChangeColumns);
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
new file mode 100644
index 0000000..82e529d
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
@@ -0,0 +1,75 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrChangeColumns} from './mr-change-columns.js';
+
+
+let element;
+
+describe('mr-change-columns', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-change-columns');
+    document.body.appendChild(element);
+
+    element._page = sinon.stub();
+    sinon.stub(element, '_currentPage').get(() => '/test');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrChangeColumns);
+  });
+
+  it('input initializes with currently set columns', async () => {
+    element.columns = ['ID', 'Summary'];
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+
+    assert.equal(input.value, 'ID Summary');
+  });
+
+  it('editing input and saving updates columns in URL', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Owner';
+
+    element.save();
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BOwner');
+  });
+
+  it('submitting form updates colspec', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Component';
+
+    // Note: HTMLFormElement.submit() does not fire event listeners.
+    const submitEvent = new Event('submit');
+    sinon.spy(submitEvent, 'preventDefault');
+    const form = element.shadowRoot.querySelector('form');
+    form.dispatchEvent(submitEvent);
+
+    // Preventing default is important to prevent native browser form submit
+    // from causing an additional navigation.
+    sinon.assert.calledOnce(submitEvent.preventDefault);
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BComponent');
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
new file mode 100644
index 0000000..54565cf
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
@@ -0,0 +1,233 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {connectStore} from 'reducers/base.js';
+
+/**
+ * `<mr-issue-hotlists-dialog>`
+ *
+ * The base dialog that <mr-move-issue-hotlists-dialog> and
+ * <mr-update-issue-hotlists-dialog> inherits common methods and behaviors from.
+ * <mr-update-issue-hotlists-dialog> is used across multiple pages where as
+ * <mr-move-issue-hotlists-dialog> is largely used within Hotlists.
+ *
+ * Important: The `render` method should be overridden by child classes.
+ */
+export class MrIssueHotlistsDialog extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          font-size: var(--chops-main-font-size);
+          --chops-dialog-max-width: 500px;
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1px;
+        }
+        select,
+        input {
+          box-sizing: border-box;
+          width: var(--mr-edit-field-width);
+          padding: var(--mr-edit-field-padding);
+          font-size: var(--chops-main-font-size);
+        }
+        input#filter {
+          margin-top: 4px;
+          width: 85%;
+          max-width: 240px;
+        }
+        .user-hotlists {
+          max-height: 240px;
+          overflow: auto;
+        }
+        .hotlist.filter-fail {
+          display: none;
+        }
+        i.material-icons {
+          font-size: 20px;
+          margin-right: 4px;
+          vertical-align: bottom;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+    <chops-dialog closeOnOutsideClick>
+      ${this.renderHeader()}
+      ${this.renderContent()}
+    </chops-dialog>
+    `;
+  }
+
+  /**
+   * Renders the dialog header.
+   * @return {TemplateResult}
+   */
+  renderHeader() {
+    return html`
+      <h3 class="medium-heading">Dialog elements below:</h3>
+    `;
+  }
+
+  /**
+   * Renders the dialog content.
+   * @return {TemplateResult}
+   */
+  renderContent() {
+    return html`
+      ${this.renderFilter()}
+      ${this.renderHotlists()}
+      ${this.renderError()}
+    `;
+  }
+
+  /**
+   * Renders the Hotlist filter.
+   * @return {TemplateResult}
+   */
+  renderFilter() {
+    return html`
+      <input id="filter" type="text" @keyup=${this.filterHotlists}>
+      <i class="material-icons">search</i>
+    `;
+  }
+
+  /**
+   * Renders the user's Hotlists.
+   * @return {TemplateResult}
+   */
+  renderHotlists() {
+    return html`
+      <div class="user-hotlists">
+        ${this.filteredHotlists.length ?
+          this.filteredHotlists.map(this.renderFilteredHotlist, this) : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a user's filtered Hotlist.
+   * @param {HotlistV0} hotlist The user Hotlist to render.
+   * @return {TemplateResult}
+   */
+  renderFilteredHotlist(hotlist) {
+    return html`
+      <div
+        class="hotlist"
+        data-hotlist-name="${hotlist.name}"
+      >
+        ${hotlist.name}
+      </div>`;
+  }
+
+  /**
+   * Renders dialog error.
+   * @return {TemplateResult}
+   */
+  renderError() {
+    return html`
+      <br>
+      ${this.error ? html`
+        <div class="error">${this.error}</div>
+      `: ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      userHotlists: {type: Array},
+      filteredHotlists: {type: Array},
+      issueRefs: {type: Array},
+      error: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.userHotlists = userV0.currentUser(state).hotlists;
+    // TODO(https://crbug.com/monorail/7778): Switch to users.js and use V3 API
+    // to make a call to GatherHotlistsForUser.
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array} */
+    this.userHotlists = [];
+
+    /** @type {Array} */
+    this.filteredHotlists = this.userHotlists;
+
+    /** @type {Array<IssueRef>} */
+    this.issueRefs = [];
+
+    /** @type {string} */
+    this.error = '';
+  }
+
+  /**
+   * Opens the dialog.
+   */
+  open() {
+    this.reset();
+    this.shadowRoot.querySelector('chops-dialog').open();
+  }
+
+  /**
+   * Resets any changes to the form and error.
+   */
+  reset() {
+    this.error = '';
+    const filter = this.shadowRoot.querySelector('#filter');
+    filter.value = '';
+    this.filterHotlists();
+  }
+
+  /**
+   * Closes the dialog.
+   */
+  close() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  /**
+   * Filters the visible Hotlists with the given user input.
+   * Requires filter to be an input element with its id as "filter".
+   */
+  filterHotlists() {
+    const input = this.shadowRoot.querySelector('#filter');
+    if (!input) {
+      // Short circuit because there's no filter.
+      this.filteredHotlists = this.userHotlists;
+    } else {
+      const filter = input.value.toLowerCase();
+      const visibleHotlists = [];
+      this.userHotlists.forEach((hotlist) => {
+        const hotlistName = hotlist.name.toLowerCase();
+        if (hotlistName.includes(filter)) {
+          visibleHotlists.push(hotlist);
+        }
+      });
+      this.filteredHotlists = visibleHotlists;
+    }
+  }
+}
+
+customElements.define('mr-issue-hotlists-dialog', MrIssueHotlistsDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..911c1a0
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
@@ -0,0 +1,78 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog.js';
+
+let element;
+const EXAMPLE_USER_HOTLISTS = [
+  {name: 'Hotlist-1'},
+  {name: 'Hotlist-2'},
+  {name: 'ac-apple-1'},
+  {name: 'ac-frita-1'},
+];
+
+describe('mr-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    await element.updateComplete;
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueHotlistsDialog);
+    assert.include(element.shadowRoot.innerHTML, 'Dialog elements below');
+  });
+
+  it('filters hotlists', async () => {
+    element.userHotlists = EXAMPLE_USER_HOTLISTS;
+    element.open();
+    await element.updateComplete;
+
+    const initialHotlists = element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(initialHotlists.length, 4);
+    const filterInput = element.shadowRoot.querySelector('#filter');
+    filterInput.value = 'list';
+    element.filterHotlists();
+    await element.updateComplete;
+    let visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 2);
+
+    filterInput.value = '2';
+    element.filterHotlists();
+    await element.updateComplete;
+    visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 1);
+  });
+
+  it('resets filter on open', async () => {
+    element.userHotlists = EXAMPLE_USER_HOTLISTS;
+    element.open();
+    await element.updateComplete;
+
+    const filterInput = element.shadowRoot.querySelector('#filter');
+    filterInput.value = 'ac';
+    element.filterHotlists();
+    await element.updateComplete;
+    let visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 2);
+
+    element.close();
+    element.open();
+    await element.updateComplete;
+
+    assert.equal(filterInput.value, '');
+    visibleHotlists =
+        element.shadowRoot.querySelectorAll('.hotlist');
+    assert.equal(visibleHotlists.length, 4);
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
new file mode 100644
index 0000000..e7c1cd3
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
@@ -0,0 +1,141 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {html, css} from 'lit-element';
+
+import 'elements/framework/mr-warning/mr-warning.js';
+import {hotlists} from 'reducers/hotlists.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-move-issue-hotlists-dialog>`
+ *
+ * Displays a dialog to select the Hotlist to move the provided Issues.
+ */
+export class MrMoveIssueDialog extends MrIssueHotlistsDialog {
+  /** @override */
+  static get styles() {
+    return [
+      super.styles,
+      css`
+        .hotlist {
+          padding: 4px;
+        }
+        .hotlist:hover {
+          background: var(--chops-active-choice-bg);
+          cursor: pointer;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  renderHeader() {
+    const warningText =
+        `Moving issues will remove them from ${this._viewedHotlist ?
+          this._viewedHotlist.displayName : 'this hotlist'}.`;
+    return html`
+      <h3 class="medium-heading">Move issues to hotlist</h3>
+      <mr-warning title=${warningText}>${warningText}</mr-warning>
+    `;
+  }
+
+  /** @override */
+  renderFilteredHotlist(hotlist) {
+    if (this._viewedHotlist &&
+      hotlist.name === this._viewedHotlist.displayName) return;
+    return html`
+      <div
+        class="hotlist"
+        data-hotlist-name="${hotlist.name}"
+        @click=${this._targetHotlistPicked}>
+        ${hotlist.name}
+      </div>`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      ...MrIssueHotlistsDialog.properties,
+      // Populated from Redux.
+      _viewedHotlist: {type: Object},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    super.stateChanged(state);
+    this._viewedHotlist = hotlists.viewedHotlist(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * The currently viewed Hotlist.
+     * @type {?Hotlist}
+     **/
+    this._viewedHotlist = null;
+  }
+
+  /**
+   * Handles picking a Hotlist to move to.
+   * @param {Event} e
+   */
+  async _targetHotlistPicked(e) {
+    const targetHotlistName = e.target.dataset.hotlistName;
+    const changes = {
+      added: [],
+      removed: [],
+    };
+
+    for (const hotlist of this.userHotlists) {
+      // We move from the current Hotlist to the target Hotlist.
+      if (changes.added.length === 1 && changes.removed.length === 1) break;
+      const change = {
+        name: hotlist.name,
+        owner: hotlist.ownerRef,
+      };
+      if (hotlist.name === targetHotlistName) {
+        changes.added.push(change);
+      } else if (hotlist.name === this._viewedHotlist.displayName) {
+        changes.removed.push(change);
+      }
+    }
+
+    const issueRefs = this.issueRefs;
+    if (!issueRefs) return;
+
+    // TODO(https://crbug.com/monorail/7778): Use action creators.
+    const promises = [];
+    if (changes.added && changes.added.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'AddIssuesToHotlists', {
+            hotlistRefs: changes.added,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.removed && changes.removed.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'RemoveIssuesFromHotlists', {
+            hotlistRefs: changes.removed,
+            issueRefs,
+          },
+      ));
+    }
+
+    try {
+      await Promise.all(promises);
+      this.dispatchEvent(new Event('saveSuccess'));
+      this.close();
+    } catch (error) {
+      this.error = error.message || error.description;
+    }
+  }
+}
+
+customElements.define('mr-move-issue-hotlists-dialog', MrMoveIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..7a2dd5c
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
@@ -0,0 +1,105 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrMoveIssueDialog} from './mr-move-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as example from 'shared/test/constants-hotlists.js';
+
+let element;
+let waitForPromises;
+
+describe('mr-move-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-move-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    // We need to wait for promisees to resolve. Alone, the updateComplete
+    // returns without allowing our Promise.all to resolve.
+    waitForPromises = async () => element.updateComplete;
+
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-3', ownerRef: {userId: 67890}},
+      {name: example.HOTLIST.displayName, ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+    element._viewedHotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMoveIssueDialog);
+  });
+
+  it('clicking a hotlist moves the issue', async () => {
+    element.open();
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    assert.isNotNull(targetHotlist);
+    targetHotlist.click();
+    await element.updateComplete;
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'AddIssuesToHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'RemoveIssuesFromHotlists', {
+          hotlistRefs: [{
+            name: example.HOTLIST.displayName,
+            owner: {userId: 67890},
+          }],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('dispatches event upon successfully moving', async () => {
+    element.open();
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+    sinon.stub(element, 'close');
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    targetHotlist.click();
+
+    await waitForPromises();
+    sinon.assert.calledOnce(savedStub);
+    sinon.assert.calledOnce(element.close);
+  });
+
+  it('dispatches no event upon error saving', async () => {
+    const mistakes = 'Mistakes were made';
+    const error = new Error(mistakes);
+    prpcClient.call.returns(Promise.reject(error));
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+    element.open();
+    await element.updateComplete;
+
+    const targetHotlist =element.shadowRoot.querySelector(
+        '.hotlist[data-hotlist-name="Hotlist-2"]');
+    targetHotlist.click();
+
+    await waitForPromises();
+    sinon.assert.notCalled(savedStub);
+    assert.include(element.shadowRoot.innerHTML, mistakes);
+  });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
new file mode 100644
index 0000000..08a8b25
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
@@ -0,0 +1,340 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {html, css} from 'lit-element';
+import deepEqual from 'deep-equal';
+
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-update-issue-hotlists-dialog>`
+ *
+ * Displays a dialog with the current hotlists's issues allowing the user to
+ * update which hotlists the issues are a member of.
+ */
+export class MrUpdateIssueDialog extends MrIssueHotlistsDialog {
+  /** @override */
+  static get styles() {
+    return [
+      ...super.styles,
+      css`
+        input[type="checkbox"] {
+          width: auto;
+          height: auto;
+        }
+        button.toggle {
+          background: none;
+          color: hsl(240, 100%, 40%);
+          border: 0;
+          width: 100%;
+          padding: 0.25em 0;
+          text-align: left;
+        }
+        button.toggle:hover {
+          cursor: pointer;
+          text-decoration: underline;
+        }
+        label, chops-checkbox {
+          display: flex;
+          line-height: 200%;
+          align-items: center;
+          width: 100%;
+          text-align: left;
+          font-weight: normal;
+          padding: 0.25em 8px;
+          box-sizing: border-box;
+        }
+        label input[type="checkbox"] {
+          margin-right: 8px;
+        }
+        .discard-button {
+          margin-right: 16px;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        .input-grid {
+          align-items: center;
+        }
+        .input-grid > input {
+          width: 200px;
+          max-width: 100%;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  renderHeader() {
+    return html`
+      <h3 class="medium-heading">Add issue to hotlists</h3>
+    `;
+  }
+
+  /** @override */
+  renderContent() {
+    return html`
+      ${this.renderFilter()}
+      <form id="issueHotlistsForm">
+        ${this.renderHotlists()}
+        <h3 class="medium-heading">Create new hotlist</h3>
+        <div class="input-grid">
+          <label for="newHotlistName">New hotlist name:</label>
+          <input type="text" name="newHotlistName">
+        </div>
+        ${this.renderError()}
+        <div class="edit-actions">
+          <chops-button
+            class="de-emphasized discard-button"
+            ?disabled=${this.disabled}
+            @click=${this.discard}
+          >
+            Discard
+          </chops-button>
+          <chops-button
+            class="emphasized"
+            ?disabled=${this.disabled}
+            @click=${this.save}
+          >
+            Save changes
+          </chops-button>
+        </div>
+      </form>
+    `;
+  }
+
+  /** @override */
+  renderFilteredHotlist(hotlist) {
+    return html`
+      <chops-checkbox
+        class="hotlist"
+        title=${this._checkboxTitle(hotlist, this.issueHotlists)}
+        data-hotlist-name="${hotlist.name}"
+        ?checked=${this.hotlistsToAdd.has(hotlist.name)}
+        @checked-change=${this._targetHotlistChecked}
+      >
+        ${hotlist.name}
+      </chops-checkbox>`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      ...super.properties,
+      viewedIssueRef: {type: Object},
+      issueHotlists: {type: Array},
+      user: {type: Object},
+      hotlistsToAdd: {
+        type: Object,
+        hasChanged(newVal, oldVal) {
+          return !deepEqual(newVal, oldVal);
+        },
+      },
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    super.stateChanged(state);
+    this.viewedIssueRef = issueV0.viewedIssueRef(state);
+    this.user = userV0.currentUser(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** The list of Hotlists attached to the issueRefs. */
+    this.issueHotlists = [];
+
+    /** The Set of Hotlist names that the Issues will be added to. */
+    this.hotlistsToAdd = this._initializeHotlistsToAdd();
+  }
+
+  /** @override */
+  reset() {
+    const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+    form.reset();
+    // LitElement's hasChanged needs an assignment to verify Set objects.
+    // https://lit-element.polymer-project.org/guide/properties#haschanged
+    this.hotlistsToAdd = this._initializeHotlistsToAdd();
+    super.reset();
+  }
+
+  /**
+   * An alias to the close method.
+   */
+  discard() {
+    this.close();
+  }
+
+  /**
+   * Saves all changes that were found in the dialog and issues async requests
+   * to update the issues.
+   * @fires Event#saveSuccess
+   */
+  async save() {
+    const changes = this.changes;
+    const issueRefs = this.issueRefs;
+    const viewedRef = this.viewedIssueRef;
+
+    if (!issueRefs || !changes) return;
+
+    // TODO(https://crbug.com/monorail/7778): Use action creators.
+    const promises = [];
+    if (changes.added && changes.added.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'AddIssuesToHotlists', {
+            hotlistRefs: changes.added,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.removed && changes.removed.length) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'RemoveIssuesFromHotlists', {
+            hotlistRefs: changes.removed,
+            issueRefs,
+          },
+      ));
+    }
+    if (changes.created) {
+      promises.push(prpcClient.call(
+          'monorail.Features', 'CreateHotlist', {
+            name: changes.created.name,
+            summary: changes.created.summary,
+            issueRefs,
+          },
+      ));
+    }
+
+    try {
+      await Promise.all(promises);
+
+      // Refresh the viewed issue's hotlists only if there is a viewed issue.
+      if (viewedRef) {
+        const viewedIssueWasUpdated = issueRefs.find((ref) =>
+          ref.projectName === viewedRef.projectName &&
+          ref.localId === viewedRef.localId);
+        if (viewedIssueWasUpdated) {
+          store.dispatch(issueV0.fetchHotlists(viewedRef));
+        }
+      }
+      store.dispatch(userV0.fetchHotlists({userId: this.user.userId}));
+      this.dispatchEvent(new Event('saveSuccess'));
+      this.close();
+    } catch (error) {
+      this.error = error.description;
+    }
+  }
+
+  /**
+   * Returns whether a given hotlist matches any of the given issue's hotlists.
+   * @param {Hotlist} hotlist Hotlist to look for.
+   * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+   * @return {boolean}
+   */
+  _issueInHotlist(hotlist, issueHotlists) {
+    return issueHotlists.some((issueHotlist) => {
+      // TODO(https://crbug.com/monorail/7451): use `===`.
+      return (hotlist.ownerRef.userId == issueHotlist.ownerRef.userId &&
+        hotlist.name === issueHotlist.name);
+    });
+  }
+
+  /**
+   * Get a Set of Hotlists to add the Issues to based on the
+   * Get the initial Set of Hotlists that Issues will be added to. Calculated
+   * using userHotlists and issueHotlists.
+   * @return {!Set<string>}
+   */
+  _initializeHotlistsToAdd() {
+    const userHotlistsInIssueHotlists = this.userHotlists.reduce(
+        (acc, hotlist) => {
+          if (this._issueInHotlist(hotlist, this.issueHotlists)) {
+            acc.push(hotlist.name);
+          }
+          return acc;
+        }, []);
+    return new Set(userHotlistsInIssueHotlists);
+  }
+
+  /**
+   * Gets the checkbox title, depending on the checked state.
+   * @param {boolean} isChecked Whether the input is checked.
+   * @return {string}
+   */
+  _getCheckboxTitle(isChecked) {
+    return (isChecked ? 'Remove issue from' : 'Add issue to') + ' this hotlist';
+  }
+
+  /**
+   * The checkbox title for the issue, shown on hover and for a11y.
+   * @param {Hotlist} hotlist Hotlist to look for.
+   * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+   * @return {string}
+   */
+  _checkboxTitle(hotlist, issueHotlists) {
+    return this._getCheckboxTitle(this._issueInHotlist(hotlist, issueHotlists));
+  }
+
+  /**
+   * Handles when the target Hotlist chops-checkbox has been checked.
+   * @param {Event} e
+   */
+  _targetHotlistChecked(e) {
+    const hotlistName = e.target.dataset.hotlistName;
+    const currentHotlistsToAdd = new Set(this.hotlistsToAdd);
+    if (hotlistName && e.detail.checked) {
+      currentHotlistsToAdd.add(hotlistName);
+    } else {
+      currentHotlistsToAdd.delete(hotlistName);
+    }
+    // LitElement's hasChanged needs an assignment to verify Set objects.
+    // https://lit-element.polymer-project.org/guide/properties#haschanged
+    this.hotlistsToAdd = currentHotlistsToAdd;
+    e.target.title = this._getCheckboxTitle(e.target.checked);
+  }
+
+  /**
+   * Gets the changes between the added, removed, and created hotlists .
+   */
+  get changes() {
+    const changes = {
+      added: [],
+      removed: [],
+    };
+    const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+    this.userHotlists.forEach((hotlist) => {
+      const issueInHotlist = this._issueInHotlist(hotlist, this.issueHotlists);
+      if (issueInHotlist && !this.hotlistsToAdd.has(hotlist.name)) {
+        changes.removed.push({
+          name: hotlist.name,
+          owner: hotlist.ownerRef,
+        });
+      } else if (!issueInHotlist && this.hotlistsToAdd.has(hotlist.name)) {
+        changes.added.push({
+          name: hotlist.name,
+          owner: hotlist.ownerRef,
+        });
+      }
+    });
+    if (form.newHotlistName.value) {
+      changes.created = {
+        name: form.newHotlistName.value,
+        summary: 'Hotlist created from issue.',
+      };
+    }
+    return changes;
+  }
+}
+
+customElements.define('mr-update-issue-hotlists-dialog', MrUpdateIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..954b8b9
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
@@ -0,0 +1,193 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrUpdateIssueDialog} from './mr-update-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let form;
+
+describe('mr-update-issue-hotlists-dialog', () => {
+  beforeEach(async () => {
+    element = document.createElement('mr-update-issue-hotlists-dialog');
+    document.body.appendChild(element);
+
+    await element.updateComplete;
+    form = element.shadowRoot.querySelector('#issueHotlistsForm');
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUpdateIssueDialog);
+  });
+
+  it('no changes', () => {
+    assert.deepEqual(element.changes, {added: [], removed: []});
+  });
+
+  it('clicking on issues produces changes', async () => {
+    element.issueHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+    ];
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+
+    element.open();
+    await element.updateComplete;
+
+    const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+    chopsCheckboxes[0].click();
+    chopsCheckboxes[1].click();
+    assert.deepEqual(element.changes, {
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+      removed: [{name: 'Hotlist-1', owner: {userId: 67890}}],
+    });
+  });
+
+  it('adding new hotlist produces changes', async () => {
+    await element.updateComplete;
+    form.newHotlistName.value = 'New-Hotlist';
+    assert.deepEqual(element.changes, {
+      added: [],
+      removed: [],
+      created: {
+        name: 'New-Hotlist',
+        summary: 'Hotlist created from issue.',
+      },
+    });
+  });
+
+  it('reset changes', async () => {
+    element.issueHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+    ];
+    element.userHotlists = [
+      {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+      {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+    ];
+    element.user = {userId: 67890};
+
+    element.open();
+    await element.updateComplete;
+
+    const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+    const checkbox1 = chopsCheckboxes[0];
+    const checkbox2 = chopsCheckboxes[1];
+    checkbox1.click();
+    checkbox2.click();
+    form.newHotlisName = 'New-Hotlist';
+    await element.reset();
+    assert.isTrue(checkbox1.checked);
+    assert.isNotTrue(checkbox2.checked); // Falsey property.
+    assert.equal(form.newHotlistName.value, '');
+  });
+
+  it('saving adds issues to hotlist', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'AddIssuesToHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving removes issues from hotlist', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      removed: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'RemoveIssuesFromHotlists', {
+          hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving creates new hotlist with issues', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      created: {name: 'MyHotlist', summary: 'the best hotlist'},
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'CreateHotlist', {
+          name: 'MyHotlist',
+          summary: 'the best hotlist',
+          issueRefs: [{localId: 22, projectName: 'test'}],
+        });
+  });
+
+  it('saving refreshes issue hotlises if viewed issue is updated', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      created: {name: 'MyHotlist', summary: 'the best hotlist'},
+    }));
+    element.issueRefs = [
+      {localId: 22, projectName: 'test'},
+      {localId: 32, projectName: 'test'},
+    ];
+    element.viewedIssueRef = {localId: 32, projectName: 'test'};
+
+    await element.save();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+        'ListHotlistsByIssue', {issue: {localId: 32, projectName: 'test'}});
+  });
+
+  it('dispatches event upon successfully saving', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+
+    await element.save();
+
+    sinon.assert.calledOnce(savedStub);
+  });
+
+  it('dispatches no event upon error saving', async () => {
+    sinon.stub(element, 'changes').get(() => ({
+      added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+    }));
+    element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+    const error = new Error('Mistakes were made');
+    prpcClient.call.returns(Promise.reject(error));
+
+    const savedStub = sinon.stub();
+    element.addEventListener('saveSuccess', savedStub);
+
+    await element.save();
+
+    sinon.assert.notCalled(savedStub);
+  });
+});
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
new file mode 100644
index 0000000..690bd6a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.js
@@ -0,0 +1,87 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+/**
+ * `<mr-crbug-link>`
+ *
+ * Displays a crbug short-link to an issue.
+ *
+ */
+export class MrCrbugLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+         /**
+         * CSS variables provided to allow conditionally hiding <mr-crbug-link>
+         * in a way that's screenreader friendly.
+         */
+        --mr-crbug-link-opacity: 1;
+        --mr-crbug-link-opacity-focused: 1;
+      }
+      a.material-icons {
+        font-size: var(--chops-icon-font-size);
+        display: inline-block;
+        color: var(--chops-primary-icon-color);
+        padding: 0 2px;
+        box-sizing: border-box;
+        text-decoration: none;
+        vertical-align: middle;
+      }
+      a {
+        opacity: var(--mr-crbug-link-opacity);
+      }
+      a:focus {
+        opacity: var(--mr-crbug-link-opacity-focused);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <a
+        id="bugLink"
+        class="material-icons"
+        href=${this._issueUrl}
+        title="crbug link"
+      >link</a>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * The issue being viewed. Falls back gracefully if this is only a ref.
+       */
+      issue: {type: Object},
+    };
+  }
+
+  /**
+   * Computes the URL to render in the shortlink.
+   * @return {string}
+   */
+  get _issueUrl() {
+    const issue = this.issue;
+    if (!issue) return '';
+    if (this._getHost() === 'bugs.chromium.org') {
+      const projectPart = (
+        issue.projectName == 'chromium' ? '' : issue.projectName + '/');
+      return `https://crbug.com/${projectPart}${issue.localId}`;
+    }
+    const issueType = issue.approvalValues ? 'approval' : 'detail';
+    return `/p/${issue.projectName}/issues/${issueType}?id=${issue.localId}`;
+  }
+
+  _getHost() {
+    // This function allows us to mock the host in unit testing.
+    return document.location.host;
+  }
+}
+customElements.define('mr-crbug-link', MrCrbugLink);
diff --git a/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
new file mode 100644
index 0000000..aa7f21f
--- /dev/null
+++ b/static_src/elements/framework/links/mr-crbug-link/mr-crbug-link.test.js
@@ -0,0 +1,62 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrCrbugLink} from './mr-crbug-link.js';
+
+
+let element;
+
+describe('mr-crbug-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-crbug-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCrbugLink);
+  });
+
+  it('In prod, link to crbug.com with project name specified', async () => {
+    element._getHost = () => 'bugs.chromium.org';
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.href, 'https://crbug.com/test/11');
+  });
+
+  it('In prod, link to crbug.com with implicit project name', async () => {
+    element._getHost = () => 'bugs.chromium.org';
+    element.issue = {
+      projectName: 'chromium',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.href, 'https://crbug.com/11');
+  });
+
+  it('does not redirects to approval page for regular issues', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+  });
+});
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
new file mode 100644
index 0000000..1f8b01a
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.js
@@ -0,0 +1,39 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-hotlist-link>`
+ *
+ * Displays a link to a hotlist.
+ *
+ */
+export class MrHotlistLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return SHARED_STYLES;
+  }
+
+  /** @override */
+  render() {
+    if (!this.hotlist) return html``;
+    return html`
+      <a
+        href="/u/${this.hotlist.ownerRef && this.hotlist.ownerRef.userId}/hotlists/${this.hotlist.name}"
+        title="${this.hotlist.name} - ${this.hotlist.summary}"
+      >
+        ${this.hotlist.name}</a>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      hotlist: {type: Object},
+    };
+  }
+}
+customElements.define('mr-hotlist-link', MrHotlistLink);
diff --git a/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
new file mode 100644
index 0000000..7071b77
--- /dev/null
+++ b/static_src/elements/framework/links/mr-hotlist-link/mr-hotlist-link.test.js
@@ -0,0 +1,23 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrHotlistLink} from './mr-hotlist-link.js';
+
+let element;
+
+describe('mr-hotlist-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-hotlist-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHotlistLink);
+  });
+});
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
new file mode 100644
index 0000000..029de6c
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.js
@@ -0,0 +1,119 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import {ifDefined} from 'lit-html/directives/if-defined';
+import {issueRefToString, issueRefToUrl} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import '../../mr-dropdown/mr-dropdown.js';
+import '../../../help/mr-cue/mr-fed-ref-cue.js';
+
+/**
+ * `<mr-issue-link>`
+ *
+ * Displays a link to an issue.
+ *
+ */
+export class MrIssueLink extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        a[is-closed] {
+          text-decoration: line-through;
+        }
+        mr-dropdown {
+          width: var(--chops-main-font-size);
+          --mr-dropdown-icon-font-size: var(--chops-main-font-size);
+          --mr-dropdown-menu-min-width: 100px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    let fedRefInfo;
+    if (this.issue && this.issue.extIdentifier) {
+      fedRefInfo = html`
+        <!-- TODO(jeffcarp): Figure out CSS to enable menuAlignment=left -->
+        <mr-dropdown
+          label="Federated Reference Info"
+          icon="info_outline"
+          menuAlignment="right"
+        >
+          <mr-fed-ref-cue
+            cuePrefName="federated_reference"
+            fedRefShortlink=${this.issue.extIdentifier}
+            nondismissible>
+          </mr-fed-ref-cue>
+        </mr-dropdown>
+      `;
+    }
+    return html`
+      <a
+        id="bugLink"
+        href=${this.href}
+        title=${ifDefined(this.issue && this.issue.summary)}
+        ?is-closed=${this.isClosed}
+      >${this._linkText}</a>${fedRefInfo}`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // The issue being viewed. Falls back gracefully if this is only a ref.
+      issue: {type: Object},
+      text: {type: String},
+      // The global current project name. NOT the issue's project name.
+      projectName: {type: String},
+      queryParams: {type: Object},
+      short: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.issue = {};
+    this.queryParams = {};
+    this.short = false;
+  }
+
+  click() {
+    const link = this.shadowRoot.querySelector('a');
+    if (!link) return;
+    link.click();
+  }
+
+  /**
+   * @return {string} Where this issue links to.
+   */
+  get href() {
+    return issueRefToUrl(this.issue, this.queryParams);
+  }
+
+  get isClosed() {
+    if (!this.issue || !this.issue.statusRef) return false;
+
+    return this.issue.statusRef.meansOpen === false;
+  }
+
+  get _linkText() {
+    const {projectName, issue, text, short} = this;
+    if (text) return text;
+
+    if (issue && issue.extIdentifier) {
+      return issue.extIdentifier;
+    }
+
+    const prefix = short ? '' : 'Issue ';
+
+    return prefix + issueRefToString(issue, projectName);
+  }
+}
+
+customElements.define('mr-issue-link', MrIssueLink);
diff --git a/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
new file mode 100644
index 0000000..1bd3ae9
--- /dev/null
+++ b/static_src/elements/framework/links/mr-issue-link/mr-issue-link.test.js
@@ -0,0 +1,147 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrIssueLink} from './mr-issue-link.js';
+
+let element;
+
+describe('mr-issue-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueLink);
+  });
+
+  it('strikethrough when closed', async () => {
+    await element.updateComplete;
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.isFalse(
+        window.getComputedStyle(link).getPropertyValue(
+            'text-decoration').includes('line-through'));
+    element.issue = {statusRef: {meansOpen: false}};
+
+    await element.updateComplete;
+
+    assert.isTrue(
+        window.getComputedStyle(link).getPropertyValue(
+            'text-decoration').includes('line-through'));
+  });
+
+  it('shortens link text when short is true', () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 13,
+    };
+
+    assert.equal(element._linkText, 'Issue test:13');
+
+    element.short = true;
+
+    assert.equal(element._linkText, 'test:13');
+  });
+
+  it('shows projectName only when different from global', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.textContent.trim(), 'Issue test:11');
+
+    element.projectName = 'test';
+    await element.updateComplete;
+
+    assert.equal(link.textContent.trim(), 'Issue 11');
+
+    element.projectName = 'other';
+    await element.updateComplete;
+
+    await element.updateComplete;
+
+    assert.equal(link.textContent.trim(), 'Issue test:11');
+  });
+
+  it('shows links for issues', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), '/p/test/issues/detail?id=11');
+    assert.equal(link.title, '');
+  });
+
+  it('shows links for federated issues', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.include(link.href.trim(), 'https://issuetracker.google.com/issues/5678');
+    assert.equal(link.title, '');
+  });
+
+  it('displays an icon for federated references', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    assert.isNotNull(dropdown);
+    const anchor = dropdown.shadowRoot.querySelector('.anchor');
+    assert.isNotNull(anchor);
+    assert.include(anchor.innerText, 'info_outline');
+  });
+
+  it('displays an info popup for federated references', async () => {
+    element.issue = {
+      extIdentifier: 'b/5678',
+    };
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    const anchor = dropdown.shadowRoot.querySelector('.anchor');
+    anchor.click();
+
+    await dropdown.updateComplete;
+
+    assert.isTrue(dropdown.opened);
+
+    const cue = dropdown.querySelector('mr-fed-ref-cue');
+    assert.isNotNull(cue);
+    const message = cue.shadowRoot.querySelector('#message');
+    assert.isNotNull(message);
+    assert.include(message.innerText, 'Buganizer issue tracker');
+  });
+
+  it('shows title when summary is defined', async () => {
+    element.issue = {
+      projectName: 'test',
+      localId: 11,
+      summary: 'Summary',
+    };
+
+    await element.updateComplete;
+    const link = element.shadowRoot.querySelector('#bugLink');
+    assert.equal(link.title, 'Summary');
+  });
+});
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
new file mode 100644
index 0000000..c009f89
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.js
@@ -0,0 +1,129 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+const NULL_DISPLAY_NAME_VALUES = [EMPTY_FIELD_VALUE, 'a_deleted_user'];
+
+/**
+ * `<mr-user-link>`
+ *
+ * Displays a link to a user profile.
+ *
+ */
+export class MrUserLink extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: inline-block;
+          white-space: nowrap;
+        }
+        i.inline-icon {
+          font-size: var(--chops-icon-font-size);
+          color: #B71C1C;
+          vertical-align: bottom;
+          cursor: pointer;
+        }
+        i.inline-icon-unseen {
+          color: var(--chops-purple-700);
+        }
+        i.material-icons[hidden] {
+          display: none;
+        }
+        .availability-notice {
+          color: #B71C1C;
+          font-weight: bold;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      referencedUsers: {
+        type: Object,
+      },
+      showAvailabilityIcon: {
+        type: Boolean,
+      },
+      showAvailabilityText: {
+        type: Boolean,
+      },
+      userRef: {
+        type: Object,
+        attribute: 'userref',
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.userRef = {};
+    this.referencedUsers = new Map();
+    this.showAvailabilityIcon = false;
+    this.showAvailabilityText = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.referencedUsers = issueV0.referencedUsers(state);
+  }
+
+  /** @override */
+  render() {
+    const availability = this._getAvailability();
+    const userLink = this._getUserLink();
+    const user = this.referencedUsers.get(this.userRef.displayName) || {};
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <i
+        id="availability-icon"
+        class="material-icons inline-icon ${user.last_visit_timestamp ? "" : "inline-icon-unseen"}"
+        title="${availability}"
+        ?hidden="${!(this.showAvailabilityIcon && availability)}"
+      >schedule</i>
+      <a
+        id="user-link"
+        href="${userLink}"
+        title="${this.userRef.displayName}"
+        ?hidden="${!userLink}"
+      >${this.userRef.displayName}</a>
+      <span
+        id="user-text"
+        ?hidden="${userLink}"
+      >${this.userRef.displayName}</span>
+      <div
+        id="availability-text"
+        class="availability-notice"
+        title="${availability}"
+        ?hidden="${!(this.showAvailabilityText && availability)}"
+      >${availability}</div>
+    `;
+  }
+
+  _getAvailability() {
+    if (!this.userRef || !this.referencedUsers) return '';
+    const user = this.referencedUsers.get(this.userRef.displayName) || {};
+    return user.availability;
+  }
+
+  _getUserLink() {
+    if (!this.userRef || !this.userRef.displayName ||
+        NULL_DISPLAY_NAME_VALUES.includes(this.userRef.displayName)) return '';
+    return `/u/${this.userRef.userId || this.userRef.displayName}`;
+  }
+}
+customElements.define('mr-user-link', MrUserLink);
diff --git a/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
new file mode 100644
index 0000000..77af246
--- /dev/null
+++ b/static_src/elements/framework/links/mr-user-link/mr-user-link.test.js
@@ -0,0 +1,156 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrUserLink} from './mr-user-link.js';
+
+
+let element;
+let availabilityIcon;
+let userLink;
+let userText;
+let availabilityText;
+
+function getElements() {
+  availabilityIcon = element.shadowRoot.querySelector(
+      '#availability-icon');
+  userLink = element.shadowRoot.querySelector(
+      '#user-link');
+  userText = element.shadowRoot.querySelector(
+      '#user-text');
+  availabilityText = element.shadowRoot.querySelector(
+      '#availability-text');
+}
+
+describe('mr-user-link', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-user-link');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUserLink);
+  });
+
+  it('no link when no userId and displayName is null value', async () => {
+    element.userRef = {displayName: '----'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userText.hidden);
+    assert.equal(userText.textContent, '----');
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userLink.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('link when displayName', async () => {
+    element.userRef = {displayName: 'test@example.com'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userLink.hidden);
+    assert.equal(userLink.textContent.trim(), 'test@example.com');
+    assert.isTrue(userLink.href.endsWith('/u/test@example.com'));
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('link when userId', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(userLink.hidden);
+    assert.equal(userLink.textContent.trim(), 'test@example.com');
+    assert.isTrue(userLink.href.endsWith('/u/1234'));
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('show availability', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+    element.showAvailabilityIcon = true;
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(availabilityIcon.hidden);
+    assert.equal(availabilityIcon.title, 'foo');
+
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('dont show availability', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.hidden);
+
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+    assert.isTrue(availabilityText.hidden);
+  });
+
+  it('show availability text', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {availability: 'foo'}]]);
+    element.showAvailabilityText = true;
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isFalse(availabilityText.hidden);
+    assert.equal(availabilityText.title, 'foo');
+    assert.equal(availabilityText.textContent, 'foo');
+
+    assert.isTrue(availabilityIcon.hidden);
+    assert.isFalse(userLink.hidden);
+    assert.isTrue(userText.hidden);
+  });
+
+  it('show availability user never visited', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {last_visit_timestamp: undefined}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.classList.contains("inline-icon-unseen"));
+  });
+
+  it('show availability user visited', async () => {
+    element.userRef = {userId: '1234', displayName: 'test@example.com'};
+    element.referencedUsers = new Map(
+        [['test@example.com', {last_visit_timestamp: "35"}]]);
+
+    await element.updateComplete;
+    getElements();
+
+    assert.isTrue(availabilityIcon.classList.contains("inline-icon"));
+    assert.isFalse(availabilityIcon.classList.contains("inline-icon-unseen"));
+  });
+});
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
new file mode 100644
index 0000000..c37eb42
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.js
@@ -0,0 +1,105 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {ChopsAutocomplete} from
+  'elements/chops/chops-autocomplete/chops-autocomplete';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+
+
+/**
+ * `<mr-autocomplete>` displays an autocomplete input.
+ *
+ */
+export class MrAutocomplete extends connectStore(ChopsAutocomplete) {
+  /** @override */
+  static get properties() {
+    return {
+      ...ChopsAutocomplete.properties,
+      /**
+       * String for the name of autocomplete vocabulary used.
+       * Valid values:
+       *  - 'project': Names of projects available to the current user.
+       *  - 'member': All members in the current project a user is viewing.
+       *  - 'owner': Similar to member, except with groups excluded.
+       *
+       * TODO(zhangtiff): Implement the following stores.
+       *  - 'component': All components in the current project.
+       *  - 'label': Well-known labels in the current project.
+       */
+      vocabularyName: {type: String},
+      /**
+       * Object where the keys are 'type' values and each value is an object
+       * with the format {strings, docDict, replacer}.
+       */
+      vocabularies: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.vocabularyName = '';
+    this.vocabularies = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    const visibleMembers = projectV0.viewedVisibleMembers(state);
+    const userProjects = userV0.projects(state);
+    this.vocabularies = {
+      'project': this._setupProjectVocabulary(userProjects),
+      'member': this._setupMemberVocabulary(visibleMembers),
+      'owner': this._setupOwnerVocabulary(visibleMembers),
+    };
+  }
+
+  // TODO(zhangtiff): Move this logic into selectors to prevent computing
+  // vocabularies for every single instance of autocomplete.
+  _setupProjectVocabulary(userProjects) {
+    const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+    const strings = [...ownerOf, ...memberOf, ...contributorTo];
+    return {strings};
+  }
+
+  _setupMemberVocabulary(visibleMembers) {
+    const {userRefs = []} = visibleMembers;
+    return {strings: userRefsToDisplayNames(userRefs)};
+  }
+
+  _setupOwnerVocabulary(visibleMembers) {
+    const {userRefs = [], groupRefs = []} = visibleMembers;
+    const groups = userRefsToDisplayNames(groupRefs);
+    const users = userRefsToDisplayNames(userRefs);
+
+    // Remove groups from the list of all members.
+    const owners = arrayDifference(users, groups);
+    return {strings: owners};
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('vocabularyName') ||
+        changedProperties.has('vocabularies')) {
+      if (this.vocabularyName in this.vocabularies) {
+        const props = this.vocabularies[this.vocabularyName];
+
+        this.strings = props.strings || [];
+        this.docDict = props.docDict || {};
+        this.replacer = props.replacer;
+      } else {
+        // Clear autocomplete if there's no data for it.
+        this.strings = [];
+        this.docDict = {};
+        this.replacer = null;
+      }
+    }
+
+    super.update(changedProperties);
+  }
+}
+customElements.define('mr-autocomplete', MrAutocomplete);
diff --git a/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
new file mode 100644
index 0000000..0c4e3ae
--- /dev/null
+++ b/static_src/elements/framework/mr-autocomplete/mr-autocomplete.test.js
@@ -0,0 +1,86 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrAutocomplete} from './mr-autocomplete.js';
+
+let element;
+
+describe('mr-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-autocomplete');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAutocomplete);
+  });
+
+  it('sets properties based on vocabularies', async () => {
+    assert.deepEqual(element.strings, []);
+    assert.deepEqual(element.docDict, {});
+
+    element.vocabularies = {
+      'project': {
+        'strings': ['chromium', 'v8'],
+        'docDict': {'chromium': 'move the web forward'},
+      },
+    };
+
+    element.vocabularyName = 'project';
+
+    await element.updateComplete;
+
+    assert.deepEqual(element.strings, ['chromium', 'v8']);
+    assert.deepEqual(element.docDict, {'chromium': 'move the web forward'});
+  });
+
+  it('_setupProjectVocabulary', () => {
+    assert.deepEqual(element._setupProjectVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupProjectVocabulary({
+      ownerOf: ['chromium'],
+      memberOf: ['skia'],
+      contributorTo: ['v8'],
+    }), {strings: ['chromium', 'skia', 'v8']});
+  });
+
+  it('_setupMemberVocabulary', () => {
+    assert.deepEqual(element._setupMemberVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupMemberVocabulary({
+      userRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+        {displayName: 'test@example.com', userId: '123'},
+        {displayName: 'test2@example.com', userId: '543'},
+      ],
+      groupRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+      ],
+    }), {strings:
+      ['group@example.com', 'test@example.com', 'test2@example.com'],
+    });
+  });
+
+  it('_setupOwnerVocabulary', () => {
+    assert.deepEqual(element._setupOwnerVocabulary({}), {strings: []});
+
+    assert.deepEqual(element._setupOwnerVocabulary({
+      userRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+        {displayName: 'test@example.com', userId: '123'},
+        {displayName: 'test2@example.com', userId: '543'},
+      ],
+      groupRefs: [
+        {displayName: 'group@example.com', userId: '100'},
+      ],
+    }), {strings:
+      ['test@example.com', 'test2@example.com'],
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
new file mode 100644
index 0000000..8cff503
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.js
@@ -0,0 +1,100 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+
+import 'shared/typedef.js';
+
+/** Button bar containing table controls. */
+export class MrButtonBar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+      }
+      button {
+        background: none;
+        color: var(--chops-link-color);
+        cursor: pointer;
+        font-size: var(--chops-normal-font-size);
+        font-weight: var(--chops-link-font-weight);
+
+        line-height: 24px;
+        padding: 4px 16px;
+
+        border: none;
+
+        align-items: center;
+        display: inline-flex;
+      }
+      button:hover {
+        background: var(--chops-active-choice-bg);
+      }
+      i.material-icons {
+        font-size: 20px;
+        margin-right: 4px;
+        vertical-align: middle;
+      }
+      mr-dropdown {
+        --mr-dropdown-anchor-padding: 6px 4px;
+        --mr-dropdown-icon-color: var(--chops-link-color);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      ${this.items.map(_renderItem)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      items: {type: Array},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array<MenuItem>} */
+    this.items = [];
+  }
+};
+
+/**
+ * Renders one item.
+ * @param {MenuItem} item
+ * @return {TemplateResult}
+ */
+function _renderItem(item) {
+  if (item.items) {
+    return html`
+      <mr-dropdown
+        icon=${item.icon}
+        menuAlignment="left"
+        label=${item.text}
+        .items=${item.items}
+      ></mr-dropdown>
+    `;
+  } else {
+    return html`
+      <button @click=${item.handler}>
+        <i class="material-icons" ?hidden=${!item.icon}>
+          ${item.icon}
+        </i>
+        ${item.text}
+      </button>
+    `;
+  }
+}
+
+customElements.define('mr-button-bar', MrButtonBar);
diff --git a/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
new file mode 100644
index 0000000..349a8df
--- /dev/null
+++ b/static_src/elements/framework/mr-button-bar/mr-button-bar.test.js
@@ -0,0 +1,53 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrButtonBar} from './mr-button-bar.js';
+
+/** @type {MrButtonBar} */
+let element;
+
+describe('mr-button-bar', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-button-bar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrButtonBar);
+  });
+
+  it('renders button items', async () => {
+    const handler = sinon.stub();
+
+    element.items = [{icon: 'emoji_nature', text: 'Pollinate', handler}];
+    await element.updateComplete;
+
+    const button = element.shadowRoot.querySelector('button');
+    button.click();
+
+    assert.include(button.innerHTML, 'emoji_nature');
+    assert.include(button.innerHTML, 'Pollinate');
+    sinon.assert.calledOnce(handler);
+  });
+
+  it('renders dropdown items', async () => {
+    const items = [{icon: 'emoji_nature', text: 'Pollinate'}];
+    element.items = [{icon: 'more_vert', text: 'More actions...', items}];
+    await element.updateComplete;
+
+    /** @type {MrDropdown} */
+    const dropdown = element.shadowRoot.querySelector('mr-dropdown');
+    assert.strictEqual(dropdown.icon, 'more_vert');
+    assert.strictEqual(dropdown.label, 'More actions...');
+    assert.strictEqual(dropdown.items, items);
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.js b/static_src/elements/framework/mr-comment-content/mr-attachment.js
new file mode 100644
index 0000000..c435dfd
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.js
@@ -0,0 +1,206 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {FILE_DOWNLOAD_WARNING, ALLOWED_ATTACHMENT_EXTENSIONS,
+  ALLOWED_CONTENT_TYPE_PREFIXES} from 'shared/settings.js';
+import 'elements/chops/chops-button/chops-button.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-attachment>`
+ *
+ * Display attachments for Monorail comments.
+ *
+ */
+export class MrAttachment extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      attachment: {type: Object},
+      projectName: {type: String},
+      localId: {type: Number},
+      sequenceNum: {type: Number},
+      canDelete: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .attachment-view,
+        .attachment-download {
+          margin-left: 8px;
+          display: block;
+        }
+        .attachment-delete {
+          margin-left: 16px;
+          color: var(--chops-button-color);
+          background: var(--chops-button-bg);
+          border-color: transparent;
+        }
+        .comment-attachment {
+          min-width: 20%;
+          width: fit-content;
+          background: var(--chops-card-details-bg);
+          padding: 4px;
+          margin: 8px;
+          overflow: auto;
+        }
+        .comment-attachment-header {
+          display: flex;
+          flex-wrap: nowrap;
+        }
+        .filename {
+          margin-left: 8px;
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+        }
+        .filename-deleted {
+          margin-right: 4px;
+        }
+        .filesize {
+          margin-left: 8px;
+          white-space: nowrap;
+        }
+        .preview {
+          border: 2px solid #c3d9ff;
+          padding: 1px;
+          max-width: 98%;
+        }
+        .preview:hover {
+          border: 2px solid blue;
+        }
+      `];
+  }
+
+
+  /** @override */
+  render() {
+    return html`
+      <div class="comment-attachment">
+        <div class="filename">
+          ${this.attachment.isDeleted ? html`
+            <div class="filename-deleted">[Deleted]</div>
+          ` : ''}
+          <b>${this.attachment.filename}</b>
+          ${this.canDelete ? html`
+            <chops-button
+              class="attachment-delete"
+              @click=${this._deleteAttachment}>
+              ${this.attachment.isDeleted ? 'Undelete' : 'Delete'}
+            </chops-button>
+          ` : ''}
+        </div>
+        ${!this.attachment.isDeleted ? html`
+          <div class="comment-attachment-header">
+            <div class="filesize">${_bytesOrKbOrMb(this.attachment.size)}</div>
+            ${this.attachment.viewUrl ? html`
+              <a
+                class="attachment-view"
+                href=${this.attachment.viewUrl}
+                target="_blank"
+              >View</a>
+            `: ''}
+            <a
+              class="attachment-download"
+              href=${this.attachment.downloadUrl}
+              target="_blank"
+              ?hidden=${!this.attachment.downloadUrl}
+              @click=${this._warnOnDownload}
+            >Download</a>
+          </div>
+          ${this.attachment.thumbnailUrl ? html`
+            <a href=${this.attachment.viewUrl} target="_blank">
+              <img
+                class="preview" alt="attachment preview"
+                src=${this.attachment.thumbnailUrl}>
+            </a>
+          ` : ''}
+          ${_isVideo(this.attachment.contentType) ? html`
+            <video
+              src=${this.attachment.viewUrl}
+              class="preview"
+              controls
+              width="640"
+              preload="metadata"
+            ></video>
+          ` : ''}
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * Deletes a given attachment in a comment.
+   */
+  _deleteAttachment() {
+    const issueRef = {
+      projectName: this.projectName,
+      localId: this.localId,
+    };
+
+    const promise = prpcClient.call(
+        'monorail.Issues', 'DeleteAttachment',
+        {
+          issueRef,
+          sequenceNum: this.sequenceNum,
+          attachmentId: this.attachment.attachmentId,
+          delete: !this.attachment.isDeleted,
+        });
+
+    promise.then(() => {
+      store.dispatch(issueV0.fetchComments(issueRef));
+    }, (error) => {
+      console.log('Failed to (un)delete attachment', error);
+    });
+  }
+
+  /**
+   * Give the user a warning before they download files that Monorail thinks
+   * might have the potential to be unsafe.
+   * @param {MouseEvent} e
+   */
+  _warnOnDownload(e) {
+    const isAllowedType = ALLOWED_CONTENT_TYPE_PREFIXES.some((prefix) => {
+      return this.attachment.contentType.startsWith(prefix);
+    });
+    const isAllowedExtension = ALLOWED_ATTACHMENT_EXTENSIONS.some((ext) => {
+      return this.attachment.filename.toLowerCase().endsWith(ext);
+    });
+
+    if (isAllowedType || isAllowedExtension) return;
+    if (!window.confirm(FILE_DOWNLOAD_WARNING)) {
+      e.preventDefault();
+    }
+  }
+}
+
+function _isVideo(contentType) {
+  if (!contentType) return;
+  return contentType.startsWith('video/');
+}
+
+function _bytesOrKbOrMb(numBytes) {
+  if (numBytes < 1024) {
+    return `${numBytes} bytes`; // e.g., 128 bytes
+  } else if (numBytes < 99 * 1024) {
+    return `${(numBytes / 1024).toFixed(1)} KB`; // e.g. 23.4 KB
+  } else if (numBytes < 1024 * 1024) {
+    return `${(numBytes / 1024).toFixed(0)} KB`; // e.g., 219 KB
+  } else if (numBytes < 99 * 1024 * 1024) {
+    return `${(numBytes / 1024 / 1024).toFixed(1)} MB`; // e.g., 21.9 MB
+  } else {
+    return `${(numBytes / 1024 / 1024).toFixed(0)} MB`; // e.g., 100 MB
+  }
+}
+
+customElements.define('mr-attachment', MrAttachment);
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.test.js b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
new file mode 100644
index 0000000..ec79c66
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
@@ -0,0 +1,228 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert, expect} from 'chai';
+import {MrAttachment} from './mr-attachment.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {FILE_DOWNLOAD_WARNING} from 'shared/settings.js';
+
+let element;
+
+describe('mr-attachment', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-attachment');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAttachment);
+  });
+
+  it('shows image thumbnail', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNotNull(img);
+    assert.isTrue(img.src.endsWith('thumbnail.jpeg'));
+  });
+
+  it('shows video thumbnail', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNotNull(video);
+    assert.isTrue(video.src.endsWith('video.mp4'));
+  });
+
+  it('does not show image thumbnail if deleted', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNull(img);
+  });
+
+  it('does not show video thumbnail if deleted', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNull(video);
+  });
+
+  it('deletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: false,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: true,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('undeletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: true,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: false,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('view link is not displayed if not given', async () => {
+    element.attachment = {};
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNull(viewLink);
+  });
+
+  it('view link is displayed if given', async () => {
+    element.attachment = {
+      viewUrl: 'http://example.com/attachment.foo',
+    };
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNotNull(viewLink);
+    expect(viewLink).to.be.displayed;
+    assert.equal(viewLink.href, 'http://example.com/attachment.foo');
+  });
+
+  describe('download', () => {
+    let downloadLink;
+
+    beforeEach(async () => {
+      sinon.stub(window, 'confirm').returns(false);
+
+
+      element.attachment = {};
+      await element.updateComplete;
+      downloadLink = element.shadowRoot.querySelector('.attachment-download');
+      // Prevent Karma from opening up new tabs because of simulated link
+      // clicks.
+      downloadLink.removeAttribute('target');
+    });
+
+    afterEach(() => {
+      window.confirm.restore();
+    });
+
+    it('download link is not displayed if not given', async () => {
+      element.attachment = {};
+      await element.updateComplete;
+      assert.isTrue(downloadLink.hidden);
+    });
+
+    it('download link is displayed if given', async () => {
+      element.attachment = {
+        downloadUrl: 'http://example.com/attachment.foo',
+      };
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector(
+          '.attachment-download');
+      assert.isFalse(downloadLink.hidden);
+      expect(downloadLink).to.be.displayed;
+      assert.equal(downloadLink.href, 'http://example.com/attachment.foo');
+    });
+
+    it('download allows recognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.png',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('file extension matching is case insensitive', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.PNG',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('download warns on unrecognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'application/virus',
+        filename: 'fake-virus.exe',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.calledOnce(window.confirm);
+      sinon.assert.calledWith(window.confirm, FILE_DOWNLOAD_WARNING);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
new file mode 100644
index 0000000..c2bf3e8
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
@@ -0,0 +1,131 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import {ifDefined} from 'lit-html/directives/if-defined';
+import {autolink} from 'autolink.js';
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {shouldRenderMarkdown, renderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+/**
+ * `<mr-comment-content>`
+ *
+ * Displays text for a comment.
+ *
+ */
+export class MrCommentContent extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+
+    this.content = '';
+    this.commentReferences = new Map();
+    this.isDeleted = false;
+    this.projectName = '';
+    this.author = '';
+    this.prefs = {};
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      content: {type: String},
+      commentReferences: {type: Object},
+      revisionUrlFormat: {type: String},
+      isDeleted: {
+        type: Boolean,
+        reflect: true,
+      },
+      projectName: {type: String},
+      author: {type: String},
+      prefs: {type: Object},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      MD_STYLES,
+      css`
+        :host {
+          word-break: break-word;
+          font-size: var(--chops-main-font-size);
+          line-height: 130%;
+          font-family: var(--mr-toggled-font-family);
+        }
+        :host([isDeleted]) {
+          color: #888;
+          font-style: italic;
+        }
+        .line {
+          white-space: pre-wrap;
+        }
+        .strike-through {
+          text-decoration: line-through;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    if (shouldRenderMarkdown({project: this.projectName, author: this.author,
+          enabled: this._renderMarkdown})) {
+      return html`
+        <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+        <div class="markdown">
+          ${unsafeHTML(renderMarkdown(this.content))}
+        </div>
+        `;
+    }
+    const runs = autolink.markupAutolinks(
+        this.content, this.commentReferences, this.projectName,
+        this.revisionUrlFormat);
+    const templates = runs.map((run) => {
+      switch (run.tag) {
+        case 'b':
+          return html`<b class="line">${run.content}</b>`;
+        case 'br':
+          return html`<br>`;
+        case 'a':
+          return html`<a
+            class="line"
+            target="_blank"
+            href=${run.href}
+            class=${run.css}
+            title=${ifDefined(run.title)}
+          >${run.content}</a>`;
+        default:
+          return html`<span class="line">${run.content}</span>`;
+      }
+    });
+    return html`${templates}`;
+  }
+
+  /**
+   * Helper to get state of Markdown rendering.
+   * @return {boolean} Whether to render Markdown.
+   */
+  get _renderMarkdown() {
+    const {prefs} = this;
+    if (!prefs) return true;
+    return prefs.get('render_markdown');
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentReferences = issueV0.commentReferences(state);
+    this.projectName = issueV0.viewedIssueRef(state).projectName;
+    this.revisionUrlFormat =
+      projectV0.viewedPresentationConfig(state).revisionUrlFormat;
+    this.prefs = userV0.prefs(state);
+  }
+}
+customElements.define('mr-comment-content', MrCommentContent);
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
new file mode 100644
index 0000000..4eeaab5
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
@@ -0,0 +1,84 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrCommentContent} from './mr-comment-content.js';
+
+
+let element;
+
+describe('mr-comment-content', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-content');
+    document.body.appendChild(element);
+
+    document.body.style.setProperty('--mr-toggled-font-family', 'Some-font');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    document.body.style.removeProperty('--mr-toggled-font-family');
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentContent);
+  });
+
+  it('changes rendered font based on --mr-toggled-font-family', async () => {
+    element.content = 'A comment';
+
+    await element.updateComplete;
+
+    const fontFamily = window.getComputedStyle(element).getPropertyValue(
+        'font-family');
+
+    assert.equal(fontFamily, 'Some-font');
+  });
+
+  it('does not render spurious spaces', async () => {
+    element.content =
+      'Some text before a go/link and more text before <b>some bold text</b>.';
+
+    await element.updateComplete;
+
+    const textContents = Array.from(element.shadowRoot.children).map(
+        (child) => child.textContent);
+
+    assert.deepEqual(textContents, [
+      'Some text before a',
+      ' ',
+      'go/link',
+      ' and more text before ',
+      'some bold text',
+      '.',
+    ]);
+
+    assert.deepEqual(
+        element.shadowRoot.textContent,
+        'Some text before a go/link and more text before some bold text.');
+  });
+
+  it('does render markdown', async () => {
+    element.prefs = new Map([['render_markdown', true]]);
+    element.content = '### this is a header';
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+
+    const headerText = element.shadowRoot.querySelector('h3').textContent;
+    assert.equal(headerText, 'this is a header');
+  });
+
+  it('does not render markdown when prefs are set to false', async () => {
+    element.prefs = new Map([['render_markdown', false]]);
+    element.projectName = 'monkeyrail';
+    element.content = '### this is a header';
+
+    await element.updateComplete;
+
+    const commentText = element.shadowRoot.textContent;
+    assert.equal(commentText, '### this is a header');
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.js b/static_src/elements/framework/mr-comment-content/mr-description.js
new file mode 100644
index 0000000..89ae105
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.js
@@ -0,0 +1,137 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import './mr-comment-content.js';
+import './mr-attachment.js';
+
+import {relativeTime} from
+  'elements/chops/chops-timestamp/chops-timestamp-helpers';
+
+
+/**
+ * `<mr-description>`
+ *
+ * Element for displaying a description or survey.
+ *
+ */
+export class MrDescription extends LitElement {
+  /** @override */
+  constructor() {
+    super();
+
+    this.descriptionList = [];
+    this.selectedIndex = 0;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      descriptionList: {type: Array},
+      selectedIndex: {type: Number},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('descriptionList')) {
+      if (!this.descriptionList || !this.descriptionList.length) return;
+      this.selectedIndex = this.descriptionList.length - 1;
+    }
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      .select-container {
+        text-align: right;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const selectedDescription = this.selectedDescription;
+
+    return html`
+      <div class="select-container">
+        <select
+          @change=${this._selectChanged}
+          ?hidden=${!this.descriptionList || this.descriptionList.length <= 1}
+          aria-label="Description history menu">
+          ${this.descriptionList.map((desc, i) => this._renderDescriptionOption(desc, i))}
+        </select>
+      </div>
+      <mr-comment-content
+        .content=${selectedDescription.content}
+        .author=${selectedDescription.commenter.displayName}
+      ></mr-comment-content>
+      <div>
+        ${(selectedDescription.attachments || []).map((attachment) => html`
+          <mr-attachment
+            .attachment=${attachment}
+            .projectName=${selectedDescription.projectName}
+            .localId=${selectedDescription.localId}
+            .sequenceNum=${selectedDescription.sequenceNum}
+            .canDelete=${selectedDescription.canDelete}
+          ></mr-attachment>
+        `)}
+      </div>
+    `;
+  }
+
+  /**
+   * Getter for the currently viewed description.
+   * @return {Comment} The description object.
+   */
+  get selectedDescription() {
+    const descriptions = this.descriptionList || [];
+    const index = Math.max(
+      Math.min(this.selectedIndex, descriptions.length - 1),
+      0);
+    return descriptions[index] || {};
+  }
+
+  /**
+   * Helper to render a <select> <option> for a single description, for our
+   * description selector.
+   * @param {Comment} description
+   * @param {Number} index
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderDescriptionOption(description, index) {
+    const {commenter, timestamp} = description || {};
+    const byLine = commenter ? `by ${commenter.displayName}` : '';
+    return html`
+      <option value=${index} ?selected=${index === this.selectedIndex}>
+        Description #${index + 1} ${byLine} (${_relativeTime(timestamp)})
+      </option>
+    `;
+  }
+
+  /**
+   * Updates the element's selectedIndex when the user changes the select menu.
+   * @param {Event} evt
+   */
+  _selectChanged(evt) {
+    if (!evt || !evt.target) return;
+    this.selectedIndex = Number.parseInt(evt.target.value);
+  }
+}
+
+/**
+ * Template helper for rendering relative time.
+ * @param {number} unixTime Unix timestamp in seconds.
+ * @return {string} human readable timestamp.
+ */
+function _relativeTime(unixTime) {
+  unixTime = Number.parseInt(unixTime);
+  if (Number.isNaN(unixTime)) return;
+  return relativeTime(new Date(unixTime * 1000));
+}
+
+customElements.define('mr-description', MrDescription);
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.test.js b/static_src/elements/framework/mr-comment-content/mr-description.test.js
new file mode 100644
index 0000000..9d39149
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.test.js
@@ -0,0 +1,81 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrDescription} from './mr-description.js';
+
+
+let element;
+
+describe('mr-description', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-description');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDescription);
+  });
+
+  it('changes rendered description on select change', async () => {
+    element.descriptionList = [
+      {content: 'description one', commenter: {displayName: 'name'}},
+      {content: 'description two', commenter: {displayName: 'name'}},
+    ];
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const commentContent =
+      element.shadowRoot.querySelector('mr-comment-content');
+    assert.equal('description two', commentContent.content);
+
+    element.selectedIndex = 0;
+
+    await element.updateComplete;
+
+    assert.equal('description one', commentContent.content);
+  });
+
+  it('hides selector when only one description', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {content: 'rutabaga', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    element.descriptionList = [
+      {content: 'blehh', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    assert.isTrue(selectMenu.hidden);
+  });
+
+  it('selector still renders when one description is deleted', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {isDeleted: true, commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    const options = selectMenu.querySelectorAll('option');
+
+    assert.include(options[0].textContent, 'Description #1 by name@email.com');
+    assert.include(options[1].textContent, 'Description #2');
+  });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
new file mode 100644
index 0000000..264b976
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.js
@@ -0,0 +1,63 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import './mr-dropdown.js';
+
+/**
+ * `<mr-account-dropdown>`
+ *
+ * Account dropdown menu for Monorail.
+ *
+ */
+export class MrAccountDropdown extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+        :host {
+          position: relative;
+          display: inline-block;
+          height: 100%;
+          font-size: inherit;
+        }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-dropdown
+        .text=${this.userDisplayName}
+        .items=${this.items}
+        .icon="arrow_drop_down"
+      ></mr-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: String,
+      logoutUrl: String,
+      loginUrl: String,
+    };
+  }
+
+  get items() {
+    return [
+      {text: 'Switch accounts', url: this.loginUrl},
+      {separator: true},
+      {text: 'Profile', url: `/u/${this.userDisplayName}`},
+      {text: 'Updates', url: `/u/${this.userDisplayName}/updates`},
+      {text: 'Settings', url: '/hosting/settings'},
+      {text: 'Saved queries', url: `/u/${this.userDisplayName}/queries`},
+      {text: 'Hotlists', url: `/u/${this.userDisplayName}/hotlists`},
+      {separator: true},
+      {text: 'Sign out', url: this.logoutUrl},
+    ];
+  }
+}
+
+customElements.define('mr-account-dropdown', MrAccountDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
new file mode 100644
index 0000000..f365823
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-account-dropdown.test.js
@@ -0,0 +1,23 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrAccountDropdown} from './mr-account-dropdown.js';
+
+let element;
+
+describe('mr-account-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-account-dropdown');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAccountDropdown);
+  });
+});
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
new file mode 100644
index 0000000..4564ab0
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.js
@@ -0,0 +1,367 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import {ifDefined} from 'lit-html/directives/if-defined';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'shared/typedef.js';
+
+export const SCREENREADER_ATTRIBUTE_ERROR = `For screenreader support,
+  mr-dropdown must always have either a label or a text property defined.`;
+
+/**
+ * `<mr-dropdown>`
+ *
+ * Dropdown menu for Monorail.
+ *
+ */
+export class MrDropdown extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          position: relative;
+          display: inline-block;
+          height: 100%;
+          font-size: inherit;
+          font-family: var(--chops-font-family);
+          --mr-dropdown-icon-color: var(--chops-primary-icon-color);
+          --mr-dropdown-icon-font-size: var(--chops-icon-font-size);
+          --mr-dropdown-anchor-font-weight: var(--chops-link-font-weight);
+          --mr-dropdown-anchor-padding: 4px 0.25em;
+          --mr-dropdown-anchor-justify-content: center;
+          --mr-dropdown-menu-max-height: initial;
+          --mr-dropdown-menu-overflow: initial;
+          --mr-dropdown-menu-min-width: 120%;
+          --mr-dropdown-menu-font-size: var(--chops-large-font-size);
+          --mr-dropdown-menu-icon-size: var(--chops-icon-font-size);
+        }
+        :host([hidden]) {
+          display: none;
+          visibility: hidden;
+        }
+        :host(:not([opened])) .menu {
+          display: none;
+          visibility: hidden;
+        }
+        strong {
+          font-size: var(--chops-large-font-size);
+        }
+        i.material-icons {
+          font-size: var(--mr-dropdown-icon-font-size);
+          display: inline-block;
+          color: var(--mr-dropdown-icon-color);
+          padding: 0 2px;
+          box-sizing: border-box;
+        }
+        i.material-icons[hidden],
+        .menu-item > i.material-icons[hidden] {
+          display: none;
+        }
+        .menu-item > i.material-icons {
+          display: block;
+          font-size: var(--mr-dropdown-menu-icon-size);
+          width: var(--mr-dropdown-menu-icon-size);
+          height: var(--mr-dropdown-menu-icon-size);
+          margin-right: 8px;
+        }
+        .anchor:disabled {
+          color: var(--chops-button-disabled-color);
+        }
+        button.anchor {
+          box-sizing: border-box;
+          background: none;
+          border: none;
+          font-size: inherit;
+          width: 100%;
+          height: 100%;
+          display: flex;
+          align-items: center;
+          justify-content: var(--mr-dropdown-anchor-justify-content);
+          cursor: pointer;
+          padding: var(--mr-dropdown-anchor-padding);
+          color: var(--chops-link-color);
+          font-weight: var(--mr-dropdown-anchor-font-weight);
+          font-family: inherit;
+        }
+        /* menuAlignment options: right, left, side. */
+        .menu.right {
+          right: 0px;
+        }
+        .menu.left {
+          left: 0px;
+        }
+        .menu.side {
+          left: 100%;
+          top: 0;
+        }
+        .menu {
+          font-size: var(--mr-dropdown-menu-font-size);
+          position: absolute;
+          min-width: var(--mr-dropdown-menu-min-width);
+          max-height: var(--mr-dropdown-menu-max-height);
+          overflow: var(--mr-dropdown-menu-overflow);
+          top: 90%;
+          display: block;
+          background: var(--chops-white);
+          border: var(--chops-accessible-border);
+          z-index: 990;
+          box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+          font-family: inherit;
+        }
+        .menu-item {
+          background: none;
+          margin: 0;
+          border: 0;
+          box-sizing: border-box;
+          text-decoration: none;
+          white-space: nowrap;
+          display: flex;
+          align-items: center;
+          justify-content: left;
+          width: 100%;
+          padding: 0.25em 8px;
+          transition: 0.2s background ease-in-out;
+
+        }
+        .menu-item[hidden] {
+          display: none;
+        }
+        mr-dropdown.menu-item {
+          width: 100%;
+          padding: 0;
+          --mr-dropdown-anchor-padding: 0.25em 8px;
+          --mr-dropdown-anchor-justify-content: space-between;
+        }
+        .menu hr {
+          width: 96%;
+          margin: 0 2%;
+          border: 0;
+          height: 1px;
+          background: hsl(0, 0%, 80%);
+        }
+        .menu a {
+          cursor: pointer;
+          color: var(--chops-link-color);
+        }
+        .menu a:hover, .menu a:focus {
+          background: var(--chops-active-choice-bg);
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button class="anchor"
+        @click=${this.toggle}
+        @keydown=${this._exitMenuOnEsc}
+        ?disabled=${this.disabled}
+        title=${this.title || this.label}
+        aria-label=${this.label}
+        aria-expanded=${this.opened}
+      >
+        ${this.text}
+        <i class="material-icons" aria-hidden="true">${this.icon}</i>
+      </button>
+      <div class="menu ${this.menuAlignment}">
+        ${this.items.map((item, index) => this._renderItem(item, index))}
+        <slot></slot>
+      </div>
+    `;
+  }
+
+  /**
+   * Render a single dropdown menu item.
+   * @param {MenuItem} item
+   * @param {number} index The item's position in the list of items.
+   * @return {TemplateResult}
+   */
+  _renderItem(item, index) {
+    if (item.separator) {
+      // The menu item is a no-op divider between sections.
+      return html`
+        <strong ?hidden=${!item.text} class="menu-item">
+          ${item.text}
+        </strong>
+        <hr />
+      `;
+    }
+    if (item.items && item.items.length) {
+      // The menu contains a sub-menu.
+      return html`
+        <mr-dropdown
+          .text=${item.text}
+          .items=${item.items}
+          menuAlignment="side"
+          icon="arrow_right"
+          data-idx=${index}
+          class="menu-item"
+        ></mr-dropdown>
+      `;
+    }
+
+    return html`
+      <a
+        href=${ifDefined(item.url)}
+        @click=${this._runItemHandler}
+        @keydown=${this._onItemKeydown}
+        data-idx=${index}
+        tabindex="0"
+        class="menu-item"
+      >
+        <i
+          class="material-icons"
+          ?hidden=${item.icon === undefined}
+        >${item.icon}</i>
+        ${item.text}
+      </a>
+    `;
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.label = '';
+    this.text = '';
+    this.items = [];
+    this.icon = 'arrow_drop_down';
+    this.menuAlignment = 'right';
+    this.opened = false;
+    this.disabled = false;
+
+    this._boundCloseOnOutsideClick = this._closeOnOutsideClick.bind(this);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      title: {type: String},
+      label: {type: String},
+      text: {type: String},
+      items: {type: Array},
+      icon: {type: String},
+      menuAlignment: {type: String},
+      opened: {type: Boolean, reflect: true},
+      disabled: {type: Boolean},
+    };
+  }
+
+  /**
+   * Either runs the click handler attached to the clicked item and closes the
+   * menu.
+   * @param {MouseEvent|KeyboardEvent} e
+   */
+  _runItemHandler(e) {
+    if (e instanceof MouseEvent || e.code === 'Enter') {
+      const idx = e.target.dataset.idx;
+      if (idx !== undefined && this.items[idx].handler) {
+        this.items[idx].handler();
+      }
+      this.close();
+    }
+  }
+
+  /**
+   * Runs multiple event handlers when a user types a key while
+   * focusing a menu item.
+   * @param {KeyboardEvent} e
+   */
+  _onItemKeydown(e) {
+    this._runItemHandler(e);
+    this._exitMenuOnEsc(e);
+  }
+
+  /**
+   * If the user types Esc while focusing any dropdown item, then
+   * exit the dropdown.
+   * @param {KeyboardEvent} e
+   */
+  _exitMenuOnEsc(e) {
+    if (e.key === 'Escape') {
+      this.close();
+
+      // Return focus to the anchor of the dropdown on closing, so that
+      // users don't lose their overall focus position within the page.
+      const anchor = this.shadowRoot.querySelector('.anchor');
+      anchor.focus();
+    }
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    window.addEventListener('click', this._boundCloseOnOutsideClick, true);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener('click', this._boundCloseOnOutsideClick, true);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('label') || changedProperties.has('text')) {
+      if (!this.label && !this.text) {
+        console.error(SCREENREADER_ATTRIBUTE_ERROR);
+      }
+    }
+  }
+
+  /**
+   * Closes and opens the dropdown menu.
+   */
+  toggle() {
+    this.opened = !this.opened;
+  }
+
+  /**
+   * Opens the dropdown menu.
+   */
+  open() {
+    this.opened = true;
+  }
+
+  /**
+   * Closes the dropdown menu.
+   */
+  close() {
+    this.opened = false;
+  }
+
+  /**
+   * Click a specific item in mr-dropdown, using JavaScript. Useful for testing.
+   *
+   * @param {number} i index of the item to click.
+   */
+  clickItem(i) {
+    const items = this.shadowRoot.querySelectorAll('.menu-item');
+    items[i].click();
+  }
+
+  /**
+   * @param {MouseEvent} evt
+   * @private
+   */
+  _closeOnOutsideClick(evt) {
+    if (!this.opened) return;
+
+    const hasMenu = evt.composedPath().find(
+        (node) => {
+          return node === this;
+        },
+    );
+    if (hasMenu) return;
+
+    this.close();
+  }
+}
+
+customElements.define('mr-dropdown', MrDropdown);
diff --git a/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
new file mode 100644
index 0000000..51f8ce9
--- /dev/null
+++ b/static_src/elements/framework/mr-dropdown/mr-dropdown.test.js
@@ -0,0 +1,276 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrDropdown, SCREENREADER_ATTRIBUTE_ERROR} from './mr-dropdown.js';
+import sinon from 'sinon';
+
+let element;
+let randomButton;
+
+describe('mr-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-dropdown');
+    document.body.appendChild(element);
+    element.label = 'new dropdown';
+
+    randomButton = document.createElement('button');
+    document.body.appendChild(randomButton);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    document.body.removeChild(randomButton);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDropdown);
+  });
+
+  it('warns users about accessibility when no label or text', async () => {
+    element.label = 'ok';
+    sinon.spy(console, 'error');
+
+    await element.updateComplete;
+    sinon.assert.notCalled(console.error);
+
+    element.label = undefined;
+
+    await element.updateComplete;
+    sinon.assert.calledWith(console.error, SCREENREADER_ATTRIBUTE_ERROR);
+
+    console.error.restore();
+  });
+
+  it('toggle changes opened state', () => {
+    element.open();
+    assert.isTrue(element.opened);
+
+    element.close();
+    assert.isFalse(element.opened);
+
+    element.toggle();
+    assert.isTrue(element.opened);
+
+    element.toggle();
+    assert.isFalse(element.opened);
+
+    element.toggle();
+    element.toggle();
+    assert.isFalse(element.opened);
+  });
+
+  it('clicking outside element closes menu', () => {
+    element.open();
+    assert.isTrue(element.opened);
+
+    randomButton.click();
+
+    assert.isFalse(element.opened);
+  });
+
+  it('escape while focusing the anchor closes menu', async () => {
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const anchor = element.shadowRoot.querySelector('.anchor');
+    anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+    assert.isFalse(element.opened);
+  });
+
+  it('other key while focusing the anchor does not close menu', async () => {
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const anchor = element.shadowRoot.querySelector('.anchor');
+    anchor.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+    assert.isTrue(element.opened);
+  });
+
+  it('escape while focusing an item closes the menu', async () => {
+    element.items = [{text: 'An item'}];
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(element.opened);
+
+    const item = element.shadowRoot.querySelector('.menu-item');
+    item.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
+
+    assert.isFalse(element.opened);
+  });
+
+  it('icon hidden when undefined', async () => {
+    element.items = [
+      {text: 'test'},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isTrue(icon.hidden);
+  });
+
+  it('icon shown when defined, even as empty string', async () => {
+    element.items = [
+      {text: 'test', icon: ''},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isFalse(icon.hidden);
+    assert.equal(icon.textContent.trim(), '');
+  });
+
+  it('icon shown when set to material icon', async () => {
+    element.items = [
+      {text: 'test', icon: 'check'},
+    ];
+
+    await element.updateComplete;
+
+    const icon = element.shadowRoot.querySelector(
+        '.menu-item > .material-icons');
+
+    assert.isFalse(icon.hidden);
+    assert.equal(icon.textContent.trim(), 'check');
+  });
+
+  it('items with handlers are handled', async () => {
+    const handler1 = sinon.spy();
+    const handler2 = sinon.spy();
+    const handler3 = sinon.spy();
+
+    element.items = [
+      {
+        url: '#',
+        text: 'blah',
+        handler: handler1,
+      },
+      {
+        url: '#',
+        text: 'rutabaga noop',
+        handler: handler2,
+      },
+      {
+        url: '#',
+        text: 'click me please',
+        handler: handler3,
+      },
+    ];
+
+    element.open();
+
+    await element.updateComplete;
+
+    element.clickItem(0);
+
+    assert.isTrue(handler1.calledOnce);
+    assert.isFalse(handler2.called);
+    assert.isFalse(handler3.called);
+
+    element.clickItem(2);
+
+    assert.isTrue(handler1.calledOnce);
+    assert.isFalse(handler2.called);
+    assert.isTrue(handler3.calledOnce);
+  });
+
+  describe('nested dropdown menus', () => {
+    beforeEach(() => {
+      element.items = [
+        {
+          text: 'test',
+          items: [
+            {text: 'item 1'},
+            {text: 'item 2'},
+            {text: 'item 3'},
+          ],
+        },
+      ];
+
+      element.open();
+    });
+
+    it('nested dropdown menu renders', async () => {
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+      assert.equal(nestedDropdown.text, 'test');
+      assert.deepEqual(nestedDropdown.items, [
+        {text: 'item 1'},
+        {text: 'item 2'},
+        {text: 'item 3'},
+      ]);
+    });
+
+    it('clicking nested item with handler calls handler', async () => {
+      const handler = sinon.stub();
+      element.items = [{
+        text: 'test',
+        items: [
+          {text: 'item 1'},
+          {
+            text: 'item with handler',
+            handler,
+          },
+        ],
+      }];
+
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+
+      nestedDropdown.open();
+      await element.updateComplete;
+
+      // Clicking an unrelated nested item shouldn't call the handler.
+      nestedDropdown.clickItem(0);
+      // Nor should clicking the parent item call the handler.
+      element.clickItem(0);
+      sinon.assert.notCalled(handler);
+
+      element.open();
+      nestedDropdown.open();
+      await element.updateComplete;
+
+      nestedDropdown.clickItem(1);
+      sinon.assert.calledOnce(handler);
+    });
+
+    it('clicking nested dropdown menu toggles nested menu', async () => {
+      await element.updateComplete;
+
+      const nestedDropdown = element.shadowRoot.querySelector('mr-dropdown');
+      const nestedAnchor = nestedDropdown.shadowRoot.querySelector('.anchor');
+
+      assert.isTrue(element.opened);
+      assert.isFalse(nestedDropdown.opened);
+
+      nestedAnchor.click();
+      await element.updateComplete;
+
+      assert.isTrue(element.opened);
+      assert.isTrue(nestedDropdown.opened);
+
+      nestedAnchor.click();
+      await element.updateComplete;
+
+      assert.isTrue(element.opened);
+      assert.isFalse(nestedDropdown.opened);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-error/mr-error.js b/static_src/elements/framework/mr-error/mr-error.js
new file mode 100644
index 0000000..084a326
--- /dev/null
+++ b/static_src/elements/framework/mr-error/mr-error.js
@@ -0,0 +1,51 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+
+/**
+ * `<mr-error>`
+ *
+ * A container for showing errors.
+ *
+ */
+export class MrError extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+        justify-content: flex-start;
+        box-sizing: border-box;
+        width: 100%;
+        margin: 0.5em 0;
+        padding: 0.25em 8px;
+        border: 1px solid #B71C1C;
+        border-radius: 4px;
+        background: #FFEBEE;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: #B71C1C;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <i class="material-icons">close</i>
+      <slot></slot>
+    `;
+  }
+}
+
+customElements.define('mr-error', MrError);
diff --git a/static_src/elements/framework/mr-header/mr-header.js b/static_src/elements/framework/mr-header/mr-header.js
new file mode 100644
index 0000000..6603c85
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.js
@@ -0,0 +1,427 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
+import '../mr-dropdown/mr-dropdown.js';
+import '../mr-dropdown/mr-account-dropdown.js';
+import './mr-search-bar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * @type {Object<string, string>} JS coding of enum values from
+ *    appengine/monorail/api/v3/api_proto/project_objects.proto.
+ */
+const projectRoles = Object.freeze({
+  OWNER: 'Owner',
+  MEMBER: 'Member',
+  CONTRIBUTOR: 'Contributor',
+  NONE: '',
+});
+
+/**
+ * `<mr-header>`
+ *
+ * The header for Monorail.
+ *
+ */
+export class MrHeader extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          color: var(--chops-header-text-color);
+          box-sizing: border-box;
+          background: hsl(221, 67%, 92%);
+          width: 100%;
+          height: var(--monorail-header-height);
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          z-index: 800;
+          background-color: var(--chops-primary-header-bg);
+          border-bottom: var(--chops-normal-border);
+          top: 0;
+          position: fixed;
+          padding: 0 4px;
+          font-size: var(--chops-large-font-size);
+        }
+        @media (max-width: 840px) {
+          :host {
+            position: static;
+          }
+        }
+        a {
+          font-size: inherit;
+          color: var(--chops-link-color);
+          text-decoration: none;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+          padding: 0 4px;
+          flex-grow: 0;
+          flex-shrink: 0;
+        }
+        a[hidden] {
+          display: none;
+        }
+        a.button {
+          font-size: inherit;
+          height: auto;
+          margin: 0 8px;
+          border: 0;
+          height: 30px;
+        }
+        .home-link {
+          color: var(--chops-gray-900);
+          letter-spacing: 0.5px;
+          font-size: 18px;
+          font-weight: 400;
+          display: flex;
+          font-stretch: 100%;
+          padding-left: 8px;
+        }
+        a.home-link img {
+          /** Cover up default padding with the custom logo. */
+          margin-left: -8px;
+        }
+        a.home-link:hover {
+          text-decoration: none;
+        }
+        mr-search-bar {
+          margin-left: 8px;
+          flex-grow: 2;
+          max-width: 1000px;
+        }
+        i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+        }
+        i.material-icons[hidden] {
+          display: none;
+        }
+        .right-section {
+          font-size: inherit;
+          display: flex;
+          align-items: center;
+          height: 100%;
+          margin-left: auto;
+          justify-content: flex-end;
+        }
+        .hamburger-icon:hover {
+          text-decoration: none;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return this.projectName ?
+        this._renderProjectScope() : this._renderNonProjectScope();
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <mr-keystrokes
+        .issueId=${this.queryParams.id}
+        .queryParams=${this.queryParams}
+        .issueEntryUrl=${this.issueEntryUrl}
+      ></mr-keystrokes>
+      <a href="/p/${this.projectName}/issues/list" class="home-link">
+        ${this.projectThumbnailUrl ? html`
+          <img
+            class="project-logo"
+            src=${this.projectThumbnailUrl}
+            title=${this.projectName}
+          />
+        ` : this.projectName}
+      </a>
+      <mr-dropdown
+        class="project-selector"
+        .text=${this.projectName}
+        .items=${this._projectDropdownItems}
+        menuAlignment="left"
+        title=${this.presentationConfig.projectSummary}
+      ></mr-dropdown>
+      <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
+        New issue
+      </a>
+      <mr-search-bar
+        .projectName=${this.projectName}
+        .userDisplayName=${this.userDisplayName}
+        .projectSavedQueries=${this.presentationConfig.savedQueries}
+        .initialCan=${this._currentCan}
+        .initialQuery=${this._currentQuery}
+        .queryParams=${this.queryParams}
+      ></mr-search-bar>
+
+      <div class="right-section">
+        <mr-dropdown
+          icon="settings"
+          label="Project Settings"
+          .items=${this._projectSettingsItems}
+        ></mr-dropdown>
+
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderNonProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <a class="hamburger-icon" title="Main menu" hidden>
+        <i class="material-icons">menu</i>
+      </a>
+      ${this._headerTitle ?
+          html`<span class="home-link">${this._headerTitle}</span>` :
+          html`<a href="/" class="home-link">Monorail</a>`}
+
+      <div class="right-section">
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderAccount() {
+    if (!this.userDisplayName) {
+      return html`<a href=${this.loginUrl}>Sign in</a>`;
+    }
+
+    return html`
+      <mr-account-dropdown
+        .userDisplayName=${this.userDisplayName}
+        .logoutUrl=${this.logoutUrl}
+        .loginUrl=${this.loginUrl}
+      ></mr-account-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      loginUrl: {type: String},
+      logoutUrl: {type: String},
+      projectName: {type: String},
+      // Project thumbnail is set separately from presentationConfig to prevent
+      // "flashing" logo when navigating EZT pages.
+      projectThumbnailUrl: {type: String},
+      userDisplayName: {type: String},
+      isSiteAdmin: {type: Boolean},
+      userProjects: {type: Object},
+      presentationConfig: {type: Object},
+      queryParams: {type: Object},
+      // TODO(zhangtiff): Change this to be dynamically computed by the
+      //   frontend with logic similar to ComputeIssueEntryURL().
+      issueEntryUrl: {type: String},
+      clientLogger: {type: Object},
+      _headerTitle: {type: String},
+      _currentQuery: {type: String},
+      _currentCan: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.presentationConfig = {};
+    this.userProjects = {};
+    this.isSiteAdmin = false;
+
+    this._headerTitle = '';
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+
+    this.userProjects = userV0.projects(state);
+
+    const currentUser = userV0.currentUser(state);
+    this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
+
+    const presentationConfig = projectV0.viewedPresentationConfig(state);
+    this.presentationConfig = presentationConfig;
+    // Set separately in order allow EZT pages to load project logo before
+    // the GetPresentationConfig pRPC request.
+    this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
+
+    this._headerTitle = sitewide.headerTitle(state);
+
+    this._currentQuery = sitewide.currentQuery(state);
+    this._currentCan = sitewide.currentCan(state);
+
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  /**
+   * @return {boolean} whether the currently logged in user has admin
+   *   privileges for the currently viewed project.
+   */
+  get canAdministerProject() {
+    if (!this.userDisplayName) return false; // Not logged in.
+    if (this.isSiteAdmin) return true;
+    if (!this.userProjects || !this.userProjects.ownerOf) return false;
+    return this.userProjects.ownerOf.includes(this.projectName);
+  }
+
+  /**
+   * @return {string} The name of the role the user has in the viewed project.
+   */
+  get roleInCurrentProject() {
+    if (!this.userProjects || !this.projectName) return projectRoles.NONE;
+    const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
+
+    if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
+    if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
+    if (contributorTo.includes(this.projectName)) {
+      return projectRoles.CONTRIBUTOR;
+    }
+
+    return projectRoles.NONE;
+  }
+
+  // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
+  // filing wizard.
+  /**
+   * @return {string} A URL for the page the issue filing wizard posts to.
+   */
+  get _wizardPostUrl() {
+    // The issue filing wizard posts to the legacy issue entry page's ".do"
+    // endpoint.
+    return `${this._origin}/p/${this.projectName}/issues/entry.do`;
+  }
+
+  /**
+   * @return {string} The domain name of the current page.
+   */
+  get _origin() {
+    return window.location.origin;
+  }
+
+  /**
+   * Computes the URL the user should see to a file an issue, accounting
+   * for the case where a project has a customIssueEntryUrl to navigate to
+   * the wizard as well.
+   * @return {string} The URL that "New issue" button goes to.
+   */
+  get issueEntryUrl() {
+    const config = this.presentationConfig;
+    const role = this.roleInCurrentProject;
+    const mayBeRedirectedToWizard = role === projectRoles.NONE;
+    if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
+        !mayBeRedirectedToWizard) {
+      return `/p/${this.projectName}/issues/entry`;
+    }
+
+    const token = prpcClient.token;
+
+    const customUrl = this.presentationConfig.customIssueEntryUrl;
+
+    return `${customUrl}?token=${token}&role=${
+      role}&continue=${this._wizardPostUrl}`;
+  }
+
+  /**
+   * @return {Array<MenuItem>} the dropdown items for the project selector,
+   *   showing which projects a user can switch to.
+   */
+  get _projectDropdownItems() {
+    const {userProjects, loginUrl} = this;
+    if (!this.userDisplayName) {
+      return [{text: 'Sign in to see your projects', url: loginUrl}];
+    }
+
+    const items = [];
+    const starredProjects = userProjects.starredProjects || [];
+    const projects = (userProjects.ownerOf || [])
+        .concat(userProjects.memberOf || [])
+        .concat(userProjects.contributorTo || []);
+
+    if (projects.length) {
+      projects.sort();
+      items.push({text: 'My Projects', separator: true});
+
+      projects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (starredProjects.length) {
+      starredProjects.sort();
+      items.push({text: 'Starred Projects', separator: true});
+
+      starredProjects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (items.length) {
+      items.push({separator: true});
+    }
+
+    items.push({text: 'All projects', url: '/hosting/'});
+    items.forEach((item) => {
+      item.handler = () => this._projectChangedHandler(item.url);
+    });
+    return items;
+  }
+
+  /**
+   * @return {Array<MenuItem>} dropdown menu items to show in the project
+   *   settings menu.
+   */
+  get _projectSettingsItems() {
+    const {projectName, canAdministerProject} = this;
+    const items = [
+      {text: 'People', url: `/p/${projectName}/people/list`},
+      {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
+      {text: 'History', url: `/p/${projectName}/updates/list`},
+    ];
+
+    if (canAdministerProject) {
+      items.push({separator: true});
+      items.push({text: 'Administer', url: `/p/${projectName}/admin`});
+    }
+    return items;
+  }
+
+  /**
+   * Records Google Analytics events for when users change projects using
+   * the selector.
+   * @param {string} url which project URL the user is navigating to.
+   */
+  _projectChangedHandler(url) {
+    // Just log it to GA and continue.
+    logEvent('mr-header', 'project-change', url);
+  }
+}
+
+customElements.define('mr-header', MrHeader);
diff --git a/static_src/elements/framework/mr-header/mr-header.test.js b/static_src/elements/framework/mr-header/mr-header.test.js
new file mode 100644
index 0000000..277347f
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.test.js
@@ -0,0 +1,191 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrHeader} from './mr-header.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-header', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-header');
+    document.body.appendChild(element);
+
+    window.ga = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHeader);
+  });
+
+  it('presentationConfig renders', async () => {
+    element.projectName = 'best-project';
+    element.projectThumbnailUrl = 'http://images.google.com/';
+    element.presentationConfig = {
+      projectSummary: 'The best project',
+    };
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelector('.project-logo').src,
+        'http://images.google.com/');
+
+    assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+        '/p/best-project/issues/entry');
+
+    assert.equal(element.shadowRoot.querySelector('.project-selector').title,
+        'The best project');
+  });
+
+  describe('issueEntryUrl', () => {
+    let oldToken;
+
+    beforeEach(() => {
+      oldToken = prpcClient.token;
+      prpcClient.token = 'token1';
+
+      element.projectName = 'proj';
+
+      sinon.stub(element, '_origin').get(() => 'http://localhost');
+    });
+
+    afterEach(() => {
+      prpcClient.token = oldToken;
+    });
+
+    it('updates on project change', async () => {
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/proj/issues/entry');
+
+      element.projectName = 'the-best-project';
+
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/the-best-project/issues/entry');
+    });
+
+    it('generates wizard URL when customIssueEntryUrl defined', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['not-proj']};
+      element.userDisplayName = 'test@example.com';
+      assert.equal(element.issueEntryUrl,
+          'https://issue.wizard?token=token1&role=&' +
+          'continue=http://localhost/p/proj/issues/entry.do');
+    });
+
+    it('uses default issue filing URL when user is not logged in', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userDisplayName = '';
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project owner', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project member', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {memberOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project contributor', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {contributorTo: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+  });
+
+
+  it('canAdministerProject is false when user is not logged in', () => {
+    element.userDisplayName = '';
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is site admin', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = true;
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.isSiteAdmin = false;
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is owner', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = false;
+
+    element.projectName = 'chromium';
+    element.userProjects = {ownerOf: ['chromium']};
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.projectName = 'v8';
+
+    assert.isFalse(element.canAdministerProject);
+
+    element.userProjects = {memberOf: ['v8']};
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('_projectDropdownItems tells user to sign in if not logged in', () => {
+    element.userDisplayName = '';
+    element.loginUrl = 'http://login';
+
+    const items = element._projectDropdownItems;
+
+    // My Projects
+    assert.deepEqual(items[0], {
+      text: 'Sign in to see your projects',
+      url: 'http://login',
+    });
+  });
+
+  it('_projectDropdownItems computes projects for user', () => {
+    element.userProjects = {
+      ownerOf: ['chromium'],
+      memberOf: ['v8'],
+      contributorTo: ['skia'],
+      starredProjects: ['gerrit'],
+    };
+    element.userDisplayName = 'test@example.com';
+
+    const items = element._projectDropdownItems;
+
+    // TODO(http://crbug.com/monorail/6236): Replace these checks with
+    // deepInclude once we upgrade Chai.
+    // My Projects
+    assert.equal(items[1].text, 'chromium');
+    assert.equal(items[1].url, '/p/chromium/issues/list');
+    assert.equal(items[2].text, 'skia');
+    assert.equal(items[2].url, '/p/skia/issues/list');
+    assert.equal(items[3].text, 'v8');
+    assert.equal(items[3].url, '/p/v8/issues/list');
+
+    // Starred Projects
+    assert.equal(items[5].text, 'gerrit');
+    assert.equal(items[5].url, '/p/gerrit/issues/list');
+  });
+});
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.js b/static_src/elements/framework/mr-header/mr-search-bar.js
new file mode 100644
index 0000000..536dfcf
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.js
@@ -0,0 +1,501 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+
+import '../mr-dropdown/mr-dropdown.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import ClientLogger from 'monitoring/client-logger';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+
+// Search field input regex testing for all digits
+// indicating that the user wants to jump to the specified issue.
+const JUMP_RE = /^\d+$/;
+
+/**
+ * `<mr-search-bar>`
+ *
+ * The searchbar for Monorail.
+ *
+ */
+export class MrSearchBar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --mr-search-bar-background: var(--chops-white);
+        --mr-search-bar-border-radius: 4px;
+        --mr-search-bar-border: var(--chops-normal-border);
+        --mr-search-bar-chip-color: var(--chops-gray-200);
+        height: 30px;
+        font-size: var(--chops-large-font-size);
+      }
+      input#searchq {
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        flex-grow: 2;
+        min-width: 100px;
+        border: none;
+        border-top: var(--mr-search-bar-border);
+        border-bottom: var(--mr-search-bar-border);
+        background: var(--mr-search-bar-background);
+        height: 100%;
+        box-sizing: border-box;
+        padding: 0 2px;
+        font-size: inherit;
+      }
+      mr-dropdown {
+        text-align: right;
+        display: flex;
+        text-overflow: ellipsis;
+        box-sizing: border-box;
+        background: var(--mr-search-bar-background);
+        border: var(--mr-search-bar-border);
+        border-left: 0;
+        border-radius: 0 var(--mr-search-bar-border-radius)
+          var(--mr-search-bar-border-radius) 0;
+        height: 100%;
+        align-items: center;
+        justify-content: center;
+        text-decoration: none;
+      }
+      button {
+        font-size: inherit;
+        order: -1;
+        background: var(--mr-search-bar-background);
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-left: none;
+        border-right: none;
+        padding: 0 8px;
+      }
+      form {
+        display: flex;
+        height: 100%;
+        width: 100%;
+        align-items: center;
+        justify-content: flex-start;
+        flex-direction: row;
+      }
+      i.material-icons {
+        font-size: var(--chops-icon-font-size);
+        color: var(--chops-primary-icon-color);
+      }
+      .select-container {
+        order: -2;
+        max-width: 150px;
+        min-width: 50px;
+        flex-shrink: 1;
+        height: 100%;
+        position: relative;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-radius: var(--mr-search-bar-border-radius) 0 0
+          var(--mr-search-bar-border-radius);
+        background: var(--mr-search-bar-chip-color);
+      }
+      .select-container i.material-icons {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        width: 20px;
+        z-index: 2;
+        padding: 0;
+      }
+      select {
+        color: var(--chops-primary-font-color);
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        -webkit-appearance: none;
+        -moz-appearance: none;
+        appearance: none;
+        text-overflow: ellipsis;
+        cursor: pointer;
+        width: 100%;
+        height: 100%;
+        background: none;
+        margin: 0;
+        padding: 0 20px 0 8px;
+        box-sizing: border-box;
+        border: 0;
+        z-index: 3;
+        font-size: inherit;
+        position: relative;
+      }
+      select::-ms-expand {
+        display: none;
+      }
+      select::after {
+        position: relative;
+        right: 0;
+        content: 'arrow_drop_down';
+        font-family: 'Material Icons';
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <form
+        @submit=${this._submitSearch}
+        @keypress=${this._submitSearchWithKeypress}
+      >
+        ${this._renderSearchScopeSelector()}
+        <input
+          id="searchq"
+          type="text"
+          name="q"
+          placeholder="Search ${this.projectName} issues..."
+          .value=${this.initialQuery || ''}
+          autocomplete="off"
+          aria-label="Search box"
+          @focus=${this._searchEditStarted}
+          @blur=${this._searchEditFinished}
+          spellcheck="false"
+        />
+        <button type="submit">
+          <i class="material-icons">search</i>
+        </button>
+        <mr-dropdown
+          label="Search options"
+          .items=${this._searchMenuItems}
+        ></mr-dropdown>
+      </form>
+    `;
+  }
+
+  /**
+   * Render helper for the select menu that lets user select which search
+   * context/saved query they want to use.
+   * @return {TemplateResult}
+   */
+  _renderSearchScopeSelector() {
+    return html`
+      <div class="select-container">
+        <i class="material-icons" role="presentation">arrow_drop_down</i>
+        <select
+          id="can"
+          name="can"
+          @change=${this._redirectOnSelect}
+          aria-label="Search scope"
+        >
+          <optgroup label="Search within">
+            <option
+              value="1"
+              ?selected=${this.initialCan === '1'}
+            >All issues</option>
+            <option
+              value="2"
+              ?selected=${this.initialCan === '2'}
+            >Open issues</option>
+            <option
+              value="3"
+              ?selected=${this.initialCan === '3'}
+            >Open and owned by me</option>
+            <option
+              value="4"
+              ?selected=${this.initialCan === '4'}
+            >Open and reported by me</option>
+            <option
+              value="5"
+              ?selected=${this.initialCan === '5'}
+            >Open and starred by me</option>
+            <option
+              value="8"
+              ?selected=${this.initialCan === '8'}
+            >Open with comment by me</option>
+            <option
+              value="6"
+              ?selected=${this.initialCan === '6'}
+            >New issues</option>
+            <option
+              value="7"
+              ?selected=${this.initialCan === '7'}
+            >Issues to verify</option>
+          </optgroup>
+          <optgroup label="Project queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.projectSavedQueries, 'project-query')}
+            <option data-href="/p/${this.projectName}/adminViews">
+              Manage project queries...
+            </option>
+          </optgroup>
+          <optgroup label="My saved queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.userSavedQueries, 'user-query')}
+            <option data-href="/u/${this.userDisplayName}/queries">
+              Manage my saved queries...
+            </option>
+          </optgroup>
+        </select>
+      </div>
+    `;
+  }
+
+  /**
+   * Render helper for adding saved queries to the search scope select.
+   * @param {Array<SavedQuery>} queries Queries to render.
+   * @param {string} className CSS class to be applied to each option.
+   * @return {Array<TemplateResult>}
+   */
+  _renderSavedQueryOptions(queries, className) {
+    if (!queries) return;
+    return queries.map((query) => html`
+      <option
+        class=${className}
+        value=${query.queryId}
+        ?selected=${this.initialCan === query.queryId}
+      >${query.name}</option>
+    `);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      userDisplayName: {type: String},
+      initialCan: {type: String},
+      initialQuery: {type: String},
+      projectSavedQueries: {type: Array},
+      userSavedQueries: {type: Array},
+      queryParams: {type: Object},
+      keptQueryParams: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.queryParams = {};
+    this.keptQueryParams = [
+      'sort',
+      'groupby',
+      'colspec',
+      'x',
+      'y',
+      'mode',
+      'cells',
+      'num',
+    ];
+    this.initialQuery = '';
+    this.initialCan = '2';
+    this.projectSavedQueries = [];
+    this.userSavedQueries = [];
+
+    this.clientLogger = new ClientLogger('issues');
+
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Global event listeners. Make sure to unbind these when the
+    // element disconnects.
+    this._boundFocus = this.focus.bind(this);
+    window.addEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (this.userDisplayName && changedProperties.has('userDisplayName')) {
+      const userSavedQueriesPromise = prpcClient.call('monorail.Users',
+          'GetSavedQueries', {});
+      userSavedQueriesPromise.then((resp) => {
+        this.userSavedQueries = resp.savedQueries;
+      });
+    }
+  }
+
+  /**
+   * Sends an event to ClientLogger describing that the user started typing
+   * a search query.
+   */
+  _searchEditStarted() {
+    this.clientLogger.logStart('query-edit', 'user-time');
+    this.clientLogger.logStart('issue-search', 'user-time');
+  }
+
+  /**
+   * Sends an event to ClientLogger saying that the user finished typing a
+   * search.
+   */
+  _searchEditFinished() {
+    this.clientLogger.logEnd('query-edit');
+  }
+
+  /**
+   * On Shift+Enter, this handler opens the search in a new tab.
+   * @param {KeyboardEvent} e
+   */
+  _submitSearchWithKeypress(e) {
+    if (e.key === 'Enter' && (e.shiftKey)) {
+      const form = e.currentTarget;
+      this._runSearch(form, true);
+    }
+    // In all other cases, we want to let the submit handler do the work.
+    // ie: pressing 'Enter' on a form should natively open it in a new tab.
+  }
+
+  /**
+   * Update the URL on form submit.
+   * @param {Event} e
+   */
+  _submitSearch(e) {
+    e.preventDefault();
+
+    const form = e.target;
+    this._runSearch(form);
+  }
+
+  /**
+   * Updates the URL with the new search set in the query string.
+   * @param {HTMLFormElement} form the native form element to submit.
+   * @param {boolean=} newTab whether to open the search in a new tab.
+   */
+  _runSearch(form, newTab) {
+    this.clientLogger.logEnd('query-edit');
+    this.clientLogger.logPause('issue-search', 'user-time');
+    this.clientLogger.logStart('issue-search', 'computer-time');
+
+    const params = {};
+
+    this.keptQueryParams.forEach((param) => {
+      if (param in this.queryParams) {
+        params[param] = this.queryParams[param];
+      }
+    });
+
+    params.q = form.q.value.trim();
+    params.can = form.can.value;
+
+    this._navigateToNext(params, newTab);
+  }
+
+  /**
+   * Attempt to jump-to-issue, otherwise continue to list view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   */
+  async _navigateToNext(params, newTab = false) {
+    let resp;
+    if (JUMP_RE.test(params.q)) {
+      const message = {
+        issueRef: {
+          projectName: this.projectName,
+          localId: params.q,
+        },
+      };
+
+      try {
+        resp = await prpcClient.call(
+            'monorail.Issues', 'GetIssue', message,
+        );
+      } catch (error) {
+        // Fall through to navigateToList
+      }
+    }
+    if (resp && resp.issue) {
+      const link = issueRefToUrl(resp.issue, params);
+      this._page(link);
+    } else {
+      this._navigateToList(params, newTab);
+    }
+  }
+
+  /**
+   * Navigate to list view, currently splits on old and new view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   * @fires Event#refreshList
+   * @private
+   */
+  _navigateToList(params, newTab = false) {
+    const pathname = `/p/${this.projectName}/issues/list`;
+
+    const hasChanges = !window.location.pathname.startsWith(pathname) ||
+      this.queryParams.q !== params.q ||
+      this.queryParams.can !== params.can;
+
+    const url =`${pathname}?${qs.stringify(params)}`;
+
+    if (newTab) {
+      window.open(url, '_blank', 'noopener');
+    } else if (hasChanges) {
+      this._page(url);
+    } else {
+      // TODO(zhangtiff): Replace this event with Redux once all of Monorail
+      // uses Redux.
+      // This is needed because navigating to the exact same page does not
+      // cause a URL change to happen.
+      this.dispatchEvent(new Event('refreshList',
+          {'composed': true, 'bubbles': true}));
+    }
+  }
+
+  /**
+   * Wrap the native focus() function for the search form to allow parent
+   * elements to focus the search.
+   */
+  focus() {
+    const search = this.shadowRoot.querySelector('#searchq');
+    search.focus();
+  }
+
+  /**
+   * Populates the search dropdown.
+   * @return {Array<MenuItem>}
+   */
+  get _searchMenuItems() {
+    const projectName = this.projectName;
+    return [
+      {
+        text: 'Advanced search',
+        url: `/p/${projectName}/issues/advsearch`,
+      },
+      {
+        text: 'Search tips',
+        url: `/p/${projectName}/issues/searchtips`,
+      },
+    ];
+  }
+
+  /**
+   * The search dropdown includes links like "Manage my saved queries..."
+   * that automatically navigate a user to a new page when they select those
+   * options.
+   * @param {Event} evt
+   */
+  _redirectOnSelect(evt) {
+    const target = evt.target;
+    const option = target.options[target.selectedIndex];
+
+    if (option.dataset.href) {
+      this._page(option.dataset.href);
+    }
+  }
+}
+
+customElements.define('mr-search-bar', MrSearchBar);
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.test.js b/static_src/elements/framework/mr-header/mr-search-bar.test.js
new file mode 100644
index 0000000..c758a41
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.test.js
@@ -0,0 +1,244 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrSearchBar} from './mr-search-bar.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-search-bar', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-search-bar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrSearchBar);
+  });
+
+  it('render user saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.userSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.user-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('render project saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.projectSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.project-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('search input resets form value when initialQuery changes', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialQuery = 'second query';
+    await element.updateComplete;
+
+    // 'blah' disappears because the new initialQuery causes the form to
+    // reset.
+    assert.equal(queryInput.value, 'second query');
+  });
+
+  it('unrelated property changes do not reset query form', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialCan = '5';
+    await element.updateComplete;
+
+    assert.equal(queryInput.value, 'blah');
+  });
+
+  it('spell check is off for search bar', async () => {
+    await element.updateComplete;
+    const searchElement = element.shadowRoot.querySelector('#searchq');
+    assert.equal(searchElement.getAttribute('spellcheck'), 'false');
+  });
+
+  describe('search form submit', () => {
+    let prpcClientStub;
+    beforeEach(() => {
+      element.clientLogger = clientLoggerFake();
+
+      element._page = sinon.stub();
+      sinon.stub(window, 'open');
+
+      element.projectName = 'chromium';
+      prpcClientStub = sinon.stub(prpcClient, 'call');
+    });
+
+    afterEach(() => {
+      window.open.restore();
+      prpcClient.call.restore();
+    });
+
+    it('prevents default', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      // Note: HTMLFormElement's submit function does not run submit handlers
+      // but clicking a submit buttons programmatically works.
+      const event = new Event('submit');
+      sinon.stub(event, 'preventDefault');
+      form.dispatchEvent(event);
+
+      sinon.assert.calledOnce(event.preventDefault);
+    });
+
+    it('uses initial values when no form changes', async () => {
+      element.initialQuery = 'test query';
+      element.initialCan = '3';
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test%20query&can=3');
+    });
+
+    it('adds form values to url', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test&can=1');
+    });
+
+    it('trims query', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '  abc  ';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=abc&can=1');
+    });
+
+    it('jumps to issue for digit-only query', async () => {
+      prpcClientStub.returns(Promise.resolve({issue: 'hello world'}));
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '123';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      await element._navigateToNext;
+
+      const expected = issueRefToUrl('hello world', {q: '123', can: '1'});
+      sinon.assert.calledWith(element._page, expected);
+    });
+
+    it('only keeps kept query params', async () => {
+      element.queryParams = {fakeParam: 'test', x: 'Status'};
+      element.keptParams = ['x'];
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?x=Status&q=&can=2');
+    });
+
+    it('on shift+enter opens search in new tab', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      // Dispatch event from an input in the form.
+      form.q.dispatchEvent(new KeyboardEvent('keypress',
+          {key: 'Enter', shiftKey: true, bubbles: true}));
+
+      sinon.assert.calledOnce(window.open);
+      sinon.assert.calledWith(window.open,
+          '/p/chromium/issues/list?q=test&can=1', '_blank', 'noopener');
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
new file mode 100644
index 0000000..13f8267
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.js
@@ -0,0 +1,62 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/** @const {string} CSV download link's data href prefix, RFC 4810 Section 3 */
+export const CSV_DATA_HREF_PREFIX = 'data:text/csv;charset=utf-8,';
+
+/**
+ * Format array into plaintext csv
+ * @param {Array<Array>} data
+ * @return {string}
+ */
+export const convertListContentToCsv = (data) => {
+  const result = data.reduce((acc, row) => {
+    return `${acc}\r\n${row.map(preventCSVInjectionAndStringify).join(',')}`;
+  }, '');
+  // Remove leading /r and /n
+  return result.slice(2);
+};
+
+/**
+ * Prevent CSV injection, escape double quotes, and wrap with double quotes
+ * See owasp.org/index.php/CSV_Injection
+ * @param {string} cell
+ * @return {string}
+ */
+export const preventCSVInjectionAndStringify = (cell) => {
+  // Prepend all double quotes with another double quote, RFC 4810 Section 2.7
+  let escaped = cell.replace(/"/g, '""');
+
+  // prevent CSV injection: owasp.org/index.php/CSV_Injection
+  if (cell[0] === '=' ||
+      cell[0] === '+' ||
+      cell[0] === '-' ||
+      cell[0] === '@') {
+    escaped = `'${escaped}`;
+  }
+
+  // Wrap cell with double quotes, RFC 4810 Section 2.7
+  return `"${escaped}"`;
+};
+
+/**
+ * Prepare data for csv download by converting array of array into csv string
+ * @param {Array<Array<string>>} data
+ * @param {Array<string>=} headers Column headers
+ * @return {string} CSV formatted string
+ */
+export const prepareDataForDownload = (data, headers = []) => {
+  const mainContent = [headers, ...data];
+
+  return `${convertListContentToCsv(mainContent)}`;
+};
+
+/**
+ * Constructs download link url from csv string data.
+ * @param {string} data CSV data
+ * @return {string}
+ */
+export const constructHref = (data = '') => {
+  return `${CSV_DATA_HREF_PREFIX}${encodeURIComponent(data)}`;
+};
diff --git a/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
new file mode 100644
index 0000000..cd124a5
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/list-to-csv-helpers.test.js
@@ -0,0 +1,145 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {
+  constructHref,
+  convertListContentToCsv,
+  prepareDataForDownload,
+  preventCSVInjectionAndStringify,
+} from './list-to-csv-helpers.js';
+
+describe('constructHref', () => {
+  it('has default of empty string', () => {
+    const result = constructHref();
+    assert.equal(result, 'data:text/csv;charset=utf-8,');
+  });
+
+  it('starts with data:', () => {
+    const result = constructHref('');
+    assert.isTrue(result.startsWith('data:'));
+  });
+
+  it('uses charset=utf-8', () => {
+    const result = constructHref('');
+    assert.isTrue(result.search('charset=utf-8') > -1);
+  });
+
+  it('encodes URI component', () => {
+    const encodeFuncStub = sinon.stub(window, 'encodeURIComponent');
+    constructHref('');
+    sinon.assert.calledOnce(encodeFuncStub);
+
+    window.encodeURIComponent.restore();
+  });
+
+  it('encodes URI component', () => {
+    const input = 'foo, bar fizz=buzz';
+    const expected = 'foo%2C%20bar%20fizz%3Dbuzz';
+    const output = constructHref(input);
+
+    assert.equal(expected, output.split(',')[1]);
+  });
+});
+
+describe('convertListContentToCsv', () => {
+  it('joins rows with carriage return and line feed, CRLF', () => {
+    const input = [['foobar'], ['fizzbuzz']];
+    const expected = '"foobar"\r\n"fizzbuzz"';
+    assert.equal(expected, convertListContentToCsv(input));
+  });
+
+  it('joins columns with commas', () => {
+    const input = [['foo', 'bar', 'fizz', 'buzz']];
+    const expected = '"foo","bar","fizz","buzz"';
+    assert.equal(expected, convertListContentToCsv(input));
+  });
+
+  it('starts with non-empty row', () => {
+    const input = [['foobar']];
+    const expected = '"foobar"';
+    const result = convertListContentToCsv(input);
+    assert.equal(expected, result);
+    assert.isFalse(result.startsWith('\r\n'));
+  });
+});
+
+describe('prepareDataForDownload', () => {
+  it('prepends header row', () => {
+    const headers = ['column1', 'column2'];
+    const result = prepareDataForDownload([['a', 'b']], headers);
+
+    const expected = `"column1","column2"`;
+    assert.equal(expected, result.split('\r\n')[0]);
+    assert.isTrue(result.startsWith(expected));
+  });
+});
+
+describe('preventCSVInjectionAndStringify', () => {
+  it('prepends all double quotes with another double quote', () => {
+    let input = '"hello world"';
+    let expect = '""hello world""';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+    input = 'Just a double quote: " ';
+    expect = 'Just a double quote: "" ';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+
+    input = 'Multiple"double"quotes"""';
+    expect = 'Multiple""double""quotes""""""';
+    assert.equal(expect, preventCSVInjectionAndStringify(input).slice(1, -1));
+  });
+
+  it('wraps string with double quotes', () => {
+    let input = '"hello world"';
+    let expected = preventCSVInjectionAndStringify(input);
+    assert.equal('"', expected[0]);
+    assert.equal('"', expected[expected.length-1]);
+
+    input = 'For unevent quotes too: " ';
+    expected = '"For unevent quotes too: "" "';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = 'And for ending quotes"""';
+    expected = '"And for ending quotes"""""""';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('wraps strings containing commas with double quotes', () => {
+    const input = 'Let\'s, add, a bunch, of, commas,';
+    const expected = '"Let\'s, add, a bunch, of, commas,"';
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('can handle strings containing commas and new line chars', () => {
+    const input = `""new"",\r\nline  "" "",\r\nand 'end', and end`;
+    const expected = `"""""new"""",\r\nline  """" """",\r\nand 'end', and end"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('preserves single quotes', () => {
+    let input = `all the 'single' quotes`;
+    let expected = `"all the 'single' quotes"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `''''' fives single quotes before and after '''''`;
+    expected = `"''''' fives single quotes before and after '''''"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+
+  it('prevents csv injection', () => {
+    let input = `@@Should prepend with single quote`;
+    let expected = `"'@@Should prepend with single quote"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `at symbol @ later on, do not expect ' at start`;
+    expected = `"at symbol @ later on, do not expect ' at start"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+
+    input = `==@+=--@Should prepend with single quote`;
+    expected = `"'==@+=--@Should prepend with single quote"`;
+    assert.equal(expected, preventCSVInjectionAndStringify(input));
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
new file mode 100644
index 0000000..3e0a279
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.js
@@ -0,0 +1,1575 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import page from 'page';
+import {connectStore, store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import {constructHref, prepareDataForDownload} from './list-to-csv-helpers.js';
+import {
+  issueRefToUrl,
+  issueRefToString,
+  issueStringToRef,
+  issueToIssueRef,
+  issueToIssueRefString,
+  labelRefsToOneWordLabels,
+} from 'shared/convertersV0.js';
+import {isTextInput, findDeepEventTarget} from 'shared/dom-helpers.js';
+import {
+  urlWithNewParams,
+  pluralize,
+  setHasAny,
+  objectValuesForKeys,
+} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import './mr-show-columns-dropdown.js';
+
+/**
+ * Column to display name mapping dictionary
+ * @type {Object<string, string>}
+ */
+const COLUMN_DISPLAY_NAMES = Object.freeze({
+  'summary': 'Summary + Labels',
+});
+
+/** @const {number} Button property value of DOM click event */
+const PRIMARY_BUTTON = 0;
+/** @const {number} Button property value of DOM auxclick event */
+const MIDDLE_BUTTON = 1;
+
+/** @const {string} A short transition to ease movement of list items. */
+const EASE_OUT_TRANSITION = 'transform 0.05s cubic-bezier(0, 0, 0.2, 1)';
+
+/**
+ * Really high cardinality attributes like ID and Summary are unlikely to be
+ * useful if grouped, so it's better to just hide the option.
+ * @const {Set<string>}
+ */
+const UNGROUPABLE_COLUMNS = new Set(['id', 'summary']);
+
+/**
+ * Columns that should render as issue links.
+ * @const {Set<string>}
+ */
+const ISSUE_COLUMNS = new Set(['id', 'mergedinto', 'blockedon', 'blocking']);
+
+/**
+ * `<mr-issue-list>`
+ *
+ * A list of issues intended to be used in multiple contexts.
+ * @extends {LitElement}
+ */
+export class MrIssueList extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          width: 100%;
+          font-size: var(--chops-main-font-size);
+        }
+        table {
+          width: 100%;
+        }
+        .edit-widget-container {
+          display: flex;
+          flex-wrap: no-wrap;
+          align-items: center;
+        }
+        mr-issue-star {
+          --mr-star-size: 18px;
+          margin-bottom: 1px;
+          margin-left: 4px;
+        }
+        input[type="checkbox"] {
+          cursor: pointer;
+          margin: 0 4px;
+          width: 16px;
+          height: 16px;
+          border-radius: 2px;
+          box-sizing: border-box;
+          appearance: none;
+          -webkit-appearance: none;
+          border: 2px solid var(--chops-gray-400);
+          position: relative;
+          background: var(--chops-white);
+        }
+        th input[type="checkbox"] {
+          border-color: var(--chops-gray-500);
+        }
+        input[type="checkbox"]:checked {
+          background: var(--chops-primary-accent-color);
+          border-color: var(--chops-primary-accent-color);
+        }
+        input[type="checkbox"]:checked::after {
+          left: 1px;
+          top: 2px;
+          position: absolute;
+          content: "";
+          width: 8px;
+          height: 4px;
+          border: 2px solid white;
+          border-right: none;
+          border-top: none;
+          transform: rotate(-45deg);
+        }
+        td, th.group-header {
+          padding: 4px 8px;
+          text-overflow: ellipsis;
+          border-bottom: var(--chops-normal-border);
+          cursor: pointer;
+          font-weight: normal;
+        }
+        .group-header-content {
+          height: 100%;
+          width: 100%;
+          align-items: center;
+          display: flex;
+        }
+        th.group-header i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+          margin-right: 4px;
+        }
+        td.ignore-navigation {
+          cursor: default;
+        }
+        th {
+          background: var(--chops-table-header-bg);
+          white-space: nowrap;
+          text-align: left;
+          border-bottom: var(--chops-normal-border);
+        }
+        th.selection-header {
+          padding: 3px 8px;
+        }
+        th > mr-dropdown, th > mr-show-columns-dropdown {
+          font-weight: normal;
+          color: var(--chops-link-color);
+          --mr-dropdown-icon-color: var(--chops-link-color);
+          --mr-dropdown-anchor-padding: 3px 8px;
+          --mr-dropdown-anchor-font-weight: bold;
+          --mr-dropdown-menu-min-width: 150px;
+        }
+        tr {
+          padding: 0 8px;
+        }
+        tr[selected] {
+          background: var(--chops-selected-bg);
+        }
+        td:first-child, th:first-child {
+          border-left: 4px solid transparent;
+        }
+        tr[cursored] > td:first-child {
+          border-left: 4px solid var(--chops-blue-700);
+        }
+        mr-crbug-link {
+          /* We need the shortlink to be hidden but still accessible.
+          * The opacity attribute visually hides a link while still
+          * keeping it in the DOM.opacity. */
+          --mr-crbug-link-opacity: 0;
+          --mr-crbug-link-opacity-focused: 1;
+        }
+        td:hover > mr-crbug-link {
+          --mr-crbug-link-opacity: 1;
+        }
+        .col-summary, .header-summary {
+          /* Setting a table cell to 100% width makes it take up
+          * all remaining space in the table, not the full width of
+          * the table. */
+          width: 100%;
+        }
+        .summary-label {
+          display: inline-block;
+          margin: 0 2px;
+          color: var(--chops-green-800);
+          text-decoration: none;
+          font-size: 90%;
+        }
+        .summary-label:hover {
+          text-decoration: underline;
+        }
+        td.draggable i {
+          opacity: 0;
+        }
+        td.draggable {
+          color: var(--chops-primary-icon-color);
+          cursor: grab;
+          padding-left: 0;
+          padding-right: 0;
+        }
+        tr.dragged {
+          opacity: 0.74;
+        }
+        tr:hover td.draggable i {
+          opacity: 1;
+        }
+        .csv-download-container {
+          border-bottom: none;
+          text-align: end;
+          cursor: default;
+        }
+        #hidden-data-link {
+          display: none;
+        }
+        @media (min-width: 1024px) {
+          .first-row th {
+            position: sticky;
+            top: var(--monorail-header-height);
+            z-index: 10;
+          }
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const selectAllChecked = this._selectedIssues.size > 0;
+    const checkboxLabel = `Select ${selectAllChecked ? 'None' : 'All'}`;
+
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <table cellspacing="0">
+        <thead>
+          <tr class="first-row">
+            ${this.rerank ? html`<th></th>` : ''}
+            <th class="selection-header">
+              <div class="edit-widget-container">
+                ${this.selectionEnabled ? html`
+                  <input
+                    class="select-all"
+                    .checked=${selectAllChecked}
+                    type="checkbox"
+                    aria-label=${checkboxLabel}
+                    title=${checkboxLabel}
+                    @change=${this._selectAll}
+                  />
+                ` : ''}
+              </div>
+            </th>
+            ${this.columns.map((column, i) => this._renderHeader(column, i))}
+            <th style="z-index: ${this.highestZIndex};">
+              <mr-show-columns-dropdown
+                title="Show columns"
+                menuAlignment="right"
+                .columns=${this.columns}
+                .issues=${this.issues}
+                .defaultFields=${this.defaultFields}
+              ></mr-show-columns-dropdown>
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this._renderIssues()}
+        </tbody>
+        ${this.userDisplayName && html`
+          <tfoot><tr><td colspan=999 class="csv-download-container">
+            <a id="download-link" aria-label="Download page as CSV"
+                @click=${this._downloadCsv} href>CSV</a>
+            <a id="hidden-data-link" download="${this.projectName}-issues.csv"
+              href=${this._csvDataHref}></a>
+          </td></tr></tfoot>
+        `}
+      </table>
+    `;
+  }
+
+  /**
+   * @param {string} column
+   * @param {number} i The index of the column in the table.
+   * @return {TemplateResult} html for header for the i-th column.
+   * @private
+   */
+  _renderHeader(column, i) {
+    // zIndex is used to render the z-index property in descending order
+    const zIndex = this.highestZIndex - i;
+    const colKey = column.toLowerCase();
+    const name = colKey in COLUMN_DISPLAY_NAMES ? COLUMN_DISPLAY_NAMES[colKey] :
+      column;
+    return html`
+      <th style="z-index: ${zIndex};" class="header-${colKey}">
+        <mr-dropdown
+          class="dropdown-${colKey}"
+          .text=${name}
+          .items=${this._headerActions(column, i)}
+          menuAlignment="left"
+        ></mr-dropdown>
+      </th>`;
+  }
+
+  /**
+   * @param {string} column
+   * @param {number} i The index of the column in the table.
+   * @return {Array<Object>} Available actions for the column.
+   * @private
+   */
+  _headerActions(column, i) {
+    const columnKey = column.toLowerCase();
+
+    const isGroupable = this.sortingAndGroupingEnabled &&
+        !UNGROUPABLE_COLUMNS.has(columnKey);
+
+    let showOnly = [];
+    if (isGroupable) {
+      const values = [...this._uniqueValuesByColumn.get(columnKey)];
+      if (values.length) {
+        showOnly = [{
+          text: 'Show only',
+          items: values.map((v) => ({
+            text: v,
+            handler: () => this.showOnly(column, v),
+          })),
+        }];
+      }
+    }
+    const sortingActions = this.sortingAndGroupingEnabled ? [
+      {
+        text: 'Sort up',
+        handler: () => this.updateSortSpec(column),
+      },
+      {
+        text: 'Sort down',
+        handler: () => this.updateSortSpec(column, true),
+      },
+    ] : [];
+    const actions = [
+      ...sortingActions,
+      ...showOnly,
+      {
+        text: 'Hide column',
+        handler: () => this.removeColumn(i),
+      },
+    ];
+    if (isGroupable) {
+      actions.push({
+        text: 'Group rows',
+        handler: () => this.addGroupBy(i),
+      });
+    }
+    return actions;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderIssues() {
+    // Keep track of all the groups that we've seen so far to create
+    // group headers as needed.
+    const {issues, groupedIssues} = this;
+
+    if (groupedIssues) {
+      // Make sure issues in groups are rendered with unique indices across
+      // groups to make sure hot keys and the like still work.
+      let indexOffset = 0;
+      return html`${groupedIssues.map(({groupName, issues}) => {
+        const template = html`
+          ${this._renderGroup(groupName, issues, indexOffset)}
+        `;
+        indexOffset += issues.length;
+        return template;
+      })}`;
+    }
+
+    return html`
+      ${issues.map((issue, i) => this._renderRow(issue, i))}
+    `;
+  }
+
+  /**
+   * @param {string} groupName
+   * @param {Array<Issue>} issues
+   * @param {number} iOffset
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderGroup(groupName, issues, iOffset) {
+    if (!this.groups.length) return html``;
+
+    const count = issues.length;
+    const groupKey = groupName.toLowerCase();
+    const isHidden = this._hiddenGroups.has(groupKey);
+
+    return html`
+      <tr>
+        <th
+          class="group-header"
+          colspan="${this.numColumns}"
+          @click=${() => this._toggleGroup(groupKey)}
+          aria-expanded=${(!isHidden).toString()}
+        >
+          <div class="group-header-content">
+            <i
+              class="material-icons"
+              title=${isHidden ? 'Show' : 'Hide'}
+            >${isHidden ? 'add' : 'remove'}</i>
+            ${count} ${pluralize(count, 'issue')}: ${groupName}
+          </div>
+        </th>
+      </tr>
+      ${issues.map((issue, i) => this._renderRow(issue, iOffset + i, isHidden))}
+    `;
+  }
+
+  /**
+   * @param {string} groupKey Lowercase group key.
+   * @private
+   */
+  _toggleGroup(groupKey) {
+    if (this._hiddenGroups.has(groupKey)) {
+      this._hiddenGroups.delete(groupKey);
+    } else {
+      this._hiddenGroups.add(groupKey);
+    }
+
+    // Lit-element's default hasChanged check does not notice when Sets mutate.
+    this.requestUpdate('_hiddenGroups');
+  }
+
+  /**
+   * @param {Issue} issue
+   * @param {number} i Index within the list of issues
+   * @param {boolean=} isHidden
+   * @return {TemplateResult}
+   */
+  _renderRow(issue, i, isHidden = false) {
+    const rowSelected = this._selectedIssues.has(issueRefToString(issue));
+    const id = issueRefToString(issue);
+    const cursorId = issueRefToString(this.cursor);
+    const hasCursor = cursorId === id;
+    const dragged = this._dragging && rowSelected;
+
+    return html`
+      <tr
+        class="row-${i} list-row ${dragged ? 'dragged' : ''}"
+        ?selected=${rowSelected}
+        ?cursored=${hasCursor}
+        ?hidden=${isHidden}
+        data-issue-ref=${id}
+        data-index=${i}
+        data-name=${issue.name}
+        @focus=${this._setRowAsCursorOnFocus}
+        @click=${this._clickIssueRow}
+        @auxclick=${this._clickIssueRow}
+        @keydown=${this._keydownIssueRow}
+        tabindex="0"
+      >
+        ${this.rerank ? html`
+          <td class="draggable ignore-navigation"
+              @mousedown=${this._onMouseDown}>
+            <i class="material-icons" title="Drag issue">drag_indicator</i>
+          </td>
+        ` : ''}
+        <td class="ignore-navigation">
+          <div class="edit-widget-container">
+            ${this.selectionEnabled ? html`
+              <input
+                class="issue-checkbox"
+                .value=${id}
+                .checked=${rowSelected}
+                type="checkbox"
+                data-index=${i}
+                aria-label="Select Issue ${issue.localId}"
+                @change=${this._selectIssue}
+                @click=${this._selectIssueRange}
+              />
+            ` : ''}
+            ${this.starringEnabled ? html`
+              <mr-issue-star
+                .issueRef=${issueToIssueRef(issue)}
+              ></mr-issue-star>
+            ` : ''}
+          </div>
+        </td>
+
+        ${this.columns.map((column) => html`
+          <td class="col-${column.toLowerCase()}">
+            ${this._renderCell(column, issue)}
+          </td>
+        `)}
+
+        <td>
+          <mr-crbug-link .issue=${issue}></mr-crbug-link>
+        </td>
+      </tr>
+    `;
+  }
+
+  /**
+   * @param {string} column
+   * @param {Issue} issue
+   * @return {TemplateResult} Html for the given column for the given issue.
+   * @private
+   */
+  _renderCell(column, issue) {
+    const columnName = column.toLowerCase();
+    if (columnName === 'summary') {
+      return html`
+        ${issue.summary}
+        ${labelRefsToOneWordLabels(issue.labelRefs).map(({label}) => html`
+          <a
+            class="summary-label"
+            href="/p/${issue.projectName}/issues/list?q=label%3A${label}"
+          >${label}</a>
+        `)}
+      `;
+    }
+    const values = this.extractFieldValues(issue, column);
+
+    if (!values.length) return EMPTY_FIELD_VALUE;
+
+    // TODO(zhangtiff): Make this based on the "ISSUE" field type rather than a
+    // hardcoded list of issue fields.
+    if (ISSUE_COLUMNS.has(columnName)) {
+      return values.map((issueRefString, i) => {
+        const issue = this._issueForRefString(issueRefString, this.projectName);
+        return html`
+          <mr-issue-link
+            .projectName=${this.projectName}
+            .issue=${issue}
+            .queryParams=${this._queryParams}
+            short
+          ></mr-issue-link>${values.length - 1 > i ? ', ' : ''}
+        `;
+      });
+    }
+    return values.join(', ');
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of columns to display.
+       */
+      columns: {type: Array},
+      /**
+       * Array of built in fields that are available outside of project
+       * configuration.
+       */
+      defaultFields: {type: Array},
+      /**
+       * A function that takes in an issue and a field name and returns the
+       * value for that field in the issue. This function accepts custom fields,
+       * built in fields, and ad hoc fields computed from label prefixes.
+       */
+      extractFieldValues: {type: Object},
+      /**
+       * Array of columns that are used as groups for issues.
+       */
+      groups: {type: Array},
+      /**
+       * List of issues to display.
+       */
+      issues: {type: Array},
+      /**
+       * A Redux action creator that calls the API to rerank the issues
+       * in the list. If set, reranking is enabled for this issue list.
+       */
+      rerank: {type: Object},
+      /**
+       * Whether issues should be selectable or not.
+       */
+      selectionEnabled: {type: Boolean},
+      /**
+       * Whether issues should be sortable and groupable or not. This will
+       * change how column headers will be displayed. The ability to sort and
+       * group are currently coupled.
+       */
+      sortingAndGroupingEnabled: {type: Boolean},
+      /**
+       * Whether to show issue starring or not.
+       */
+      starringEnabled: {type: Boolean},
+      /**
+       * A query representing the current set of matching issues in the issue
+       * list. Does not necessarily match queryParams.q since queryParams.q can
+       * be empty while currentQuery is set to a default project query.
+       */
+      currentQuery: {type: String},
+      /**
+       * Object containing URL parameters to be preserved when issue links are
+       * clicked. This Object is only used for the purpose of preserving query
+       * parameters across links, not for the purpose of evaluating the query
+       * parameters themselves to get values like columns, sort, or q. This
+       * separation is important because we don't want to tightly couple this
+       * list component with a specific URL system.
+       * @private
+       */
+      _queryParams: {type: Object},
+      /**
+       * The initial cursor that a list view uses. This attribute allows users
+       * of the list component to specify and control the cursor. When the
+       * initialCursor attribute updates, the list focuses the element specified
+       * by the cursor.
+       */
+      initialCursor: {type: String},
+      /**
+       * Logged in user's display name
+       */
+      userDisplayName: {type: String},
+      /**
+       * IssueRef Object specifying which issue the user is currently focusing.
+       */
+      _localCursor: {type: Object},
+      /**
+       * Set of group keys that are currently hidden.
+       */
+      _hiddenGroups: {type: Object},
+      /**
+       * Set of all selected issues where each entry is an issue ref string.
+       */
+      _selectedIssues: {type: Object},
+      /**
+       * List of unique phase names for all phases in issues.
+       */
+      _phaseNames: {type: Array},
+      /**
+       * True iff the user is dragging issues.
+       */
+      _dragging: {type: Boolean},
+      /**
+       * CSV data in data HREF format, used to download csv
+       */
+      _csvDataHref: {type: String},
+      /**
+       * Function to get a full Issue object for a given ref string.
+       */
+      _issueForRefString: {type: Object},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Array<Issue>} */
+    this.issues = [];
+    // TODO(jojwang): monorail:6336#c8, when ezt listissues page is fully
+    // deprecated, remove phaseNames from mr-issue-list.
+    this._phaseNames = [];
+    /** @type {IssueRef} */
+    this._localCursor;
+    /** @type {IssueRefString} */
+    this.initialCursor;
+    /** @type {Set<IssueRefString>} */
+    this._selectedIssues = new Set();
+    /** @type {string} */
+    this.projectName;
+    /** @type {Object} */
+    this._queryParams = {};
+    /** @type {string} */
+    this.currentQuery = '';
+    /**
+     * @param {Array<String>} items
+     * @param {number} index
+     * @return {Promise<void>}
+     */
+    this.rerank = null;
+    /** @type {boolean} */
+    this.selectionEnabled = false;
+    /** @type {boolean} */
+    this.sortingAndGroupingEnabled = false;
+    /** @type {boolean} */
+    this.starringEnabled = false;
+    /** @type {Array} */
+    this.columns = ['ID', 'Summary'];
+    /** @type {Array<string>} */
+    this.defaultFields = [];
+    /** @type {Array} */
+    this.groups = [];
+    this.userDisplayName = '';
+
+    /** @type {function(KeyboardEvent): void} */
+    this._boundRunListHotKeys = this._runListHotKeys.bind(this);
+    /** @type {function(MouseEvent): void} */
+    this._boundOnMouseMove = this._onMouseMove.bind(this);
+    /** @type {function(MouseEvent): void} */
+    this._boundOnMouseUp = this._onMouseUp.bind(this);
+
+    /**
+     * @param {Issue} _issue
+     * @param {string} _fieldName
+     * @return {Array<string>}
+     */
+    this.extractFieldValues = (_issue, _fieldName) => [];
+
+    /**
+     * @param {IssueRefString} _issueRefString
+     * @param {string} projectName The currently viewed project.
+     * @return {Issue}
+     */
+    this._issueForRefString = (_issueRefString, projectName) =>
+      issueStringToRef(_issueRefString, projectName);
+
+    this._hiddenGroups = new Set();
+
+    this._starredIssues = new Set();
+    this._fetchingStarredIssues = false;
+    this._starringIssues = new Map();
+
+    this._uniqueValuesByColumn = new Map();
+
+    this._dragging = false;
+    this._mouseX = null;
+    this._mouseY = null;
+
+    /** @type {number} */
+    this._lastSelectedCheckbox = -1;
+
+    // Expose page.js for stubbing.
+    this._page = page;
+    /** @type {string} page data in csv format as data href */
+    this._csvDataHref = '';
+  };
+
+  /** @override */
+  stateChanged(state) {
+    this._starredIssues = issueV0.starredIssues(state);
+    this._fetchingStarredIssues =
+        issueV0.requests(state).fetchStarredIssues.requesting;
+    this._starringIssues = issueV0.starringIssues(state);
+
+    this._phaseNames = (issueV0.issueListPhaseNames(state) || []);
+    this._queryParams = sitewide.queryParams(state);
+
+    this._issueForRefString = issueV0.issueForRefString(state);
+  }
+
+  /** @override */
+  firstUpdated() {
+    // Only attach an event listener once the DOM has rendered.
+    window.addEventListener('keydown', this._boundRunListHotKeys);
+    this._dataLink = this.shadowRoot.querySelector('#hidden-data-link');
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('keydown', this._boundRunListHotKeys);
+  }
+
+  /**
+   * @override
+   * @fires CustomEvent#selectionChange
+   */
+  update(changedProperties) {
+    if (changedProperties.has('issues')) {
+      // Clear selected issues to avoid an ever-growing Set size. In the future,
+      // we may want to consider saving selections across issue reloads, though,
+      // such as in the case or list refreshing.
+      this._selectedIssues = new Set();
+      this.dispatchEvent(new CustomEvent('selectionChange'));
+
+      // Clear group toggle state when the list of issues changes to prevent an
+      // ever-growing Set size.
+      this._hiddenGroups = new Set();
+
+      this._lastSelectedCheckbox = -1;
+    }
+
+    const valuesByColumnArgs = ['issues', 'columns', 'extractFieldValues'];
+    if (setHasAny(changedProperties, valuesByColumnArgs)) {
+      this._uniqueValuesByColumn = this._computeUniqueValuesByColumn(
+          ...objectValuesForKeys(this, valuesByColumnArgs));
+    }
+
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('initialCursor')) {
+      const ref = issueStringToRef(this.initialCursor, this.projectName);
+      const row = this._getRowFromIssueRef(ref);
+      if (row) {
+        row.focus();
+      }
+    }
+  }
+
+  /**
+   * Iterates through all issues in a list to sort unique values
+   * across columns, for use in the "Show only" feature.
+   * @param {Array} issues
+   * @param {Array} columns
+   * @param {function(Issue, string): Array<string>} fieldExtractor
+   * @return {Map} Map where each entry has a String key for the
+   *   lowercase column name and a Set value, continuing all values for
+   *   that column.
+   */
+  _computeUniqueValuesByColumn(issues, columns, fieldExtractor) {
+    const valueMap = new Map(
+        columns.map((col) => [col.toLowerCase(), new Set()]));
+
+    issues.forEach((issue) => {
+      columns.forEach((col) => {
+        const key = col.toLowerCase();
+        const valueSet = valueMap.get(key);
+
+        const values = fieldExtractor(issue, col);
+        // Note: This allows multiple casings of the same values to be added
+        // to the Set.
+        values.forEach((v) => valueSet.add(v));
+      });
+    });
+    return valueMap;
+  }
+
+  /**
+   * Used for dynamically computing z-index to ensure column dropdowns overlap
+   * properly.
+   */
+  get highestZIndex() {
+    return this.columns.length + 10;
+  }
+
+  /**
+   * The number of columns displayed in the table. This is the count of
+   * customized columns + number of built in columns.
+   */
+  get numColumns() {
+    return this.columns.length + 2;
+  }
+
+  /**
+   * Sort issues into groups if groups are defined. The grouping feature is used
+   * when the "groupby" URL parameter is set in the list view.
+   */
+  get groupedIssues() {
+    if (!this.groups || !this.groups.length) return;
+
+    const issuesByGroup = new Map();
+
+    this.issues.forEach((issue) => {
+      const groupName = this._groupNameForIssue(issue);
+      const groupKey = groupName.toLowerCase();
+
+      if (!issuesByGroup.has(groupKey)) {
+        issuesByGroup.set(groupKey, {groupName, issues: [issue]});
+      } else {
+        const entry = issuesByGroup.get(groupKey);
+        entry.issues.push(issue);
+      }
+    });
+    return [...issuesByGroup.values()];
+  }
+
+  /**
+   * The currently selected issue, with _localCursor overriding initialCursor.
+   *
+   * @return {IssueRef} The currently selected issue.
+   */
+  get cursor() {
+    if (this._localCursor) {
+      return this._localCursor;
+    }
+    if (this.initialCursor) {
+      return issueStringToRef(this.initialCursor, this.projectName);
+    }
+    return {};
+  }
+
+  /**
+   * Computes the name of the group that an issue belongs to. Issues are grouped
+   * by fields that the user specifies and group names are generated using a
+   * combination of an issue's field values for all specified groups.
+   *
+   * @param {Issue} issue
+   * @return {string}
+   */
+  _groupNameForIssue(issue) {
+    const groups = this.groups;
+    const keyPieces = [];
+
+    groups.forEach((group) => {
+      const values = this.extractFieldValues(issue, group);
+      if (!values.length) {
+        keyPieces.push(`-has:${group}`);
+      } else {
+        values.forEach((v) => {
+          keyPieces.push(`${group}=${v}`);
+        });
+      }
+    });
+
+    return keyPieces.join(' ');
+  }
+
+  /**
+   * @return {Array<Issue>} Selected issues in the order they appear.
+   */
+  get selectedIssues() {
+    return this.issues.filter((issue) =>
+      this._selectedIssues.has(issueToIssueRefString(issue)));
+  }
+
+  /**
+   * Update the search query to filter values matching a specific one.
+   *
+   * @param {string} column name of the column being filtered.
+   * @param {string} value value of the field to filter by.
+   */
+  showOnly(column, value) {
+    column = column.toLowerCase();
+
+    // TODO(zhangtiff): Handle edge cases where column names are not
+    // mapped directly to field names. For example, "AllLabels", should
+    // query for "Labels".
+    const querySegment = `${column}=${value}`;
+
+    let query = this.currentQuery.trim();
+
+    if (!query.includes(querySegment)) {
+      query += ' ' + querySegment;
+
+      this._updateQueryParams({q: query.trim()}, ['start']);
+    }
+  }
+
+  /**
+   * Update sort parameter in the URL based on user input.
+   *
+   * @param {string} column name of the column to be sorted.
+   * @param {boolean} descending descending or ascending order.
+   */
+  updateSortSpec(column, descending = false) {
+    column = column.toLowerCase();
+    const oldSpec = this._queryParams.sort || '';
+    const columns = parseColSpec(oldSpec.toLowerCase());
+
+    // Remove any old instances of the same sort spec.
+    const newSpec = columns.filter(
+        (c) => c && c !== column && c !== `-${column}`);
+
+    newSpec.unshift(`${descending ? '-' : ''}${column}`);
+
+    this._updateQueryParams({sort: newSpec.join(' ')}, ['start']);
+  }
+
+  /**
+   * Updates the groupby URL parameter to include a new column to group.
+   *
+   * @param {number} i index of the column to be grouped.
+   */
+  addGroupBy(i) {
+    const groups = [...this.groups];
+    const columns = [...this.columns];
+    const groupedColumn = columns[i];
+    columns.splice(i, 1);
+
+    groups.unshift(groupedColumn);
+
+    this._updateQueryParams({
+      groupby: groups.join(' '),
+      colspec: columns.join('+'),
+    }, ['start']);
+  }
+
+  /**
+   * Removes the column at a particular index.
+   *
+   * @param {number} i the issue column to be removed.
+   */
+  removeColumn(i) {
+    const columns = [...this.columns];
+    columns.splice(i, 1);
+    this.reloadColspec(columns);
+  }
+
+  /**
+   * Adds a new column to a particular index.
+   *
+   * @param {string} name of the new column added.
+   */
+  addColumn(name) {
+    this.reloadColspec([...this.columns, name]);
+  }
+
+  /**
+   * Reflects changes to the columns of an issue list to the URL, through
+   * frontend routing.
+   *
+   * @param {Array} newColumns the new colspec to set in the URL.
+   */
+  reloadColspec(newColumns) {
+    this._updateQueryParams({colspec: newColumns.join('+')});
+  }
+
+  /**
+   * Navigates to the same URL as the current page, but with query
+   * params updated.
+   *
+   * @param {Object} newParams keys and values of the queryParams
+   * Object to be updated.
+   * @param {Array} deletedParams keys to be cleared from queryParams.
+   */
+  _updateQueryParams(newParams = {}, deletedParams = []) {
+    const url = urlWithNewParams(this._baseUrl(), this._queryParams, newParams,
+        deletedParams);
+    this._page(url);
+  }
+
+  /**
+   * Get the current URL of the page, without query params. Useful for
+   * test stubbing.
+   *
+   * @return {string} the URL of the list page, without params.
+   */
+  _baseUrl() {
+    return window.location.pathname;
+  }
+
+  /**
+   * Run issue list hot keys. This event handler needs to be bound globally
+   * because a list cursor can be defined even when no element in the list is
+   * focused.
+   * @param {KeyboardEvent} e
+   */
+  _runListHotKeys(e) {
+    if (!this.issues || !this.issues.length) return;
+    const target = findDeepEventTarget(e);
+    if (!target || isTextInput(target)) return;
+
+    const key = e.key;
+
+    const activeRow = this._getCursorElement();
+
+    let i = -1;
+    if (activeRow) {
+      i = Number.parseInt(activeRow.dataset.index);
+
+      const issue = this.issues[i];
+
+      switch (key) {
+        case 's': // Star focused issue.
+          this._starIssue(issueToIssueRef(issue));
+          return;
+        case 'x': // Toggle selection of focused issue.
+          const issueRefString = issueToIssueRefString(issue);
+          this._updateSelectedIssues([issueRefString],
+              !this._selectedIssues.has(issueRefString));
+          return;
+        case 'o': // Open current issue.
+        case 'O': // Open current issue in new tab.
+          this._navigateToIssue(issue, e.shiftKey);
+          return;
+      }
+    }
+
+    // Move up and down the issue list.
+    // 'j' moves 'down'.
+    // 'k' moves 'up'.
+    if (key === 'j' || key === 'k') {
+      if (key === 'j') { // Navigate down the list.
+        i += 1;
+        if (i >= this.issues.length) {
+          i = 0;
+        }
+      } else if (key === 'k') { // Navigate up the list.
+        i -= 1;
+        if (i < 0) {
+          i = this.issues.length - 1;
+        }
+      }
+
+      const nextRow = this.shadowRoot.querySelector(`.row-${i}`);
+      this._setRowAsCursor(nextRow);
+    }
+  }
+
+  /**
+   * @return {HTMLTableRowElement}
+   */
+  _getCursorElement() {
+    const cursor = this.cursor;
+    if (cursor) {
+      // If there's a cursor set, use that instead of focus.
+      return this._getRowFromIssueRef(cursor);
+    }
+    return;
+  }
+
+  /**
+   * @param {FocusEvent} e
+   */
+  _setRowAsCursorOnFocus(e) {
+    this._setRowAsCursor(/** @type {HTMLTableRowElement} */ (e.target));
+  }
+
+  /**
+   *
+   * @param {HTMLTableRowElement} row
+   */
+  _setRowAsCursor(row) {
+    this._localCursor = issueStringToRef(row.dataset.issueRef,
+        this.projectName);
+    row.focus();
+  }
+
+  /**
+   * @param {IssueRef} ref The issueRef to query for.
+   * @return {HTMLTableRowElement}
+   */
+  _getRowFromIssueRef(ref) {
+    return this.shadowRoot.querySelector(
+        `.list-row[data-issue-ref="${issueRefToString(ref)}"]`);
+  }
+
+  /**
+   * Returns an Array containing every <tr> in the list, excluding the header.
+   * @return {Array<HTMLTableRowElement>}
+   */
+  _getRows() {
+    return Array.from(this.shadowRoot.querySelectorAll('.list-row'));
+  }
+
+  /**
+   * Returns an Array containing every selected <tr> in the list.
+   * @return {Array<HTMLTableRowElement>}
+   */
+  _getSelectedRows() {
+    return this._getRows().filter((row) => {
+      return this._selectedIssues.has(row.dataset.issueRef);
+    });
+  }
+
+  /**
+   * @param {IssueRef} issueRef Issue to star
+   */
+  _starIssue(issueRef) {
+    if (!this.starringEnabled) return;
+    const issueKey = issueRefToString(issueRef);
+
+    // TODO(zhangtiff): Find way to share star disabling logic more.
+    const isStarring = this._starringIssues.has(issueKey) &&
+      this._starringIssues.get(issueKey).requesting;
+    const starEnabled = !this._fetchingStarredIssues && !isStarring;
+    if (starEnabled) {
+      const newIsStarred = !this._starredIssues.has(issueKey);
+      this._starIssueInternal(issueRef, newIsStarred);
+    }
+  }
+
+  /**
+   * Wrap store.dispatch and issue.star, for testing.
+   *
+   * @param {IssueRef} issueRef the issue being starred.
+   * @param {boolean} newIsStarred whether to star or unstar the issue.
+   * @private
+   */
+  _starIssueInternal(issueRef, newIsStarred) {
+    store.dispatch(issueV0.star(issueRef, newIsStarred));
+  }
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _selectAll(e) {
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+    if (checkbox.checked) {
+      this._selectedIssues = new Set(this.issues.map(issueRefToString));
+    } else {
+      this._selectedIssues = new Set();
+    }
+    this.dispatchEvent(new CustomEvent('selectionChange'));
+  }
+
+  // TODO(zhangtiff): Implement Shift+Click to select a range of checkboxes
+  // for the 'x' hot key.
+  /**
+   * @param {MouseEvent} e
+   * @private
+   */
+  _selectIssueRange(e) {
+    if (!this.selectionEnabled) return;
+
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+
+    const index = Number.parseInt(checkbox.dataset.index);
+    if (Number.isNaN(index)) {
+      console.error('Issue checkbox has invalid data-index attribute.');
+      return;
+    }
+
+    const lastIndex = this._lastSelectedCheckbox;
+    if (e.shiftKey && lastIndex >= 0) {
+      const newCheckedState = checkbox.checked;
+
+      const start = Math.min(lastIndex, index);
+      const end = Math.max(lastIndex, index) + 1;
+
+      const updatedIssueKeys = this.issues.slice(start, end).map(
+          issueToIssueRefString);
+      this._updateSelectedIssues(updatedIssueKeys, newCheckedState);
+    }
+
+    this._lastSelectedCheckbox = index;
+  }
+
+  /**
+   * @param {Event} e
+   * @private
+   */
+  _selectIssue(e) {
+    if (!this.selectionEnabled) return;
+
+    const checkbox = /** @type {HTMLInputElement} */ (e.target);
+    const issueKey = checkbox.value;
+
+    this._updateSelectedIssues([issueKey], checkbox.checked);
+  }
+
+  /**
+   * @param {Array<IssueRefString>} issueKeys Stringified issue refs.
+   * @param {boolean} selected
+   * @fires CustomEvent#selectionChange
+   * @private
+   */
+  _updateSelectedIssues(issueKeys, selected) {
+    let hasChanges = false;
+
+    issueKeys.forEach((issueKey) => {
+      const oldSelection = this._selectedIssues.has(issueKey);
+
+      if (selected) {
+        this._selectedIssues.add(issueKey);
+      } else if (this._selectedIssues.has(issueKey)) {
+        this._selectedIssues.delete(issueKey);
+      }
+
+      const newSelection = this._selectedIssues.has(issueKey);
+
+      hasChanges = hasChanges || newSelection !== oldSelection;
+    });
+
+
+    if (hasChanges) {
+      this.requestUpdate('_selectedIssues');
+      this.dispatchEvent(new CustomEvent('selectionChange'));
+    }
+  }
+
+  /**
+   * Handles 'Enter' being pressed when a row is focused.
+   * Note we install the 'Enter' listener on the row rather than the window so
+   * 'Enter' behaves as expected when the focus is on other elements.
+   *
+   * @param {KeyboardEvent} e
+   * @private
+   */
+  _keydownIssueRow(e) {
+    if (e.key === 'Enter') {
+      this._maybeOpenIssueRow(e);
+    }
+  }
+
+  /**
+   * Handles mouseDown to start drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseDown(event) {
+    event.cancelable && event.preventDefault();
+
+    this._mouseX = event.clientX;
+    this._mouseY = event.clientY;
+
+    this._setRowAsCursor(event.currentTarget.parentNode);
+    this._startDrag();
+
+    // We add the event listeners to window because the mouse can go out of the
+    // bounds of the target element. window.mouseUp still triggers even if the
+    // mouse is outside the browser window.
+    window.addEventListener('mousemove', this._boundOnMouseMove);
+    window.addEventListener('mouseup', this._boundOnMouseUp);
+  }
+
+  /**
+   * Handles mouseMove to continue drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseMove(event) {
+    event.cancelable && event.preventDefault();
+
+    const x = event.clientX - this._mouseX;
+    const y = event.clientY - this._mouseY;
+    this._continueDrag(x, y);
+  }
+
+  /**
+   * Handles mouseUp to end drag events.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _onMouseUp(event) {
+    event.cancelable && event.preventDefault();
+
+    window.removeEventListener('mousemove', this._boundOnMouseMove);
+    window.removeEventListener('mouseup', this._boundOnMouseUp);
+
+    this._endDrag(event.clientY - this._mouseY);
+  }
+
+  /**
+   * Gives a visual indicator that we've started dragging an issue row.
+   * @private
+   */
+  _startDrag() {
+    this._dragging = true;
+
+    // If the dragged row is not selected, select it.
+    // TODO(dtu): Allow dragging an existing selection for multi-drag.
+    const issueRefString = issueRefToString(this.cursor);
+    this._selectedIssues = new Set();
+    this._updateSelectedIssues([issueRefString], true);
+  }
+
+  /**
+   * @param {number} x The x-distance the cursor has moved since mouseDown.
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @private
+   */
+  _continueDrag(x, y) {
+    // Unselected rows: Transition them to their new positions.
+    const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+    this._translateRows(rows, initialIndex, finalIndex);
+
+    // Selected rows: Stick them to the cursor. No transition.
+    for (const row of this._getSelectedRows()) {
+      row.style.transform = `translate(${x}px, ${y}px`;
+    };
+  }
+
+  /**
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @private
+   */
+  async _endDrag(y) {
+    this._dragging = false;
+
+    // Unselected rows: Transition them to their new positions.
+    const [rows, initialIndex, finalIndex] = this._computeRerank(y);
+    const targetTranslation =
+        this._translateRows(rows, initialIndex, finalIndex);
+
+    // Selected rows: Transition them to their final positions
+    // and reset their opacity.
+    const selectedRows = this._getSelectedRows();
+    for (const row of selectedRows) {
+      row.style.transition = EASE_OUT_TRANSITION;
+      row.style.transform = `translate(0px, ${targetTranslation}px)`;
+    };
+
+    // Submit the change.
+    const items = selectedRows.map((row) => row.dataset.name);
+    await this.rerank(items, finalIndex);
+
+    // Reset the transforms.
+    for (const row of this._getRows()) {
+      row.style.transition = '';
+      row.style.transform = '';
+    };
+
+    // Set the cursor to the new row.
+    // In order to focus the correct element, we need the DOM to be in sync
+    // with the issue list. We modified this.issues, so wait for a re-render.
+    await this.updateComplete;
+    const selector = `.list-row[data-index="${finalIndex}"]`;
+    this.shadowRoot.querySelector(selector).focus();
+  }
+
+  /**
+   * Computes the starting and ending indices of the cursor row,
+   * given how far the mouse has been dragged in the y-direction.
+   * The indices assume the cursor row has been removed from the list.
+   * @param {number} y The y-distance the cursor has moved since mouseDown.
+   * @return {[Array<HTMLTableRowElement>, number, number]} A tuple containing:
+   *     An Array of table rows with the cursor row removed.
+   *     The initial index of the cursor row.
+   *     The final index of the cursor row.
+   * @private
+   */
+  _computeRerank(y) {
+    const row = this._getCursorElement();
+    const rows = this._getRows();
+    const listTop = row.parentNode.offsetTop;
+
+    // Find the initial index of the cursor row.
+    // TODO(dtu): If we support multi-drag, this should be the adjusted index of
+    // the first selected row after collapsing spaces in the selected group.
+    const initialIndex = rows.indexOf(row);
+    rows.splice(initialIndex, 1);
+
+    // Compute the initial and final y-positions of the top
+    // of the cursor row relative to the top of the list.
+    const initialY = row.offsetTop - listTop;
+    const finalY = initialY + y;
+
+    // Compute the final index of the cursor row.
+    // The break points are the halfway marks of each row.
+    let finalIndex = 0;
+    for (finalIndex = 0; finalIndex < rows.length; ++finalIndex) {
+      const rowTop = rows[finalIndex].offsetTop - listTop -
+          (finalIndex >= initialIndex ? row.scrollHeight : 0);
+      const breakpoint = rowTop + rows[finalIndex].scrollHeight / 2;
+      if (breakpoint > finalY) {
+        break;
+      }
+    }
+
+    return [rows, initialIndex, finalIndex];
+  }
+
+  /**
+   * @param {Array<HTMLTableRowElement>} rows Array of table rows with the
+   *    cursor row removed.
+   * @param {number} initialIndex The initial index of the cursor row.
+   * @param {number} finalIndex The final index of the cursor row.
+   * @return {number} The number of pixels the cursor row moved.
+   * @private
+   */
+  _translateRows(rows, initialIndex, finalIndex) {
+    const firstIndex = Math.min(initialIndex, finalIndex);
+    const lastIndex = Math.max(initialIndex, finalIndex);
+
+    const rowHeight = this._getCursorElement().scrollHeight;
+    const translation = initialIndex < finalIndex ? -rowHeight : rowHeight;
+
+    let targetTranslation = 0;
+    for (let i = 0; i < rows.length; ++i) {
+      rows[i].style.transition = EASE_OUT_TRANSITION;
+      if (i >= firstIndex && i < lastIndex) {
+        rows[i].style.transform = `translate(0px, ${translation}px)`;
+        targetTranslation += rows[i].scrollHeight;
+      } else {
+        rows[i].style.transform = '';
+      }
+    }
+
+    return initialIndex < finalIndex ? targetTranslation : -targetTranslation;
+  }
+
+  /**
+   * Handle click and auxclick on issue row.
+   * @param {MouseEvent} event
+   * @private
+   */
+  _clickIssueRow(event) {
+    if (event.button === PRIMARY_BUTTON || event.button === MIDDLE_BUTTON) {
+      this._maybeOpenIssueRow(
+          event, /* openNewTab= */ event.button === MIDDLE_BUTTON);
+    }
+  }
+
+  /**
+   * Checks that the given event should not be ignored, then navigates to the
+   * issue associated with the row.
+   *
+   * @param {MouseEvent|KeyboardEvent} rowEvent A click or 'enter' on a row.
+   * @param {boolean=} openNewTab Forces opening in a new tab
+   * @private
+   */
+  _maybeOpenIssueRow(rowEvent, openNewTab = false) {
+    const path = rowEvent.composedPath();
+    const containsIgnoredElement = path.find(
+        (node) => (node.tagName || '').toUpperCase() === 'A' ||
+        (node.classList && node.classList.contains('ignore-navigation')));
+    if (containsIgnoredElement) return;
+
+    const row = /** @type {HTMLTableRowElement} */ (rowEvent.currentTarget);
+
+    const i = Number.parseInt(row.dataset.index);
+
+    if (i >= 0 && i < this.issues.length) {
+      this._navigateToIssue(this.issues[i], openNewTab || rowEvent.metaKey ||
+          rowEvent.ctrlKey);
+    }
+  }
+
+  /**
+   * @param {Issue} issue
+   * @param {boolean} newTab
+   * @private
+   */
+  _navigateToIssue(issue, newTab) {
+    const link = issueRefToUrl(issueToIssueRef(issue),
+        this._queryParams);
+
+    if (newTab) {
+      // Whether the link opens in a new tab or window is based on the
+      // user's browser preferences.
+      window.open(link, '_blank', 'noopener');
+    } else {
+      this._page(link);
+    }
+  }
+
+  /**
+   * Convert an issue's data into an array of strings, where the columns
+   * match this.columns. Extracting data like _renderCell.
+   * @param {Issue} issue
+   * @return {Array<string>}
+   * @private
+   */
+  _convertIssueToPlaintextArray(issue) {
+    return this.columns.map((column) => {
+      return this.extractFieldValues(issue, column).join(', ');
+    });
+  }
+
+  /**
+   * Convert each Issue into array of strings, where the columns
+   * match this.columns.
+   * @return {Array<Array<string>>}
+   * @private
+   */
+  _convertIssuesToPlaintextArrays() {
+    return this.issues.map(this._convertIssueToPlaintextArray.bind(this));
+  }
+
+  /**
+   * Download content as csv. Conversion to CSV only on button click
+   * instead of on data change because CSV download is not often used.
+   * @param {MouseEvent} event
+   * @private
+   */
+  async _downloadCsv(event) {
+    event.preventDefault();
+
+    if (this.userDisplayName) {
+      // convert issues to array of arrays of strings
+      const issueData = this._convertIssuesToPlaintextArrays();
+
+      // convert the data into csv formatted string.
+      const csvDataString = prepareDataForDownload(issueData, this.columns);
+
+      // construct data href
+      const href = constructHref(csvDataString);
+
+      // modify a tag's href
+      this._csvDataHref = href;
+      await this.requestUpdate('_csvDataHref');
+
+      // click to trigger download
+      this._dataLink.click();
+
+      // reset dataHref
+      this._csvDataHref = '';
+    }
+  }
+};
+
+customElements.define('mr-issue-list', MrIssueList);
diff --git a/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
new file mode 100644
index 0000000..3861e32
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-issue-list.test.js
@@ -0,0 +1,1328 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {assert} from 'chai';
+import sinon from 'sinon';
+import * as projectV0 from 'reducers/projectV0.js';
+import {stringValuesForIssueField} from 'shared/issue-fields.js';
+import {MrIssueList} from './mr-issue-list.js';
+
+let element;
+
+const listRowIsFocused = (element, i) => {
+  const focused = element.shadowRoot.activeElement;
+  assert.equal(focused.tagName.toUpperCase(), 'TR');
+  assert.equal(focused.dataset.index, `${i}`);
+};
+
+describe('mr-issue-list', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-list');
+    element.extractFieldValues = projectV0.extractFieldValuesFromIssue({});
+    document.body.appendChild(element);
+
+    sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+    sinon.stub(element, '_page');
+    sinon.stub(window, 'open');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.open.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueList);
+  });
+
+  it('issue summaries render', async () => {
+    element.issues = [
+      {summary: 'test issue'},
+      {summary: 'I have a summary'},
+    ];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const summaries = element.shadowRoot.querySelectorAll('.col-summary');
+
+    assert.equal(summaries.length, 2);
+
+    assert.equal(summaries[0].textContent.trim(), 'test issue');
+    assert.equal(summaries[1].textContent.trim(), 'I have a summary');
+  });
+
+  it('one word labels render in summary column', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        summary: 'test issue',
+        labelRefs: [
+          {label: 'ignore-multi-word-labels'},
+          {label: 'Security'},
+          {label: 'A11y'},
+        ],
+      },
+    ];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const summary = element.shadowRoot.querySelector('.col-summary');
+    const labels = summary.querySelectorAll('.summary-label');
+
+    assert.equal(labels.length, 2);
+
+    assert.equal(labels[0].textContent.trim(), 'Security');
+    assert.include(labels[0].href,
+        '/p/test/issues/list?q=label%3ASecurity');
+    assert.equal(labels[1].textContent.trim(), 'A11y');
+    assert.include(labels[1].href,
+        '/p/test/issues/list?q=label%3AA11y');
+  });
+
+  it('blocking column renders issue links', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        blockingIssueRefs: [
+          {projectName: 'test', localId: 2},
+          {projectName: 'test', localId: 3},
+        ],
+      },
+    ];
+    element.columns = ['Blocking'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-blocking');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('blockedOn column renders issue links', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        blockedOnIssueRefs: [{projectName: 'test', localId: 2}],
+      },
+    ];
+    element.columns = ['BlockedOn'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-blockedon');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('mergedInto column renders issue link', async () => {
+    element.issues = [
+      {
+        projectName: 'test',
+        localId: 1,
+        mergedIntoIssueRef: {projectName: 'test', localId: 2},
+      },
+    ];
+    element.columns = ['MergedInto'];
+
+    await element.updateComplete;
+
+    const blocking = element.shadowRoot.querySelector('.col-mergedinto');
+    const link = blocking.querySelector('mr-issue-link');
+    assert.equal(link.href, '/p/test/issues/detail?id=2');
+  });
+
+  it('clicking issue link does not trigger _navigateToIssue', async () => {
+    sinon.stub(element, '_navigateToIssue');
+
+    // Prevent the page from actually navigating on the link click.
+    const clickIntercepter = sinon.spy((e) => {
+      e.preventDefault();
+    });
+    window.addEventListener('click', clickIntercepter);
+
+    element.issues = [
+      {projectName: 'test', localId: 1, summary: 'test issue'},
+      {projectName: 'test', localId: 2, summary: 'I have a summary'},
+    ];
+    element.columns = ['ID'];
+
+    await element.updateComplete;
+
+    const idLink = element.shadowRoot.querySelector('.col-id > mr-issue-link');
+
+    idLink.click();
+
+    sinon.assert.calledOnce(clickIntercepter);
+    sinon.assert.notCalled(element._navigateToIssue);
+
+    window.removeEventListener('click', clickIntercepter);
+  });
+
+  it('clicking issue row opens issue', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 22,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.click();
+
+    sinon.assert.calledWith(element._page, '/p/chromium/issues/detail?id=22');
+    sinon.assert.notCalled(window.open);
+  });
+
+  it('ctrl+click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('click',
+        {ctrlKey: true, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('meta+click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('click',
+        {metaKey: true, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('mouse wheel click on row opens issue in new tab', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('auxclick',
+        {button: 1, bubbles: true}));
+
+    sinon.assert.calledWith(window.open,
+        '/p/chromium/issues/detail?id=24', '_blank', 'noopener');
+  });
+
+  it('right click on row does not navigate', async () => {
+    element.issues = [{
+      summary: 'click me',
+      localId: 24,
+      projectName: 'chromium',
+    }];
+    element.columns = ['Summary'];
+
+    await element.updateComplete;
+
+    const rowChild = element.shadowRoot.querySelector('.col-summary');
+    rowChild.dispatchEvent(new MouseEvent('auxclick',
+        {button: 2, bubbles: true}));
+
+    sinon.assert.notCalled(window.open);
+  });
+
+  it('AllLabels column renders', async () => {
+    element.issues = [
+      {labelRefs: [{label: 'test'}, {label: 'hello-world'}]},
+      {labelRefs: [{label: 'one-label'}]},
+    ];
+
+    element.columns = ['AllLabels'];
+
+    await element.updateComplete;
+
+    const labels = element.shadowRoot.querySelectorAll('.col-alllabels');
+
+    assert.equal(labels.length, 2);
+
+    assert.equal(labels[0].textContent.trim(), 'test, hello-world');
+    assert.equal(labels[1].textContent.trim(), 'one-label');
+  });
+
+  it('issues sorted into groups when groups defined', async () => {
+    element.issues = [
+      {ownerRef: {displayName: 'test@example.com'}},
+      {ownerRef: {displayName: 'test@example.com'}},
+      {ownerRef: {displayName: 'other.user@example.com'}},
+      {},
+    ];
+
+    element.columns = ['Owner'];
+    element.groups = ['Owner'];
+
+    await element.updateComplete;
+
+    const owners = element.shadowRoot.querySelectorAll('.col-owner');
+    assert.equal(owners.length, 4);
+
+    const groupHeaders = element.shadowRoot.querySelectorAll(
+        '.group-header');
+    assert.equal(groupHeaders.length, 3);
+
+    assert.include(groupHeaders[0].textContent,
+        '2 issues: Owner=test@example.com');
+    assert.include(groupHeaders[1].textContent,
+        '1 issue: Owner=other.user@example.com');
+    assert.include(groupHeaders[2].textContent, '1 issue: -has:Owner');
+  });
+
+  it('toggling group hides members', async () => {
+    element.issues = [
+      {ownerRef: {displayName: 'group1@example.com'}},
+      {ownerRef: {displayName: 'group2@example.com'}},
+    ];
+
+    element.columns = ['Owner'];
+    element.groups = ['Owner'];
+
+    await element.updateComplete;
+
+    const issueRows = element.shadowRoot.querySelectorAll('.list-row');
+    assert.equal(issueRows.length, 2);
+
+    assert.isFalse(issueRows[0].hidden);
+    assert.isFalse(issueRows[1].hidden);
+
+    const groupHeaders = element.shadowRoot.querySelectorAll(
+        '.group-header');
+    assert.equal(groupHeaders.length, 2);
+
+    // Toggle first group hidden.
+    groupHeaders[0].click();
+    await element.updateComplete;
+
+    assert.isTrue(issueRows[0].hidden);
+    assert.isFalse(issueRows[1].hidden);
+  });
+
+  it('reloadColspec navigates to page with new colspec', () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {};
+
+    element.reloadColspec(['Summary', 'AllLabels']);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?colspec=Summary%2BAllLabels');
+  });
+
+  it('updateSortSpec navigates to page with new sort option', async () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Summary', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=-summary');
+  });
+
+  it('updateSortSpec navigates to first page when on later page', async () => {
+    element.columns = ['ID', 'Summary'];
+    element._queryParams = {start: '100', q: 'owner:me'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Summary', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?q=owner%3Ame&sort=-summary');
+  });
+
+  it('updateSortSpec prepends new option to existing sort', async () => {
+    element.columns = ['ID', 'Summary', 'Owner'];
+    element._queryParams = {sort: '-summary+owner'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('ID');
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=id%20-summary%20owner');
+  });
+
+  it('updateSortSpec removes existing instances of sorted column', async () => {
+    element.columns = ['ID', 'Summary', 'Owner'];
+    element._queryParams = {sort: '-summary+owner+owner'};
+
+    await element.updateComplete;
+
+    element.updateSortSpec('Owner', true);
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?sort=-owner%20-summary');
+  });
+
+  it('_uniqueValuesByColumn re-computed when columns update', async () => {
+    element.issues = [
+      {id: 1, projectName: 'chromium'},
+      {id: 2, projectName: 'chromium'},
+      {id: 3, projectName: 'chrOmiUm'},
+      {id: 1, projectName: 'other'},
+    ];
+    element.columns = [];
+    await element.updateComplete;
+
+    assert.deepEqual(element._uniqueValuesByColumn, new Map());
+
+    element.columns = ['project'];
+    await element.updateComplete;
+
+    assert.deepEqual(element._uniqueValuesByColumn,
+        new Map([['project', new Set(['chromium', 'chrOmiUm', 'other'])]]));
+  });
+
+  it('showOnly adds new search term to query', async () => {
+    element.currentQuery = 'owner:me';
+    element._queryParams = {};
+
+    await element.updateComplete;
+
+    element.showOnly('Priority', 'High');
+
+    sinon.assert.calledWith(element._page,
+        '/p/chromium/issues/list?q=owner%3Ame%20priority%3DHigh');
+  });
+
+  it('addColumn adds a column', () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'reloadColspec');
+
+    element.addColumn('AllLabels');
+
+    sinon.assert.calledWith(element.reloadColspec,
+        ['ID', 'Summary', 'AllLabels']);
+  });
+
+  it('removeColumn removes a column', () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'reloadColspec');
+
+    element.removeColumn(0);
+
+    sinon.assert.calledWith(element.reloadColspec, ['Summary']);
+  });
+
+  it('clicking hide column in column header removes column', async () => {
+    element.columns = ['ID', 'Summary'];
+
+    sinon.stub(element, 'removeColumn');
+
+    await element.updateComplete;
+
+    const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+    dropdown.clickItem(0); // Hide column.
+
+    sinon.assert.calledWith(element.removeColumn, 1);
+  });
+
+  it('starring disabled when starringEnabled is false', async () => {
+    element.starringEnabled = false;
+    element.issues = [
+      {projectName: 'test', localId: 1, summary: 'test issue'},
+      {projectName: 'test', localId: 2, summary: 'I have a summary'},
+    ];
+
+    await element.updateComplete;
+
+    let stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+    assert.equal(stars.length, 0);
+
+    element.starringEnabled = true;
+    await element.updateComplete;
+
+    stars = element.shadowRoot.querySelectorAll('mr-issue-star');
+    assert.equal(stars.length, 2);
+  });
+
+  describe('issue sorting and grouping enabled', () => {
+    beforeEach(() => {
+      element.sortingAndGroupingEnabled = true;
+    });
+
+    it('clicking sort up column header sets sort spec', async () => {
+      element.columns = ['ID', 'Summary'];
+
+      sinon.stub(element, 'updateSortSpec');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+      dropdown.clickItem(0); // Sort up.
+
+      sinon.assert.calledWith(element.updateSortSpec, 'Summary');
+    });
+
+    it('clicking sort down column header sets sort spec', async () => {
+      element.columns = ['ID', 'Summary'];
+
+      sinon.stub(element, 'updateSortSpec');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-summary');
+
+      dropdown.clickItem(1); // Sort down.
+
+      sinon.assert.calledWith(element.updateSortSpec, 'Summary', true);
+    });
+
+    it('clicking group rows column header groups rows', async () => {
+      element.columns = ['Owner', 'Priority'];
+      element.groups = ['Status'];
+
+      sinon.spy(element, 'addGroupBy');
+
+      await element.updateComplete;
+
+      const dropdown = element.shadowRoot.querySelector('.dropdown-owner');
+      dropdown.clickItem(3); // Group rows.
+
+      sinon.assert.calledWith(element.addGroupBy, 0);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?groupby=Owner%20Status&colspec=Priority');
+    });
+  });
+
+  describe('issue selection', () => {
+    beforeEach(() => {
+      element.selectionEnabled = true;
+    });
+
+    it('selections disabled when selectionEnabled is false', async () => {
+      element.selectionEnabled = false;
+      element.issues = [
+        {projectName: 'test', localId: 1, summary: 'test issue'},
+        {projectName: 'test', localId: 2, summary: 'I have a summary'},
+      ];
+
+      await element.updateComplete;
+
+      let checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+      assert.equal(checkboxes.length, 0);
+
+      element.selectionEnabled = true;
+      await element.updateComplete;
+
+      checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+      assert.equal(checkboxes.length, 2);
+    });
+
+    it('selected issues render selected attribute', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'another issue', localId: 2, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ];
+      element.columns = ['Summary'];
+
+      await element.updateComplete;
+
+      element._selectedIssues = new Set(['proj:1']);
+
+      await element.updateComplete;
+
+      const issues = element.shadowRoot.querySelectorAll('tr[selected]');
+
+      assert.equal(issues.length, 1);
+      assert.equal(issues[0].dataset.index, '0');
+      assert.include(issues[0].textContent, 'issue 1');
+    });
+
+    it('select all / none conditionally shows tooltip', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+      assert.deepEqual(element.selectedIssues, []);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+
+      // No issues selected, offer "Select All".
+      assert.equal(selectAll.title, 'Select All');
+      assert.equal(selectAll.getAttribute('aria-label'), 'Select All');
+
+      selectAll.click();
+
+      await element.updateComplete;
+
+      // Some issues selected, offer "Select None".
+      assert.equal(selectAll.title, 'Select None');
+      assert.equal(selectAll.getAttribute('aria-label'), 'Select None');
+    });
+
+    it('clicking select all selects all issues', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+      selectAll.click();
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ]);
+    });
+
+    it('when checked select all deselects all issues', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      element._selectedIssues = new Set(['proj:1', 'proj:2']);
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 2, projectName: 'proj'},
+      ]);
+
+      const selectAll = element.shadowRoot.querySelector('.select-all');
+      selectAll.click();
+
+      assert.deepEqual(element.selectedIssues, []);
+    });
+
+    it('selected issues added when issues checked', async () => {
+      element.issues = [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'another issue', localId: 2, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+      assert.equal(checkboxes.length, 3);
+
+      checkboxes[2].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ]);
+
+      checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {summary: 'issue 1', localId: 1, projectName: 'proj'},
+        {summary: 'issue 2', localId: 3, projectName: 'proj'},
+      ]);
+    });
+
+    it('shift+click selects issues in a range', async () => {
+      element.issues = [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+        {localId: 3, projectName: 'proj'},
+        {localId: 4, projectName: 'proj'},
+        {localId: 5, projectName: 'proj'},
+      ];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, []);
+
+      const checkboxes = element.shadowRoot.querySelectorAll('.issue-checkbox');
+
+      // First click.
+      checkboxes[0].dispatchEvent(new MouseEvent('click'));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+      ]);
+
+      // Second click.
+      checkboxes[3].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+        {localId: 3, projectName: 'proj'},
+        {localId: 4, projectName: 'proj'},
+      ]);
+
+      // It's possible to chain Shift+Click operations.
+      checkboxes[2].dispatchEvent(new MouseEvent('click', {shiftKey: true}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 1, projectName: 'proj'},
+        {localId: 2, projectName: 'proj'},
+      ]);
+    });
+
+    it('fires selectionChange events', async () => {
+      const listener = sinon.stub();
+      element.addEventListener('selectionChange', listener);
+
+      // Changing the issue list clears the selection and fires an event.
+      element.issues = [{localId: 1, projectName: 'proj'}];
+      await element.updateComplete;
+      // Selecting all/deselecting all fires an event.
+      element.shadowRoot.querySelector('.select-all').click();
+      await element.updateComplete;
+      // Selecting an individual issue fires an event.
+      element.shadowRoot.querySelectorAll('.issue-checkbox')[0].click();
+
+      sinon.assert.calledThrice(listener);
+    });
+  });
+
+  describe('cursor', () => {
+    beforeEach(() => {
+      element.issues = [
+        {localId: 1, projectName: 'chromium'},
+        {localId: 2, projectName: 'chromium'},
+      ];
+    });
+
+    it('empty when no initialCursor', () => {
+      assert.deepEqual(element.cursor, {});
+
+      element.initialCursor = '';
+      assert.deepEqual(element.cursor, {});
+    });
+
+    it('parses initialCursor value', () => {
+      element.initialCursor = '1';
+      element.projectName = 'chromium';
+
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+
+      element.initialCursor = 'chromium:1';
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 1});
+    });
+
+    it('overrides initialCursor with _localCursor', () => {
+      element.initialCursor = 'chromium:1';
+      element._localCursor = {projectName: 'gerrit', localId: 2};
+
+      assert.deepEqual(element.cursor, {projectName: 'gerrit', localId: 2});
+    });
+
+    it('initialCursor renders cursor and focuses element', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-0');
+      assert.isTrue(row.hasAttribute('cursored'));
+      listRowIsFocused(element, 0);
+    });
+
+    it('cursor value updated when row is focused', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      // HTMLElement.focus() seems to cause a timing related flake here.
+      element.shadowRoot.querySelector('.row-1').dispatchEvent(
+          new Event('focus'));
+
+      assert.deepEqual(element.cursor, {projectName: 'chromium', localId: 2});
+    });
+  });
+
+  describe('hot keys', () => {
+    beforeEach(() => {
+      element.issues = [
+        {localId: 1, projectName: 'chromium'},
+        {localId: 2, projectName: 'chromium'},
+        {localId: 3, projectName: 'chromium'},
+      ];
+
+      element.selectionEnabled = true;
+
+      sinon.stub(element, '_navigateToIssue');
+    });
+
+    afterEach(() => {
+      element._navigateToIssue.restore();
+    });
+
+    it('global keydown listener removed on disconnect', async () => {
+      sinon.stub(element, '_boundRunListHotKeys');
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new Event('keydown'));
+      sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+      document.body.removeChild(element);
+
+      window.dispatchEvent(new Event('keydown'));
+      sinon.assert.calledOnce(element._boundRunListHotKeys);
+
+      document.body.appendChild(element);
+    });
+
+    it('pressing j defaults to first issue', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+    it('pressing j focuses next issue', async () => {
+      element.initialCursor = 'chromium:1';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 1);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('pressing j at the end of the list loops around', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-2').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+
+    it('pressing k defaults to last issue', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('pressing k focuses previous issue', async () => {
+      element.initialCursor = 'chromium:3';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 1);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 0);
+    });
+
+    it('pressing k at the start of the list loops around', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-0').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+
+      listRowIsFocused(element, 2);
+    });
+
+    it('j and k keys treat row as focused if child is focused', async () => {
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('.row-1').querySelector(
+          'mr-issue-link').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 2);
+
+      element.shadowRoot.querySelector('.row-1').querySelector(
+          'mr-issue-link').focus();
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+    });
+
+    it('j and k keys stay on one element when one issue', async () => {
+      element.issues = [{localId: 2, projectName: 'chromium'}];
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      listRowIsFocused(element, 0);
+    });
+
+    it('j and k no-op when event is from input', async () => {
+      const input = document.createElement('input');
+      document.body.appendChild(input);
+
+      await element.updateComplete;
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      document.body.removeChild(input);
+    });
+
+    it('j and k no-op when event is from shadowDOM input', async () => {
+      const input = document.createElement('input');
+      const root = document.createElement('div');
+
+      root.attachShadow({mode: 'open'});
+      root.shadowRoot.appendChild(input);
+
+      document.body.appendChild(root);
+
+      await element.updateComplete;
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'j'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      input.dispatchEvent(new KeyboardEvent('keydown', {key: 'k'}));
+      assert.isNull(element.shadowRoot.activeElement);
+
+      document.body.removeChild(root);
+    });
+
+    describe('starring issue', () => {
+      beforeEach(() => {
+        element.starringEnabled = true;
+        element.initialCursor = 'chromium:2';
+      });
+
+      it('pressing s stars focused issue', async () => {
+        sinon.stub(element, '_starIssue');
+        await element.updateComplete;
+
+        window.dispatchEvent(new KeyboardEvent('keydown', {key: 's'}));
+
+        sinon.assert.calledWith(element._starIssue,
+            {localId: 2, projectName: 'chromium'});
+      });
+
+      it('starIssue does not star issue while stars are fetched', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._fetchingStarredIssues = true;
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.notCalled(element._starIssueInternal);
+      });
+
+      it('starIssue does not star when issue is being starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starringIssues = new Map([['chromium:2', {requesting: true}]]);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.notCalled(element._starIssueInternal);
+      });
+
+      it('starIssue stars issue when issue is not being starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starringIssues = new Map([
+          ['chromium:2', {requesting: false}],
+        ]);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.calledWith(element._starIssueInternal,
+            {localId: 2, projectName: 'chromium'}, true);
+      });
+
+      it('starIssue unstars issue when issue is already starred', () => {
+        sinon.stub(element, '_starIssueInternal');
+        element._starredIssues = new Set(['chromium:2']);
+
+        element._starIssue({localId: 2, projectName: 'chromium'});
+
+        sinon.assert.calledWith(element._starIssueInternal,
+            {localId: 2, projectName: 'chromium'}, false);
+      });
+    });
+
+    it('pressing x selects focused issue', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'x'}));
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.selectedIssues, [
+        {localId: 2, projectName: 'chromium'},
+      ]);
+    });
+
+    it('pressing o navigates to focused issue', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'o'}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, false);
+    });
+
+    it('pressing shift+o opens focused issue in new tab', async () => {
+      element.initialCursor = 'chromium:2';
+
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown',
+          {key: 'O', shiftKey: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, true);
+    });
+
+    it('enter keydown on row navigates to issue', async () => {
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-1');
+
+      row.dispatchEvent(
+          new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(
+          element._navigateToIssue, {localId: 2, projectName: 'chromium'},
+          false);
+    });
+
+    it('ctrl+enter keydown on row navigates to issue in new tab', async () => {
+      await element.updateComplete;
+
+      const row = element.shadowRoot.querySelector('.row-1');
+
+      // Note: metaKey would also work, but this is covered by click tests.
+      row.dispatchEvent(new KeyboardEvent(
+          'keydown', {key: 'Enter', ctrlKey: true, bubbles: true}));
+
+      await element.updateComplete;
+
+      sinon.assert.calledOnce(element._navigateToIssue);
+      sinon.assert.calledWith(element._navigateToIssue,
+          {localId: 2, projectName: 'chromium'}, true);
+    });
+
+    it('enter keypress outside row is ignored', async () => {
+      await element.updateComplete;
+
+      window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element._navigateToIssue);
+    });
+  });
+
+  describe('_convertIssueToPlaintextArray', () => {
+    it('returns an array with as many entries as this.columns.length', () => {
+      element.columns = ['summary'];
+      const result = element._convertIssueToPlaintextArray({
+        summary: 'test issue',
+      });
+      assert.equal(element.columns.length, result.length);
+    });
+
+    it('for column id uses issueRefToString', async () => {
+      const projectName = 'some_project_name';
+      const otherProjectName = 'some_other_project';
+      const localId = '123';
+      element.columns = ['ID'];
+      element.projectName = projectName;
+
+      element.extractFieldValues = (issue, fieldName) =>
+        stringValuesForIssueField(issue, fieldName, projectName);
+
+      let result;
+      result = element._convertIssueToPlaintextArray({
+        localId,
+        projectName,
+      });
+      assert.equal(localId, result[0]);
+
+      result = element._convertIssueToPlaintextArray({
+        localId,
+        projectName: otherProjectName,
+      });
+      assert.equal(`${otherProjectName}:${localId}`, result[0]);
+    });
+
+    it('uses extractFieldValues', () => {
+      element.columns = ['summary', 'notsummary', 'anotherColumn'];
+      element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+      element._convertIssueToPlaintextArray({summary: 'test issue'});
+      sinon.assert.callCount(element.extractFieldValues,
+          element.columns.length);
+    });
+
+    it('joins the result of extractFieldValues with ", "', () => {
+      element.columns = ['notSummary'];
+      element.extractFieldValues = sinon.fake.returns(['a', 'b']);
+
+      const result = element._convertIssueToPlaintextArray({
+        summary: 'test issue',
+      });
+      assert.deepEqual(result, ['a, b']);
+    });
+  });
+
+  describe('_convertIssuesToPlaintextArrays', () => {
+    it('maps this.issues with this._convertIssueToPlaintextArray', () => {
+      element._convertIssueToPlaintextArray = sinon.fake.returns(['foobar']);
+
+      element.columns = ['summary'];
+      element.issues = [
+        {summary: 'test issue'},
+        {summary: 'I have a summary'},
+      ];
+      const result = element._convertIssuesToPlaintextArrays();
+
+      assert.deepEqual([['foobar'], ['foobar']], result);
+      sinon.assert.callCount(element._convertIssueToPlaintextArray,
+          element.issues.length);
+    });
+  });
+
+  it('drag-and-drop', async () => {
+    element.rerank = () => {};
+    element.issues = [
+      {projectName: 'project', localId: 123, summary: 'test issue'},
+      {projectName: 'project', localId: 456, summary: 'I have a summary'},
+      {projectName: 'project', localId: 789, summary: 'third issue'},
+    ];
+    await element.updateComplete;
+
+    const rows = element._getRows();
+
+    // Mouse down on the middle element!
+    const secondRow = rows[1];
+    const dragHandle = secondRow.firstElementChild;
+    const mouseDown = new MouseEvent('mousedown', {clientX: 0, clientY: 0});
+    dragHandle.dispatchEvent(mouseDown);
+
+    assert.deepEqual(element._dragging, true);
+    assert.deepEqual(element.cursor, {projectName: 'project', localId: 456});
+    assert.deepEqual(element.selectedIssues, [element.issues[1]]);
+
+    // Drag the middle element to the end!
+    const mouseMove = new MouseEvent('mousemove', {clientX: 0, clientY: 100});
+    window.dispatchEvent(mouseMove);
+
+    assert.deepEqual(rows[0].style['transform'], '');
+    assert.deepEqual(rows[1].style['transform'], 'translate(0px, 100px)');
+    assert.match(rows[2].style['transform'], /^translate\(0px, -\d+px\)$/);
+
+    // Mouse up!
+    const mouseUp = new MouseEvent('mouseup', {clientX: 0, clientY: 100});
+    window.dispatchEvent(mouseUp);
+
+    assert.deepEqual(element._dragging, false);
+    assert.match(rows[1].style['transform'], /^translate\(0px, \d+px\)$/);
+  });
+
+  describe('CSV download', () => {
+    let _downloadCsvSpy;
+    let convertStub;
+
+    beforeEach(() => {
+      element.userDisplayName = 'notempty';
+      _downloadCsvSpy = sinon.spy(element, '_downloadCsv');
+      convertStub = sinon
+          .stub(element, '_convertIssuesToPlaintextArrays')
+          .returns([['']]);
+    });
+
+    afterEach(() => {
+      _downloadCsvSpy.restore();
+      convertStub.restore();
+    });
+
+    it('hides download link for anonymous users', async () => {
+      element.userDisplayName = '';
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      assert.isNull(downloadLink);
+    });
+
+    it('renders a #download-link', async () => {
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      assert.isNotNull(downloadLink);
+      assert.equal('inline', window.getComputedStyle(downloadLink).display);
+    });
+
+    it('renders a #hidden-data-link', async () => {
+      await element.updateComplete;
+      assert.isNotNull(element._dataLink);
+      const expected = element.shadowRoot.querySelector('#hidden-data-link');
+      assert.equal(expected, element._dataLink);
+    });
+
+    it('hides #hidden-data-link', async () => {
+      await element.updateComplete;
+      const _dataLink = element.shadowRoot.querySelector('#hidden-data-link');
+      assert.equal('none', window.getComputedStyle(_dataLink).display);
+    });
+
+    it('calls _downloadCsv on click', async () => {
+      await element.updateComplete;
+      sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+
+      sinon.assert.calledOnce(_downloadCsvSpy);
+      element._dataLink.click.restore();
+    });
+
+    it('converts issues into arrays of plaintext data', async () => {
+      await element.updateComplete;
+      sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+
+      sinon.assert.calledOnce(convertStub);
+      element._dataLink.click.restore();
+    });
+
+    it('triggers _dataLink click after #downloadLink click', async () => {
+      await element.updateComplete;
+      const dataLinkStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.calledOnce(dataLinkStub);
+
+      element._dataLink.click.restore();
+    });
+
+    it('triggers _csvDataHref update and _dataLink click', async () => {
+      await element.updateComplete;
+      assert.equal('', element._csvDataHref);
+      const downloadStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+      assert.notEqual('', element._csvDataHref);
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.calledOnce(downloadStub);
+
+      element._dataLink.click.restore();
+    });
+
+    it('resets _csvDataHref', async () => {
+      await element.updateComplete;
+      assert.equal('', element._csvDataHref);
+
+      sinon.stub(element._dataLink, 'click');
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+      downloadLink.click();
+      assert.notEqual('', element._csvDataHref);
+
+      await element.requestUpdate('_csvDataHref');
+      assert.equal('', element._csvDataHref);
+      element._dataLink.click.restore();
+    });
+
+    it('does nothing for anonymous users', async () => {
+      await element.updateComplete;
+
+      element.userDisplayName = '';
+
+      const downloadStub = sinon.stub(element._dataLink, 'click');
+
+      const downloadLink = element.shadowRoot.querySelector('#download-link');
+
+      downloadLink.click();
+      await element.requestUpdate('_csvDataHref');
+      sinon.assert.notCalled(downloadStub);
+
+      element._dataLink.click.restore();
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
new file mode 100644
index 0000000..5d6a97b
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.js
@@ -0,0 +1,310 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {css} from 'lit-element';
+import {MrDropdown} from 'elements/framework/mr-dropdown/mr-dropdown.js';
+import page from 'page';
+import qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {fieldTypes, fieldsForIssue} from 'shared/issue-fields.js';
+
+
+/**
+ * `<mr-show-columns-dropdown>`
+ *
+ * Issue list column options dropdown.
+ *
+ */
+export class MrShowColumnsDropdown extends connectStore(MrDropdown) {
+  /** @override */
+  static get styles() {
+    return [
+      ...MrDropdown.styles,
+      css`
+        :host {
+          font-weight: normal;
+          color: var(--chops-link-color);
+          --mr-dropdown-icon-color: var(--chops-link-color);
+          --mr-dropdown-anchor-padding: 3px 8px;
+          --mr-dropdown-anchor-font-weight: bold;
+          --mr-dropdown-menu-min-width: 150px;
+          --mr-dropdown-menu-font-size: var(--chops-main-font-size);
+          --mr-dropdown-menu-icon-size: var(--chops-main-font-size);
+          /* Because we're using a sticky header, we need to make sure the
+           * dropdown cannot be taller than the screen. */
+          --mr-dropdown-menu-max-height: 80vh;
+          --mr-dropdown-menu-overflow: auto;
+        }
+      `,
+    ];
+  }
+  /** @override */
+  static get properties() {
+    return {
+      ...MrDropdown.properties,
+      /**
+       * Array of displayed columns.
+       */
+      columns: {type: Array},
+      /**
+       * Array of displayed issues.
+       */
+      issues: {type: Array},
+      /**
+       * Array of unique phase names to prepend to phase field columns.
+       */
+      // TODO(dtu): Delete after removing EZT hotlist issue list.
+      phaseNames: {type: Array},
+      /**
+       * Array of built in fields that are available outside of project
+       * configuration.
+       */
+      defaultFields: {type: Array},
+      _fieldDefs: {type: Array},
+      _labelPrefixFields: {type: Array},
+      // TODO(zhangtiff): Delete this legacy integration after removing
+      // the EZT issue list view.
+      onHideColumn: {type: Object},
+      onShowColumn: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Inherited from MrDropdown.
+    this.label = 'Show columns';
+    this.icon = 'more_horiz';
+
+    this.columns = [];
+    /** @type {Array<Issue>} */
+    this.issues = [];
+    this.phaseNames = [];
+    this.defaultFields = [];
+
+    // TODO(dtu): Delete after removing EZT hotlist issue list.
+    this._fieldDefs = [];
+    this._labelPrefixFields = [];
+
+    this._queryParams = {};
+    this._page = page;
+
+    // TODO(zhangtiff): Delete this legacy integration after removing
+    // the EZT issue list view.
+    this.onHideColumn = null;
+    this.onShowColumn = null;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._fieldDefs = projectV0.fieldDefs(state) || [];
+    this._labelPrefixFields = projectV0.labelPrefixFields(state) || [];
+    this._queryParams = sitewide.queryParams(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (this.issues.length) {
+      this.items = this.columnOptions();
+    } else {
+      // TODO(dtu): Delete after removing EZT hotlist issue list.
+      this.items = this.columnOptionsEzt(
+          this.defaultFields, this._fieldDefs, this._labelPrefixFields,
+          this.columns, this.phaseNames);
+    }
+
+    super.update(changedProperties);
+  }
+
+  /**
+   * Computes the column options available in the list view based on Issues.
+   * @return {Array<MenuItem>}
+   */
+  columnOptions() {
+    const availableFields = new Set(this.defaultFields);
+    this.issues.forEach((issue) => {
+      fieldsForIssue(issue).forEach((field) => {
+        availableFields.add(field);
+      });
+    });
+
+    // Remove selected columns from available fields.
+    this.columns.forEach((field) => availableFields.delete(field));
+    const sortedFields = [...availableFields].sort();
+
+    return [
+      // Show selected options first.
+      ...this.columns.map((field, i) => ({
+        icon: 'check',
+        text: field,
+        handler: () => this._removeColumn(i),
+      })),
+      // Unselected options come next.
+      ...sortedFields.map((field) => ({
+        icon: '',
+        text: field,
+        handler: () => this._addColumn(field),
+      })),
+    ];
+  }
+
+  // TODO(dtu): Delete after removing EZT hotlist issue list.
+  /**
+   * Computes the column options available in the list view based on project
+   * config data.
+   * @param {Array<string>} defaultFields List of built in columns.
+   * @param {Array<FieldDef>} fieldDefs List of custom fields configured in the
+   *   viewed project.
+   * @param {Array<string>} labelPrefixes List of available label prefixes for
+   *   the current project config..
+   * @param {Array<string>} selectedColumns List of columns the user is
+   *   currently viewing.
+   * @param {Array<string>} phaseNames All phase namws present in the currently
+   *   viewed issue list.
+   * @return {Array<MenuItem>}
+   */
+  columnOptionsEzt(defaultFields, fieldDefs, labelPrefixes, selectedColumns,
+      phaseNames) {
+    const selectedOptions = new Set(
+        selectedColumns.map((col) => col.toLowerCase()));
+
+    const availableFields = new Set();
+
+    // Built-in, hard-coded fields like Owner, Status, and Labels.
+    defaultFields.forEach((field) => this._addUnselectedField(
+        availableFields, field, selectedOptions));
+
+    // Custom fields.
+    fieldDefs.forEach((fd) => {
+      const {fieldRef, isPhaseField} = fd;
+      const {fieldName, type} = fieldRef;
+      if (isPhaseField) {
+        // If the custom field belongs to phases, prefix the phase name for
+        // each phase.
+        phaseNames.forEach((phaseName) => {
+          this._addUnselectedField(
+              availableFields, `${phaseName}.${fieldName}`, selectedOptions);
+        });
+        return;
+      }
+
+      // TODO(zhangtiff): Prefix custom fields with "approvalName" defined by
+      // the approval name after deprecating the old issue list page.
+
+      // Most custom fields can be directly added to the list with no
+      // modifications.
+      this._addUnselectedField(
+          availableFields, fieldName, selectedOptions);
+
+      // If the custom field is type approval, then it also has a built in
+      // "Approver" field.
+      if (type === fieldTypes.APPROVAL_TYPE) {
+        this._addUnselectedField(
+            availableFields, `${fieldName}-Approver`, selectedOptions);
+      }
+    });
+
+    // Fields inferred from label prefixes.
+    labelPrefixes.forEach((field) => this._addUnselectedField(
+        availableFields, field, selectedOptions));
+
+    const sortedFields = [...availableFields];
+    sortedFields.sort();
+
+    return [
+      ...selectedColumns.map((field, i) => ({
+        icon: 'check',
+        text: field,
+        handler: () => this._removeColumn(i),
+      })),
+      ...sortedFields.map((field) => ({
+        icon: '',
+        text: field,
+        handler: () => this._addColumn(field),
+      })),
+    ];
+  }
+
+  /**
+   * Helper that mutates a Set of column names in place, adding a given
+   * field only if it doesn't already show up in the list of selected
+   * fields.
+   * @param {Set<string>} availableFields Set of column names to mutate.
+   * @param {string} field Name of the field being added to the options.
+   * @param {Set<string>} selectedOptions Set of fieldNames that the user
+   *   is viewing.
+   * @private
+   */
+  _addUnselectedField(availableFields, field, selectedOptions) {
+    if (!selectedOptions.has(field.toLowerCase())) {
+      availableFields.add(field);
+    }
+  }
+
+  /**
+   * Removes the column at a particular index.
+   *
+   * @param {number} i the issue column to be removed.
+   */
+  _removeColumn(i) {
+    if (this.onHideColumn) {
+      if (!this.onHideColumn(this.columns[i])) {
+        return;
+      }
+    }
+    const columns = [...this.columns];
+    columns.splice(i, 1);
+    this._reloadColspec(columns);
+  }
+
+  /**
+   * Adds a new column to a particular index.
+   *
+   * @param {string} name of the new column added.
+   */
+  _addColumn(name) {
+    if (this.onShowColumn) {
+      if (!this.onShowColumn(name)) {
+        return;
+      }
+    }
+    this._reloadColspec([...this.columns, name]);
+  }
+
+  /**
+   * Reflects changes to the columns of an issue list to the URL, through
+   * frontend routing.
+   *
+   * @param {Array} newColumns the new colspec to set in the URL.
+   */
+  _reloadColspec(newColumns) {
+    this._updateQueryParams({colspec: newColumns.join(' ')});
+  }
+
+  /**
+   * Navigates to the same URL as the current page, but with query
+   * params updated.
+   *
+   * @param {Object} newParams keys and values of the queryParams
+   * Object to be updated.
+   */
+  _updateQueryParams(newParams) {
+    const params = {...this._queryParams, ...newParams};
+    this._page(`${this._baseUrl()}?${qs.stringify(params)}`);
+  }
+
+  /**
+   * Get the current URL of the page, without query params. Useful for
+   * test stubbing.
+   *
+   * @return {string} the URL of the list page, without params.
+   */
+  _baseUrl() {
+    return window.location.pathname;
+  }
+}
+
+customElements.define('mr-show-columns-dropdown', MrShowColumnsDropdown);
diff --git a/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
new file mode 100644
index 0000000..495ffe2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-list/mr-show-columns-dropdown.test.js
@@ -0,0 +1,209 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrShowColumnsDropdown} from './mr-show-columns-dropdown.js';
+
+/** @type {MrShowColumnsDropdown} */
+let element;
+
+describe('mr-show-columns-dropdown', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-show-columns-dropdown');
+    document.body.appendChild(element);
+
+    sinon.stub(element, '_baseUrl').returns('/p/chromium/issues/list');
+    sinon.stub(element, '_page');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrShowColumnsDropdown);
+  });
+
+  it('displaying columns (spa)', async () => {
+    element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+    element.columns = ['ID'];
+    element.issues = [
+      {approvalValues: [{fieldRef: {fieldName: 'Approval-Name'}}]},
+      {fieldValues: [
+        {phaseRef: {phaseName: 'Phase'}, fieldRef: {fieldName: 'Field-Name'}},
+        {fieldRef: {fieldName: 'Field-Name'}},
+      ]},
+      {labelRefs: [{label: 'Label-Name'}]},
+    ];
+
+    await element.updateComplete;
+
+    const actual =
+        element.items.map((item) => ({icon: item.icon, text: item.text}));
+    const expected = [
+      {icon: 'check', text: 'ID'},
+      {icon: '', text: 'AllLabels'},
+      {icon: '', text: 'Approval-Name'},
+      {icon: '', text: 'Approval-Name-Approver'},
+      {icon: '', text: 'Field-Name'},
+      {icon: '', text: 'Label'},
+      {icon: '', text: 'Phase.Field-Name'},
+      {icon: '', text: 'Summary'},
+    ];
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('displaying columns (ezt)', () => {
+    it('sorts default column options', async () => {
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.columns = [];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 3);
+
+      assert.equal(options[0].text.trim(), 'AllLabels');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'ID');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Summary');
+      assert.equal(options[2].icon, '');
+    });
+
+    it('sorts selected columns above unselected columns', async () => {
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.columns = ['ID'];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 3);
+
+      assert.equal(options[0].text.trim(), 'ID');
+      assert.equal(options[0].icon, 'check');
+
+      assert.equal(options[1].text.trim(), 'AllLabels');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Summary');
+      assert.equal(options[2].icon, '');
+    });
+
+    it('sorts field defs and label prefix column options', async () => {
+      element.defaultFields = ['ID', 'Summary'];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'HelloWorld'}},
+        {fieldRef: {fieldName: 'TestField'}},
+      ];
+
+      element._labelPrefixFields = ['Milestone', 'Priority'];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 6);
+      assert.equal(options[0].text.trim(), 'HelloWorld');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'ID');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'Milestone');
+      assert.equal(options[2].icon, '');
+
+      assert.equal(options[3].text.trim(), 'Priority');
+      assert.equal(options[3].icon, '');
+
+      assert.equal(options[4].text.trim(), 'Summary');
+      assert.equal(options[4].icon, '');
+
+      assert.equal(options[5].text.trim(), 'TestField');
+      assert.equal(options[5].icon, '');
+    });
+
+    it('add approver fields for approval type fields', async () => {
+      element.defaultFields = [];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'HelloWorld', type: 'APPROVAL_TYPE'}},
+      ];
+      element._labelPrefixFields = [];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 2);
+      assert.equal(options[0].text.trim(), 'HelloWorld');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'HelloWorld-Approver');
+      assert.equal(options[1].icon, '');
+    });
+
+    it('phase field columns are correctly named', async () => {
+      element.defaultFields = [];
+      element.columns = [];
+      element._fieldDefs = [
+        {fieldRef: {fieldName: 'Number', type: 'INT_TYPE'}, isPhaseField: true},
+        {fieldRef: {fieldName: 'Speak', type: 'STR_TYPE'}, isPhaseField: true},
+      ];
+      element._labelPrefixFields = [];
+      element.phaseNames = ['cow', 'chicken'];
+
+      // Re-compute menu items on update.
+      await element.updateComplete;
+      const options = element.items;
+
+      assert.equal(options.length, 4);
+      assert.equal(options[0].text.trim(), 'chicken.Number');
+      assert.equal(options[0].icon, '');
+
+      assert.equal(options[1].text.trim(), 'chicken.Speak');
+      assert.equal(options[1].icon, '');
+
+      assert.equal(options[2].text.trim(), 'cow.Number');
+      assert.equal(options[2].icon, '');
+
+      assert.equal(options[3].text.trim(), 'cow.Speak');
+      assert.equal(options[3].icon, '');
+    });
+  });
+
+  describe('modifying columns', () => {
+    it('clicking unset column adds a column', async () => {
+      element.columns = ['ID', 'Summary'];
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.queryParams = {};
+
+      await element.updateComplete;
+      element.clickItem(2);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?colspec=ID%20Summary%20AllLabels');
+    });
+
+    it('clicking set column removes a column', async () => {
+      element.columns = ['ID', 'Summary'];
+      element.defaultFields = ['ID', 'Summary', 'AllLabels'];
+      element.queryParams = {};
+
+      await element.updateComplete;
+      element.clickItem(0);
+
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?colspec=Summary');
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
new file mode 100644
index 0000000..5a3e42c
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.js
@@ -0,0 +1,59 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {determineSloStatus} from './slo-rules.js';
+
+/** @typedef {import('./slo-rules.js').SloStatus} SloStatus */
+
+/**
+ * `<mr-issue-slo>`
+ *
+ * A widget for showing the given issue's SLO status.
+ */
+export class MrIssueSlo extends LitElement {
+  /** @override */
+  static get styles() {
+    return css``;
+  }
+
+  /** @override */
+  render() {
+    const sloStatus = this._determineSloStatus();
+    if (!sloStatus) {
+      return html`N/A`;
+    }
+    if (!sloStatus.target) {
+      return html`Done`;
+    }
+    return html`
+      <chops-timestamp .timestamp=${sloStatus.target} short></chops-timestamp>`;
+  }
+
+  /**
+   * Wrapper around slo-rules.js determineSloStatus to allow tests to override
+   * the return value.
+   * @private
+   * @return {SloStatus}
+   */
+  _determineSloStatus() {
+    return this.issue ? determineSloStatus(this.issue) : null;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+    };
+  }
+  /** @override */
+  constructor() {
+    super();
+    /** @type {Issue} */
+    this.issue;
+  }
+}
+customElements.define('mr-issue-slo', MrIssueSlo);
diff --git a/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
new file mode 100644
index 0000000..28d23eb
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/mr-issue-slo.test.js
@@ -0,0 +1,54 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrIssueSlo} from './mr-issue-slo.js';
+
+
+let element;
+
+describe('mr-issue-slo', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-slo');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueSlo);
+  });
+
+  it('handles ineligible issues', async () => {
+    element._determineSloStatus = () => {
+      return null;
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'N/A');
+  });
+
+  it('handles issues that have completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: null};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent, 'Done');
+  });
+
+  it('handles issues that have not completed the SLO criteria', async () => {
+    element._determineSloStatus = () => {
+      return {target: 1234};
+    };
+    element.issue = {};
+    await element.updateComplete;
+    const timestampElement =
+        element.shadowRoot.querySelector('chops-timestamp');
+
+    assert.equal(timestampElement.timestamp, 1234);
+  });
+});
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.js b/static_src/elements/framework/mr-issue-slo/slo-rules.js
new file mode 100644
index 0000000..e351ae0
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.js
@@ -0,0 +1,195 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Determining Issues' statuses relative to SLO rules.
+ *
+ * See go/monorail-slo-v0 for more info.
+ */
+
+/**
+ * A rule determining the compliance of an issue with regard to an SLO.
+ * @typedef {Object} SloRule
+ * @property {function(Issue): SloStatus} statusFunction
+ */
+
+/**
+ * Potential statuses of an issue relative to an SLO's completion criteria.
+ * @enum {string}
+ */
+export const SloCompletionStatus = {
+  /** The completion criteria for the SloRule have not been satisfied. */
+  INCOMPLETE: 'INCOMPLETE',
+  /** The completion criteria for the SloRule have been satisfied. */
+  COMPLETE: 'COMPLETE',
+};
+
+/**
+ * The status of an issue with regard to an SloRule.
+ * @typedef {Object} SloStatus
+ * @property {SloRule} rule The rule that generated this status.
+ * @property {Date} target The time the Issue must move to completion, or null
+ *     if the issue has already moved to completion.
+ * @property {SloCompletionStatus} completion Issue's completion status.
+ */
+
+/**
+ * Chrome OS Software's SLO for issue closure (go/chromeos-software-bug-slos).
+ *
+ * Implementation based on the queries defined in Sheriffbot
+ * https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py
+ *
+ * @const {SloRule}
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO = {
+  statusFunction: (issue) => {
+    if (!_isCrosClosureEligible(issue)) {
+      return null;
+    }
+
+    const pri = getPriFromIssue(issue);
+    const daysToClose = _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY[pri];
+
+    if (!daysToClose) {
+      // No applicable SLO found issues with this priority.
+      return null;
+    }
+    // Return a complete status for closed issues.
+    if (issue.statusRef && !issue.statusRef.meansOpen) {
+      return {
+        rule: _CROS_CLOSURE_SLO,
+        target: null,
+        completion: SloCompletionStatus.COMPLETE};
+    }
+
+    // Set the target based on the opening and the daysToClose.
+    const target = new Date(issue.openedTimestamp * 1000);
+    target.setDate(target.getDate() + daysToClose);
+    return {
+      rule: _CROS_CLOSURE_SLO,
+      target: target,
+      completion: SloCompletionStatus.INCOMPLETE};
+  },
+};
+
+/**
+ * @param {Issue} issue
+ * @return {string?} the pri's value, if found.
+ */
+const getPriFromIssue = (issue) => {
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Pri') {
+      return fv.value;
+    }
+  }
+};
+
+/**
+ * The number of days (since the issue was opened) allowed for it to be fixed.
+ * @private Only visible for testing.
+ */
+export const _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY = Object.freeze({
+  '1': 42,
+});
+
+// https://chrome-internal.googlesource.com/infra/infra_internal/+/refs/heads/main/appengine/sheriffbot/src/sheriffbot/bug_slo_daily_queries.py#97
+const CROS_ELIGIBLE_COMPONENT_PATHS = new Set([
+  'OS>Systems>CrashReporting',
+  'OS>Systems>Displays',
+  'OS>Systems>Feedback',
+  'OS>Systems>HaTS',
+  'OS>Systems>Input',
+  'OS>Systems>Input>Keyboard',
+  'OS>Systems>Input>Mouse',
+  'OS>Systems>Input>Shortcuts',
+  'OS>Systems>Input>Touch',
+  'OS>Systems>Metrics',
+  'OS>Systems>Multidevice',
+  'OS>Systems>Multidevice>Messages',
+  'OS>Systems>Multidevice>SmartLock',
+  'OS>Systems>Multidevice>Tethering',
+  'OS>Systems>Network>Bluetooth',
+  'OS>Systems>Network>Cellular',
+  'OS>Systems>Network>VPN',
+  'OS>Systems>Network>WiFi',
+  'OS>Systems>Printing',
+  'OS>Systems>Settings',
+  'OS>Systems>Spellcheck',
+  'OS>Systems>Update',
+  'OS>Systems>Wallpaper',
+  'OS>Systems>WirelessCharging',
+  'Platform>Apps>Feedback',
+  'UI>Shell>Networking',
+]);
+
+/**
+ * Determines if an issue is eligible for _CROS_CLOSURE_SLO.
+ * @param {Issue} issue
+ * @return {boolean}
+ * @private Only visible for testing.
+ */
+export const _isCrosClosureEligible = (issue) => {
+  // If at least one component applies, continue.
+  const hasEligibleComponent = issue.componentRefs.some(
+      (component) => CROS_ELIGIBLE_COMPONENT_PATHS.has(component.path));
+  if (!hasEligibleComponent) {
+    return false;
+  }
+
+  let priority = null;
+  let hasMilestone = false;
+  for (const fv of issue.fieldValues) {
+    if (fv.fieldRef.fieldName === 'Type') {
+      // These types don't apply.
+      if (fv.value === 'Feature' || fv.value === 'FLT-Launch' ||
+      fv.value === 'Postmortem-Followup' || fv.value === 'Design-Review') {
+        return false;
+      }
+    }
+    if (fv.fieldRef.fieldName === 'Pri') {
+      priority = fv.value;
+    }
+    if (fv.fieldRef.fieldName === 'M') {
+      hasMilestone = true;
+    }
+  }
+  // P1 issues with milestones don't apply.
+  if (priority === '1' && hasMilestone) {
+    return false;
+  }
+  // Issues with the ChromeOS_No_SLO label don't apply.
+  for (const labelRef of issue.labelRefs) {
+    if (labelRef.label === 'ChromeOS_No_SLO') {
+      return false;
+    }
+  }
+  return true;
+};
+
+/**
+ * Active SLO Rules.
+ * @const {Array<SloRule>}
+ */
+const SLO_RULES = [_CROS_CLOSURE_SLO];
+
+/**
+ * Determines the SloStatus for the given issue.
+ * @param {Issue} issue The issue to check.
+ * @return {SloStatus} The status of the issue, or null if no rules apply.
+ */
+export const determineSloStatus = (issue) => {
+  try {
+    for (const rule of SLO_RULES) {
+      const status = rule.statusFunction(issue);
+      if (status) {
+        return status;
+      }
+    }
+  } catch (error) {
+    // Don't bubble up any errors in SLO_RULES functions, which might sometimes
+    // be written/updated by client teams.
+  }
+  return null;
+};
diff --git a/static_src/elements/framework/mr-issue-slo/slo-rules.test.js b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
new file mode 100644
index 0000000..a48e5e2
--- /dev/null
+++ b/static_src/elements/framework/mr-issue-slo/slo-rules.test.js
@@ -0,0 +1,152 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {_CROS_CLOSURE_SLO, _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY,
+  _isCrosClosureEligible, SloCompletionStatus, determineSloStatus}
+  from './slo-rules.js';
+
+const P1_FIELD_VALUE = Object.freeze({
+  fieldRef: {
+    fieldId: 1,
+    fieldName: 'Pri',
+    type: 'ENUM_TYPE',
+  },
+  value: '1'});
+
+// TODO(crbug.com/monorail/7843): Separate testing of determineSloStatus from
+// testing of specific SLO Rules. Add testing for a rule that throws an error.
+describe('determineSloStatus', () => {
+  it('returns null for ineligible issues', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'Some>Other>Component'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns null for eligible issues without defined priority', () => {
+    const ineligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+    assert.isNull(determineSloStatus(ineligibleIssue));
+  });
+
+  it('returns SloStatus with target for incomplete eligible issues', () => {
+    const openedTimestamp = 1412362587;
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      openedTimestamp: openedTimestamp,
+      projectName: 'x',
+    };
+    const status = determineSloStatus(eligibleIssue);
+
+    const expectedTarget = new Date(openedTimestamp * 1000);
+    expectedTarget.setDate(
+        expectedTarget.getDate() + _CROS_CLOSURE_SLO_DAYS_BY_PRIORITY['1']);
+
+    assert.equal(status.target.valueOf(), expectedTarget.valueOf());
+    assert.equal(status.completion, SloCompletionStatus.INCOMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+
+  it('returns SloStatus without target for complete eligible issues', () => {
+    const eligibleIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [P1_FIELD_VALUE],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+      statusRef: {status: 'Closed', meansOpen: false},
+    };
+    const status = determineSloStatus(eligibleIssue);
+    assert.isNull(status.target);
+    assert.equal(status.completion, SloCompletionStatus.COMPLETE);
+    assert.equal(status.rule, _CROS_CLOSURE_SLO);
+  });
+});
+
+describe('_isCrosClosureEligible', () => {
+  let crosIssue;
+  beforeEach(() => {
+    crosIssue = {
+      componentRefs: [{path: 'OS>Systems>CrashReporting'}],
+      fieldValues: [],
+      labelRefs: [],
+      localId: 1,
+      projectName: 'x',
+    };
+  });
+
+  it('returns true when eligible', () => {
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true if at least one eligible component', () => {
+    crosIssue.componentRefs.push({path: 'Some>Other>Component'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for issues in wrong component', () => {
+    crosIssue.componentRefs = [{path: 'Some>Other>Component'}];
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Feature', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Feature'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for FLT-Launch', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'FLT-Launch'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Postmortem-Followup', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Postmortem-Followup'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for Design-Review', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'Type'}, value: 'Design-Review'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for other types', () => {
+    crosIssue.fieldValues.push(
+        {fieldRef: {fieldName: 'type'}, value: 'Any-Other-Type'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for p1 with milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'M'}, value: 'any'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns true for p1 without milestone', () => {
+    crosIssue.fieldValues.push(P1_FIELD_VALUE);
+    crosIssue.fieldValues.push({fieldRef: {fieldName: 'Other'}, value: 'any'});
+    assert.isTrue(_isCrosClosureEligible(crosIssue));
+  });
+
+  it('returns false for ChromeOS_No_SLO label', () => {
+    crosIssue.labelRefs.push({label: 'ChromeOS_No_SLO'});
+    assert.isFalse(_isCrosClosureEligible(crosIssue));
+  });
+});
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
new file mode 100644
index 0000000..9e932d6
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
@@ -0,0 +1,421 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+import Mousetrap from 'mousetrap';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+
+
+const SHORTCUT_DOC_GROUPS = [
+  {
+    title: 'Issue list',
+    keyDocs: [
+      {
+        keys: ['k', 'j'],
+        tip: 'up/down in the list',
+      },
+      {
+        keys: ['o', 'Enter'],
+        tip: 'open the current issue',
+      },
+      {
+        keys: ['Shift-O'],
+        tip: 'open issue in new tab',
+      },
+      {
+        keys: ['x'],
+        tip: 'select the current issue',
+      },
+    ],
+  },
+  {
+    title: 'Issue details',
+    keyDocs: [
+      {
+        keys: ['k', 'j'],
+        tip: 'prev/next issue in list',
+      },
+      {
+        keys: ['u'],
+        tip: 'up to issue list',
+      },
+      {
+        keys: ['r'],
+        tip: 'reply to current issue',
+      },
+      {
+        keys: ['Ctrl+Enter', '\u2318+Enter'],
+        tip: 'save issue reply (submit issue on issue filing page)',
+      },
+    ],
+  },
+  {
+    title: 'Anywhere',
+    keyDocs: [
+      {
+        keys: ['/'],
+        tip: 'focus on the issue search field',
+      },
+      {
+        keys: ['c'],
+        tip: 'compose a new issue',
+      },
+      {
+        keys: ['s'],
+        tip: 'star the current issue',
+      },
+      {
+        keys: ['?'],
+        tip: 'show this help dialog',
+      },
+    ],
+  },
+];
+
+/**
+ * `<mr-keystrokes>`
+ *
+ * Adds keybindings for Monorail, including a dialog for showing keystrokes.
+ * @extends {LitElement}
+ */
+export class MrKeystrokes extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      h2 {
+        margin-top: 0;
+        display: flex;
+        justify-content: space-between;
+        font-weight: normal;
+        border-bottom: 2px solid white;
+        font-size: var(--chops-large-font-size);
+        padding-bottom: 0.5em;
+      }
+      .close-button {
+        border: 0;
+        background: 0;
+        text-decoration: underline;
+        cursor: pointer;
+      }
+      .keyboard-help {
+        display: flex;
+        align-items: flex-start;
+        justify-content: space-around;
+        flex-direction: row;
+        border-bottom: 2px solid white;
+        flex-wrap: wrap;
+      }
+      .keyboard-help-section {
+        width: 32%;
+        display: grid;
+        grid-template-columns: 40% 60%;
+        padding-bottom: 1em;
+        grid-gap: 4px;
+        min-width: 300px;
+      }
+      .help-title {
+        font-weight: bold;
+      }
+      .key-shortcut {
+        text-align: right;
+        padding-right: 8px;
+        font-weight: bold;
+        margin: 2px;
+      }
+      kbd {
+        background: var(--chops-gray-200);
+        padding: 2px 8px;
+        border-radius: 2px;
+        min-width: 28px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog ?opened=${this._opened}>
+        <h2>
+          Issue tracker keyboard shortcuts
+          <button class="close-button" @click=${this._closeDialog}>
+            Close
+          </button>
+        </h2>
+        <div class="keyboard-help">
+          ${this._shortcutDocGroups.map((group) => html`
+            <div class="keyboard-help-section">
+              <span></span><span class="help-title">${group.title}</span>
+              ${group.keyDocs.map((keyDoc) => html`
+                <span class="key-shortcut">
+                  ${keyDoc.keys.map((key, i) => html`
+                    <kbd>${key}</kbd>
+                    <span
+                      class="key-separator"
+                      ?hidden=${i === keyDoc.keys.length - 1}
+                    > / </span>
+                  `)}:
+                </span>
+                <span class="key-tip">${keyDoc.tip}</span>
+              `)}
+            </div>
+          `)}
+        </div>
+        <p>
+          Note: Only signed in users can star issues or add comments, and
+          only project members can select issues for bulk edits.
+        </p>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issueEntryUrl: {type: String},
+      issueId: {type: Number},
+      _projectName: {type: String},
+      queryParams: {type: Object},
+      _fetchingIsStarred: {type: Boolean},
+      _isStarred: {type: Boolean},
+      _issuePermissions: {type: Array},
+      _opened: {type: Boolean},
+      _shortcutDocGroups: {type: Array},
+      _starringIssues: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this._shortcutDocGroups = SHORTCUT_DOC_GROUPS;
+    this._opened = false;
+    this._starringIssues = new Map();
+    this._projectName = undefined;
+    this._issuePermissions = [];
+    this.issueId = undefined;
+    this.queryParams = undefined;
+    this.issueEntryUrl = undefined;
+
+    this._page = page;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._projectName = projectV0.viewedProjectName(state);
+    this._issuePermissions = issueV0.permissions(state);
+
+    const starredIssues = issueV0.starredIssues(state);
+    this._isStarred = starredIssues.has(issueRefToString(this._issueRef));
+    this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+    this._starringIssues = issueV0.starringIssues(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_projectName') ||
+        changedProperties.has('issueEntryUrl')) {
+      this._bindProjectKeys(this._projectName, this.issueEntryUrl);
+    }
+    if (changedProperties.has('_projectName') ||
+        changedProperties.has('issueId') ||
+        changedProperties.has('_issuePermissions') ||
+        changedProperties.has('queryParams')) {
+      this._bindIssueDetailKeys(this._projectName, this.issueId,
+          this._issuePermissions, this.queryParams);
+    }
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    this._unbindProjectKeys();
+    this._unbindIssueDetailKeys();
+  }
+
+  /** @private */
+  get _isStarring() {
+    const requestKey = issueRefToString(this._issueRef);
+    if (this._starringIssues.has(requestKey)) {
+      return this._starringIssues.get(requestKey).requesting;
+    }
+    return false;
+  }
+
+  /** @private */
+  get _issueRef() {
+    return {
+      projectName: this._projectName,
+      localId: this.issueId,
+    };
+  }
+
+  /** @private */
+  _toggleDialog() {
+    this._opened = !this._opened;
+  }
+
+  /** @private */
+  _openDialog() {
+    this._opened = true;
+  }
+
+  /** @private */
+  _closeDialog() {
+    this._opened = false;
+  }
+
+  /**
+   * @param {string} projectName
+   * @param {string} issueEntryUrl
+   * @fires CustomEvent#focus-search
+   * @private
+   */
+  _bindProjectKeys(projectName, issueEntryUrl) {
+    this._unbindProjectKeys();
+
+    if (!projectName) return;
+
+    issueEntryUrl = issueEntryUrl || `/p/${projectName}/issues/entry`;
+
+    Mousetrap.bind('/', (e) => {
+      e.preventDefault();
+      // Focus search.
+      this.dispatchEvent(new CustomEvent('focus-search',
+          {composed: true, bubbles: true}));
+    });
+
+    Mousetrap.bind('?', () => {
+      // Toggle key help.
+      this._toggleDialog();
+    });
+
+    Mousetrap.bind('esc', () => {
+      // Close key help dialog if open.
+      this._closeDialog();
+    });
+
+    Mousetrap.bind('c', () => this._page(issueEntryUrl));
+  }
+
+  /** @private */
+  _unbindProjectKeys() {
+    Mousetrap.unbind('/');
+    Mousetrap.unbind('?');
+    Mousetrap.unbind('esc');
+    Mousetrap.unbind('c');
+  }
+
+  /**
+   * @param {string} projectName
+   * @param {string} issueId
+   * @param {Array<string>} issuePermissions
+   * @param {Object} queryParams
+   * @private
+   */
+  _bindIssueDetailKeys(projectName, issueId, issuePermissions, queryParams) {
+    this._unbindIssueDetailKeys();
+
+    if (!projectName || !issueId) return;
+
+    const projectHomeUrl = `/p/${projectName}`;
+
+    const queryString = qs.stringify(queryParams);
+
+    // TODO(zhangtiff): Update these links when mr-flipper's async request
+    // finishes.
+    const prevUrl = `${projectHomeUrl}/issues/detail/previous?${queryString}`;
+    const nextUrl = `${projectHomeUrl}/issues/detail/next?${queryString}`;
+    const canComment = issuePermissions.includes('addissuecomment');
+    const canStar = issuePermissions.includes('setstar');
+
+    // Previous issue in list.
+    Mousetrap.bind('k', () => this._page(prevUrl));
+
+    // Next issue in list.
+    Mousetrap.bind('j', () => this._page(nextUrl));
+
+    // Back to list.
+    Mousetrap.bind('u', () => this._backToList());
+
+    if (canComment) {
+      // Navigate to the form to make changes.
+      Mousetrap.bind('r', () => this._jumpToEditForm());
+    }
+
+    if (canStar) {
+      Mousetrap.bind('s', () => this._starIssue());
+    }
+  }
+
+  /**
+   * Navigates back to the issue list page.
+   * @private
+   */
+  _backToList() {
+    const params = {...this.queryParams,
+      cursor: issueRefToString(this._issueRef)};
+    const queryString = qs.stringify(params);
+    if (params['hotlist_id']) {
+      // Because hotlist URLs require a server look up to be built from a
+      // hotlist ID, we have to route the request through an extra endpoint
+      // that redirects to the appropriate hotlist.
+      const listUrl = `/p/${this._projectName}/issues/detail/list?${
+        queryString}`;
+      this._page(listUrl);
+
+      // TODO(crbug.com/monorail/6341): Switch to using the new hotlist URL once
+      // hotlists have migrated.
+      // this._page(`/hotlists/${params['hotlist_id']}`);
+    } else {
+      delete params.id;
+      const listUrl = `/p/${this._projectName}/issues/list?${queryString}`;
+      this._page(listUrl);
+    }
+  }
+
+  /**
+   * Scrolls the user to the issue editing form when they press
+   * the 'r' key.
+   * @private
+   */
+  _jumpToEditForm() {
+    // Force a hash change even the hash is already makechanges.
+    if (window.location.hash.toLowerCase() === '#makechanges') {
+      window.location.hash = ' ';
+    }
+    window.location.hash = '#makechanges';
+  }
+
+  /**
+   * Stars the current issue the user is viewing on the issue detail page.
+   * @private
+   */
+  _starIssue() {
+    if (!this._fetchingIsStarred && !this._isStarring) {
+      const newIsStarred = !this._isStarred;
+
+      store.dispatch(issueV0.star(this._issueRef, newIsStarred));
+    }
+  }
+
+
+  /** @private */
+  _unbindIssueDetailKeys() {
+    Mousetrap.unbind('k');
+    Mousetrap.unbind('j');
+    Mousetrap.unbind('u');
+    Mousetrap.unbind('r');
+    Mousetrap.unbind('s');
+  }
+}
+
+customElements.define('mr-keystrokes', MrKeystrokes);
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
new file mode 100644
index 0000000..0d7468f
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
@@ -0,0 +1,194 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrKeystrokes} from './mr-keystrokes.js';
+import Mousetrap from 'mousetrap';
+
+import {issueRefToString} from 'shared/convertersV0.js';
+
+/** @type {MrKeystrokes} */
+let element;
+
+describe('mr-keystrokes', () => {
+  beforeEach(() => {
+    element = /** @type {MrKeystrokes} */ (
+      document.createElement('mr-keystrokes'));
+    document.body.appendChild(element);
+
+    element._projectName = 'proj';
+    element.issueId = 11;
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrKeystrokes);
+  });
+
+  it('tracks if the issue is currently starring', async () => {
+    await element.updateComplete;
+    assert.isFalse(element._isStarring);
+
+    const issueRefStr = issueRefToString(element._issueRef);
+    element._starringIssues.set(issueRefStr, {requesting: true});
+    assert.isTrue(element._isStarring);
+  });
+
+  it('? and esc open and close dialog', async () => {
+    await element.updateComplete;
+    assert.isFalse(element._opened);
+
+    Mousetrap.trigger('?');
+
+    await element.updateComplete;
+    assert.isTrue(element._opened);
+
+    Mousetrap.trigger('esc');
+
+    await element.updateComplete;
+    assert.isFalse(element._opened);
+  });
+
+  describe('issue detail keys', () => {
+    beforeEach(() => {
+      sinon.stub(element, '_page');
+      sinon.stub(element, '_jumpToEditForm');
+      sinon.stub(element, '_starIssue');
+    });
+
+    it('not bound when _projectName not set', async () => {
+      element._projectName = '';
+      element.issueId = 1;
+
+      await element.updateComplete;
+
+      // Navigation hot keys.
+      Mousetrap.trigger('k');
+      Mousetrap.trigger('j');
+      Mousetrap.trigger('u');
+      sinon.assert.notCalled(element._page);
+
+      // Jump to edit form hot key.
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+
+      // Star issue hotkey.
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('not bound when issueId not set', async () => {
+      element._projectName = 'proj';
+      element.issueId = 0;
+
+      await element.updateComplete;
+
+      // Navigation hot keys.
+      Mousetrap.trigger('k');
+      Mousetrap.trigger('j');
+      Mousetrap.trigger('u');
+      sinon.assert.notCalled(element._page);
+
+      // Jump to edit form hot key.
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+
+      // Star issue hotkey.
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('binds j and k navigation hot keys', async () => {
+      element.queryParams = {q: 'something'};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('k');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/previous?q=something');
+
+      Mousetrap.trigger('j');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/next?q=something');
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/list?q=something&cursor=proj%3A11');
+    });
+
+    it('u key navigates back to issue list wth cursor set', async () => {
+      element.queryParams = {q: 'something'};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/list?q=something&cursor=proj%3A11');
+    });
+
+    it('u key navigates back to hotlist when hotlist_id set', async () => {
+      element.queryParams = {hotlist_id: 1234};
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('u');
+      sinon.assert.calledWith(element._page,
+          '/p/proj/issues/detail/list?hotlist_id=1234&cursor=proj%3A11');
+    });
+
+    it('does not star when user does not have permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('does star when user has permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = ['setstar'];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.calledOnce(element._starIssue);
+    });
+
+    it('does not star when user does not have permission', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('s');
+      sinon.assert.notCalled(element._starIssue);
+    });
+
+    it('does not jump to edit form when user cannot comment', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = [];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('r');
+      sinon.assert.notCalled(element._jumpToEditForm);
+    });
+
+    it('does jump to edit form when user can comment', async () => {
+      element.queryParams = {q: 'something'};
+      element._issuePermissions = ['addissuecomment'];
+
+      await element.updateComplete;
+
+      Mousetrap.trigger('r');
+      sinon.assert.calledOnce(element._jumpToEditForm);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js
new file mode 100644
index 0000000..a5f9d7a
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.js
@@ -0,0 +1,94 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-toggle/chops-toggle.js';
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * `<mr-pref-toggle>`
+ *
+ * Toggle button for any user pref, including code font and
+ * rendering markdown.  For our purposes, pressing it causes
+ * issue description and comment text to switch either to
+ * monospace font or to render in markdown and the setting
+ * is saved in the user's preferences.
+ */
+export class MrPrefToggle extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+        <chops-toggle
+          ?checked=${this._checked}
+          ?disabled=${this._prefsInFlight}
+          @checked-change=${this._togglePref}
+          title=${this.title}
+        >${this.label}</chops-toggle>
+      `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      prefs: {type: Object},
+      userDisplayName: {type: String},
+      initialValue: {type: Boolean},
+      _prefsInFlight: {type: Boolean},
+      label: {type: String},
+      title: {type: String},
+      prefName: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.prefs = userV0.prefs(state);
+    this._prefsInFlight = userV0.requests(state).fetchPrefs.requesting ||
+      userV0.requests(state).setPrefs.requesting;
+    this._projectName = projectV0.viewedProjectName(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.initialValue = false;
+    this.userDisplayName = '';
+    this.label = '';
+    this.title = '';
+    this.prefName = '';
+    this._projectName = '';
+  }
+
+  // Used by the legacy EZT page to interact with Redux.
+  fetchPrefs() {
+    store.dispatch(userV0.fetchPrefs());
+  }
+
+  get _checked() {
+    const {prefs, initialValue} = this;
+    if (prefs && prefs.has(this.prefName)) return prefs.get(this.prefName);
+    return initialValue;
+  }
+
+  /**
+   * Toggles the code font in response to the user activating the button.
+   * @param {Event} e
+   * @fires CustomEvent#font-toggle
+   * @private
+   */
+  _togglePref(e) {
+    const checked = e.detail.checked;
+    this.dispatchEvent(new CustomEvent('font-toggle', {detail: {checked}}));
+
+    const newPrefs = [{name: this.prefName, value: '' + checked}];
+    store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+
+    logEvent('mr-pref-toggle', `${this.prefName}: ${checked}`, this._projectName);
+  }
+}
+customElements.define('mr-pref-toggle', MrPrefToggle);
diff --git a/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
new file mode 100644
index 0000000..b6dbb41
--- /dev/null
+++ b/static_src/elements/framework/mr-pref-toggle/mr-pref-toggle.test.js
@@ -0,0 +1,86 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'sinon';
+import {assert} from 'chai';
+import {MrPrefToggle} from './mr-pref-toggle.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-pref-toggle', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-pref-toggle');
+    element.label = 'Code';
+    element.title = 'Code font';
+    element.prefName = 'code_font';
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+    window.ga = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrPrefToggle);
+  });
+
+  it('toggling does not save when user is not logged in', async () => {
+    element.userDisplayName = undefined;
+    element.prefs = new Map([]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+    chopsToggle.click();
+    await element.updateComplete;
+
+    sinon.assert.notCalled(prpcClient.call);
+
+    assert.isTrue(element.prefs.get('code_font'));
+  });
+
+  it('toggling to true saves result', async () => {
+    element.userDisplayName = 'test@example.com';
+    element.prefs = new Map([['code_font', false]]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+    chopsToggle.click(); // Toggle it on.
+    await element.updateComplete;
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Users',
+        'SetUserPrefs',
+        {prefs: [{name: 'code_font', value: 'true'}]});
+
+    assert.isTrue(element.prefs.get('code_font'));
+  });
+
+  it('toggling to false saves result', async () => {
+    element.userDisplayName = 'test@example.com';
+    element.prefs = new Map([['code_font', true]]);
+
+    await element.updateComplete;
+
+    const chopsToggle = element.shadowRoot.querySelector('chops-toggle');
+
+    chopsToggle.click(); // Toggle it off.
+    await element.updateComplete;
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Users',
+        'SetUserPrefs',
+        {prefs: [{name: 'code_font', value: 'false'}]});
+
+    assert.isFalse(element.prefs.get('code_font'));
+  });
+});
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
new file mode 100644
index 0000000..2a98a5c
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.js
@@ -0,0 +1,75 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+export class MrSiteBanner extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host([hidden]) {
+        display: none;
+      }
+      :host {
+        display: block;
+        font-weight: bold;
+        color: var(--chops-field-error-color);
+        background: var(--chops-orange-50);
+        padding: 5px;
+        text-align: center;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${this.bannerMessage}
+      ${this.bannerTime ? html`
+        <chops-timestamp
+          .timestamp=${this.bannerTime}
+        ></chops-timestamp>
+      ` : ''}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+      bannerMessage: {type: String},
+      bannerTime: {type: Number},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.bannerMessage = '';
+    this.bannerTime = 0;
+    this.hidden = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.bannerMessage = sitewide.bannerMessage(state);
+    this.bannerTime = sitewide.bannerTime(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('bannerMessage')) {
+      this.hidden = !this.bannerMessage;
+    }
+  }
+}
+
+customElements.define('mr-site-banner', MrSiteBanner);
diff --git a/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js b/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js
new file mode 100644
index 0000000..527b942
--- /dev/null
+++ b/static_src/elements/framework/mr-site-banner/mr-site-banner.test.js
@@ -0,0 +1,56 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {FORMATTER}
+  from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {MrSiteBanner} from './mr-site-banner.js';
+
+
+let element;
+
+describe('mr-site-banner', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-site-banner');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrSiteBanner);
+  });
+
+  it('displays a banner message', async () => {
+    element.bannerMessage = 'Message';
+    await element.updateComplete;
+    assert.equal(element.shadowRoot.textContent.trim(), 'Message');
+    assert.isNull(element.shadowRoot.querySelector('chops-timestamp'));
+  });
+
+  it('displays the banner timestamp', async () => {
+    const timestamp = 1560450600;
+
+    element.bannerMessage = 'Message';
+    element.bannerTime = timestamp;
+    await element.updateComplete;
+
+    const chopsTimestamp = element.shadowRoot.querySelector('chops-timestamp');
+
+    // The formatted date strings differ based on time zone and browser, so we
+    // can't use static strings for testing. We can't stub out the format method
+    // because it's native code and can't be modified. So just use the FORMATTER
+    // object.
+    assert.include(
+        chopsTimestamp.shadowRoot.textContent,
+        FORMATTER.format(new Date(timestamp * 1000)));
+  });
+
+  it('hides when there is no banner message', async () => {
+    await element.updateComplete;
+    assert.isTrue(element.hidden);
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.js b/static_src/elements/framework/mr-star/mr-issue-star.js
new file mode 100644
index 0000000..5255820
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.js
@@ -0,0 +1,110 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import {MrStar} from './mr-star.js';
+
+
+/**
+ * `<mr-issue-star>`
+ *
+ * A button for starring an issue.
+ *
+ */
+export class MrIssueStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * A reference to the issue that the star button interacts with.
+       */
+      issueRef: {type: Object},
+      /**
+       * Whether the issue is starred (used for accessing easily).
+       */
+      _starredIssues: {type: Set},
+      /**
+       * Whether the issue's star state is being fetched. This is taken from
+       * the component's parent, which is expected to handle fetching initial
+       * star state for an issue.
+       */
+      _fetchingIsStarred: {type: Boolean},
+      /**
+       * A Map of all issues currently being starred.
+       */
+      _starringIssues: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /**
+     * @type {IssueRef}
+     */
+    this.issueRef = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    // TODO(crbug.com/monorail/7374): Remove references to issueV0 in
+    // <mr-star>.
+    this._starringIssues = issueV0.starringIssues(state);
+    this._starredIssues = issueV0.starredIssues(state);
+    this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+  }
+
+  /** @override */
+  get type() {
+    return 'issue';
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = issueRefToString(this.issueRef);
+    if (this._starringIssues.has(requestKey)) {
+      return this._starringIssues.get(requestKey).requesting;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingIsStarred || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return this._starredIssues.has(issueRefToString(this.issueRef));
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(issueV0.star(this.issueRef, true));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(issueV0.star(this.issueRef, false));
+  }
+}
+
+customElements.define('mr-issue-star', MrIssueStar);
diff --git a/static_src/elements/framework/mr-star/mr-issue-star.test.js b/static_src/elements/framework/mr-star/mr-issue-star.test.js
new file mode 100644
index 0000000..bb618f7
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-issue-star.test.js
@@ -0,0 +1,85 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrIssueStar} from './mr-issue-star.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+import sinon from 'sinon';
+
+
+let element;
+
+describe('mr-issue-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-issue-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueStar);
+  });
+
+  it('starring logins user when user is not logged in', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('_isStarring true only when issue ref is being starred', async () => {
+    element._starringIssues = new Map([['chromium:22', {requesting: true}]]);
+    element.issueRef = {projectName: 'chromium', localId: 5};
+
+    assert.isFalse(element._isStarring);
+
+    element.issueRef = {projectName: 'chromium', localId: 22};
+
+    assert.isTrue(element._isStarring);
+
+    element._starringIssues = new Map([['chromium:22', {requesting: false}]]);
+
+    assert.isFalse(element._isStarring);
+  });
+
+  it('starring is disabled when _isStarring true', () => {
+    element._currentUserName = 'users/1234';
+    sinon.stub(element, '_isStarring').get(() => true);
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('starring is disabled when _fetchingIsStarred true', () => {
+    element._currentUserName = 'users/1234';
+    element._fetchingIsStarred = true;
+
+    assert.isFalse(element._starringEnabled);
+  });
+
+  it('_starredIssues changes displayed icon', async () => {
+    element.issueRef = {projectName: 'proj', localId: 1};
+
+    element._starredIssues = new Set([issueRefToString(element.issueRef)]);
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._starredIssues = new Set();
+
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-project-star.js b/static_src/elements/framework/mr-star/mr-project-star.js
new file mode 100644
index 0000000..14b2c73
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.js
@@ -0,0 +1,148 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {connectStore, store} from 'reducers/base.js';
+import * as users from 'reducers/users.js';
+import {stars} from 'reducers/stars.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import {MrStar} from './mr-star.js';
+import 'shared/typedef.js';
+
+
+/**
+ * `<mr-project-star>`
+ *
+ * A button for starring a project.
+ *
+ */
+export class MrProjectStar extends connectStore(MrStar) {
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Resource name of the project being starred.
+       */
+      name: {type: String},
+      /**
+       * List of all stars, indexed by star name.
+       */
+      _stars: {type: Object},
+      /**
+       * Whether project stars are currently being fetched.
+       */
+      _fetchingStars: {type: Boolean},
+      /**
+       * Request data for projects currently being starred.
+       */
+      _starringProjects: {type: Object},
+      /**
+       * Request data for projects currently being unstarred.
+       */
+      _unstarringProjects: {type: Object},
+      /**
+       * The currently logged in user. Required to determine if the user can
+       * star.
+       */
+      _currentUserName: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {string} */
+    this.name = undefined;
+
+    /** @type {boolean} */
+    this._fetchingStars = false;
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._starringProjects = {};
+
+    /** @type {Object<ProjectStarName, ReduxRequestState>} */
+    this._unstarringProjects = {};
+
+    /** @type {Object<StarName, Star>} */
+    this._stars = {};
+
+    /** @type {string} */
+    this._currentUserName = undefined;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this._currentUserName = users.currentUserName(state);
+
+    this._stars = stars.byName(state);
+
+    const requests = stars.requests(state);
+    this._fetchingStars = requests.listProjects.requesting;
+    this._starringProjects = requests.starProject;
+    this._unstarringProjects = requests.unstarProject;
+  }
+
+  /** @override */
+  get type() {
+    return 'project';
+  }
+
+  /**
+   * @return {string} The resource name of the ProjectStar.
+   */
+  get _starName() {
+    return projectAndUserToStarName(this.name, this._currentUserName);
+  }
+
+  /**
+   * @return {ProjectStar} The ProjectStar object for the referenced project,
+   *   if one exists.
+   */
+  get _projectStar() {
+    const name = this._starName;
+    if (!(name in this._stars)) return {};
+    return this._stars[name];
+  }
+
+  /**
+   * @return {boolean} Whether there's an in-flight star request.
+   */
+  get _isStarring() {
+    const requestKey = this._starName;
+    if (requestKey in this._starringProjects &&
+        this._starringProjects[requestKey].requesting) {
+      return true;
+    }
+    if (requestKey in this._unstarringProjects &&
+        this._unstarringProjects[requestKey].requesting) {
+      return true;
+    }
+    return false;
+  }
+
+  /** @override */
+  get isLoggedIn() {
+    return !!this._currentUserName;
+  }
+
+  /** @override */
+  get requesting() {
+    return this._fetchingStars || this._isStarring;
+  }
+
+  /** @override */
+  get isStarred() {
+    return !!(this._projectStar && this._projectStar.name);
+  }
+
+  /** @override */
+  star() {
+    store.dispatch(stars.starProject(this.name, this._currentUserName));
+  }
+
+  /** @override */
+  unstar() {
+    store.dispatch(stars.unstarProject(this.name, this._currentUserName));
+  }
+}
+
+customElements.define('mr-project-star', MrProjectStar);
diff --git a/static_src/elements/framework/mr-star/mr-project-star.test.js b/static_src/elements/framework/mr-star/mr-project-star.test.js
new file mode 100644
index 0000000..6afd982
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-project-star.test.js
@@ -0,0 +1,181 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrProjectStar} from './mr-project-star.js';
+import {stars} from 'reducers/stars.js';
+
+let element;
+
+describe('mr-project-star (disconnected)', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-project-star');
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+    sinon.spy(stars, 'starProject');
+    sinon.spy(stars, 'unstarProject');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    stars.starProject.restore();
+    stars.unstarProject.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrProjectStar);
+  });
+
+  it('clicking on star when logged out logs in user', async () => {
+    element._currentUserName = undefined;
+    sinon.stub(element, 'login');
+
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    star.click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  it('star dispatches star request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.star();
+
+    sinon.assert.calledWith(stars.starProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  it('unstar dispatches unstar request', () => {
+    element._currentUserName = 'users/1234';
+    element.name = 'projects/monorail';
+
+    element.unstar();
+
+    sinon.assert.calledWith(stars.unstarProject,
+        'projects/monorail', 'users/1234');
+  });
+
+  describe('isStarred', () => {
+    beforeEach(() => {
+      element._stars = {
+        'users/1234/projectStars/monorail':
+            {name: 'users/1234/projectStars/monorail'},
+        'users/5678/projectStars/chromium':
+            {name: 'users/5678/projectStars/chromium'},
+      };
+    });
+
+    it('false when no data', () => {
+      element._stars = {};
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when user is not logged in', () => {
+      element._currentUserName = '';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('false when project is not starred', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/chromium';
+
+      assert.isFalse(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/monorail';
+
+      assert.isFalse(element.isStarred);
+    });
+
+    it('true when user has starred project', () => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+
+      assert.isTrue(element.isStarred);
+
+      element._currentUserName = 'users/5678';
+      element.name = 'projects/chromium';
+
+      assert.isTrue(element.isStarred);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    beforeEach(() => {
+      element._currentUserName = 'users/1234';
+      element.name = 'projects/monorail';
+    });
+
+    it('disabled when user is not logged in', () => {
+      element._currentUserName = '';
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when stars are being fetched', () => {
+      element._fetchingStars = true;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is starring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+      element._unstarringProjects = {};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user is unstarring project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects =
+          {'users/1234/projectStars/monorail': {requesting: true}};
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('enabled when user is starring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when user is unstarring an unrelated project', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {
+        'users/1234/projectStars/chromium': {requesting: true},
+        'users/1234/projectStars/monorail': {requesting: false},
+      };
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('enabled when no in-flight requests', () => {
+      element._fetchingStars = false;
+      element._starringProjects = {};
+      element._unstarringProjects = {};
+
+      assert.isTrue(element._starringEnabled);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-star/mr-star.js b/static_src/elements/framework/mr-star/mr-star.js
new file mode 100644
index 0000000..fe509be
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.js
@@ -0,0 +1,235 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+/**
+ * `<mr-star>`
+ *
+ * A button for starring a resource. Does not directly integrate with app
+ * state. Subclasses by <mr-issue-star> and <mr-project-star>, which add
+ * resource-specific logic for state management.
+ *
+ */
+export class MrStar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        --mr-star-size: var(--chops-icon-font-size);
+      }
+      button {
+        background: none;
+        border: none;
+        cursor: pointer;
+        padding: 0;
+        margin: 0;
+        display: flex;
+        align-items: center;
+      }
+      /* TODO(crbug.com/monorail/8008): Add nicer looking loading style. */
+      button.loading {
+        opacity: 0.5;
+        cursor: default;
+      }
+      i.material-icons {
+        font-size: var(--mr-star-size);
+        color: var(--chops-primary-icon-color);
+      }
+      i.material-icons.starred {
+        color: var(--chops-primary-accent-color);
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const {isStarred} = this;
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button class="star-button"
+        @click=${this._loginOrStar}
+        title=${this._starToolTip}
+        role="checkbox"
+        aria-checked=${isStarred ? 'true' : 'false'}
+        class=${this.requesting ? 'loading' : ''}
+      >
+        ${isStarred ? html`
+          <i class="material-icons starred" role="presentation">
+            star
+          </i>
+        `: html`
+          <i class="material-icons" role="presentation">
+            star_border
+          </i>
+        `}
+      </button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Note: In order for re-renders to happen based on the getters defined
+       * in this class, those getters must have values based on properties.
+       * Subclasses of <mr-star> are not expected to inherit <mr-star>'s
+       * properties, but they should make sure their getter implementations
+       * are also backed by properties.
+       */
+      _isStarred: {type: Boolean},
+      _isLoggedIn: {type: Boolean},
+      _canStar: {type: Boolean},
+      _requesting: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    /**
+     * @type {boolean} Whether the user has starred the resource or not.
+     */
+    this._isStarred = false;
+
+    /**
+     * @type {boolean} If the user is logged in.
+     */
+    this._isLoggedIn = false;
+
+    /**
+     * @return {boolean} Whether the user has permission to star the star.
+     */
+    this._canStar = true;
+
+    /**
+     * @return {boolean} Whether there's an in-flight request to star
+     * the resource.
+     */
+    this._requesting = false;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Prevent clicks on this element from causing navigation if the element
+    // is embedded inside a link.
+    this.addEventListener('click', (e) => e.preventDefault());
+  }
+
+  /**
+   * @return {boolean} If the user is logged in.
+   */
+  get isLoggedIn() {
+    return this._isLoggedIn;
+  }
+
+  /**
+   * @return {boolean} If there's an in-flight request that might affect the
+   *   star's data.
+   */
+  get requesting() {
+    return this._requesting;
+  }
+
+  /**
+   * @return {boolean} Whether the resource is starred or not.
+   */
+  get isStarred() {
+    return this._isStarred;
+  }
+
+  /**
+   * @return {boolean} If the user has permission to star.
+   */
+  get canStar() {
+    return this._canStar;
+  }
+
+  /**
+   * @return {boolean}
+   */
+  get _starringEnabled() {
+    return this.isLoggedIn && this.canStar && !this.requesting;
+  }
+
+  /**
+   * @return {string} The name of the resource kind being starred.
+   * ie: issue, project, etc.
+   */
+  get type() {
+    return 'resource';
+  }
+
+  /**
+   * @return {string} the title to display on the star button.
+   */
+  get _starToolTip() {
+    if (!this.isLoggedIn) {
+      return `Login to star this ${this.type}.`;
+    }
+    if (!this.canStar) {
+      return `You don't have permission to star this ${this.type}.`;
+    }
+    if (this.requesting) {
+      return `Loading star state for this ${this.type}.`;
+    }
+    return `${this.isStarred ? 'Unstar' : 'Star'} this ${this.type}.`;
+  }
+
+  /**
+   * Logins the user if they're not logged in. Otherwise, stars or
+   * unstars the resource based on star state.
+   */
+  _loginOrStar() {
+    if (!this.isLoggedIn) {
+      this.login();
+    } else {
+      this.toggleStar();
+    }
+  }
+
+  /**
+   * Logs in the user.
+   */
+  login() {
+    // TODO(crbug.com/monorail/6073): Replace this logic with a function call
+    // when moving authentication to frontend.
+    // HACK: In our current login implementation, login URLs can only be
+    // generated by the backend which makes piping a login URL into a component
+    // a <mr-star> complex. To get around this, we're using the
+    // legacy window.CS_env infrastructure.
+    window.location.href = window.CS_env.login_url;
+  }
+
+  /**
+   * Stars or unstars the resource based on the user's interaction.
+   */
+  toggleStar() {
+    if (!this._starringEnabled) return;
+    if (this.isStarred) {
+      this.unstar();
+    } else {
+      this.star();
+    }
+  }
+
+  /**
+   * Stars the given resource. To be implemented by a subclass.
+   */
+  star() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * Unstars the given resource. To be implemented by a subclass.
+   */
+  unstar() {
+    throw new Error('Method not implemented.');
+  }
+}
+
+customElements.define('mr-star', MrStar);
diff --git a/static_src/elements/framework/mr-star/mr-star.test.js b/static_src/elements/framework/mr-star/mr-star.test.js
new file mode 100644
index 0000000..4db7877
--- /dev/null
+++ b/static_src/elements/framework/mr-star/mr-star.test.js
@@ -0,0 +1,302 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import sinon from 'sinon';
+import {assert} from 'chai';
+
+import {MrStar} from './mr-star.js';
+
+let element;
+
+describe('mr-star', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-star');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrStar);
+  });
+
+  it('unimplemented methods throw errors', () => {
+    assert.throws(element.star, 'Method not implemented.');
+    assert.throws(element.unstar, 'Method not implemented.');
+  });
+
+  describe('clicking star toggles star state', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+    });
+
+    it('unstarred star', async () => {
+      element._isStarred = false;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('starred star', async () => {
+      element._isStarred = true;
+
+      await element.updateComplete;
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.notCalled(element.unstar);
+
+      element.shadowRoot.querySelector('button').click();
+
+      sinon.assert.notCalled(element.star);
+      sinon.assert.calledOnce(element.unstar);
+    });
+  });
+
+  it('clicking while logged out logs you in', async () => {
+    sinon.stub(element, 'login');
+    element._isLoggedIn = false;
+    element._canStar = true;
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.login);
+
+    element.shadowRoot.querySelector('button').click();
+
+    sinon.assert.calledOnce(element.login);
+  });
+
+  describe('toggleStar', () => {
+    beforeEach(() => {
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    it('stars when unstarred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.star);
+      sinon.assert.notCalled(element.unstar);
+    });
+
+    it('unstars when starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.calledOnce(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user is not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = true;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when user does not have permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+
+    it('does nothing when stars are being fetched', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      element.toggleStar();
+
+      sinon.assert.notCalled(element.unstar);
+      sinon.assert.notCalled(element.star);
+    });
+  });
+
+  describe('_starringEnabled', () => {
+    it('enabled when user is logged in and has permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isTrue(element._starringEnabled);
+    });
+
+    it('disabled when user is logged out', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      element._isStarred = false;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when user has no permission', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      element._isStarred = true;
+      element._requesting = false;
+
+      assert.isFalse(element._starringEnabled);
+    });
+
+    it('disabled when requesting star', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      element._requesting = true;
+
+      assert.isFalse(element._starringEnabled);
+    });
+  });
+
+  it('loading state shown when requesting', async () => {
+    element._requesting = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+
+    assert.isTrue(star.classList.contains('loading'));
+
+    element._requesting = false;
+    await element.updateComplete;
+
+    assert.isFalse(star.classList.contains('loading'));
+  });
+
+  it('isStarred changes displayed icon', async () => {
+    element._isStarred = true;
+    await element.updateComplete;
+
+    const star = element.shadowRoot.querySelector('button');
+    assert.equal(star.textContent.trim(), 'star');
+
+    element._isStarred = false;
+    await element.updateComplete;
+
+    assert.equal(star.textContent.trim(), 'star_border');
+  });
+
+  describe('mr-star nested inside a link', () => {
+    let parent;
+    let oldHash;
+
+    beforeEach(() => {
+      parent = document.createElement('a');
+      parent.setAttribute('href', '#test-hash');
+      parent.appendChild(element);
+
+      oldHash = window.location.hash;
+
+      sinon.stub(element, 'star');
+      sinon.stub(element, 'unstar');
+    });
+
+    afterEach(() => {
+      window.location.hash = oldHash;
+    });
+
+    it('clicking to star does not cause navigation', async () => {
+      sinon.spy(element, 'toggleStar');
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+      sinon.assert.calledOnce(element.toggleStar);
+    });
+
+    it('clicking on disabled star does not cause navigation', async () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      await element.updateComplete;
+
+      element.shadowRoot.querySelector('button').click();
+
+      assert.notEqual(window.location.hash, '#test-hash');
+    });
+
+    it('clicking on link still navigates', async () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      await element.updateComplete;
+
+      parent.click();
+
+      assert.equal(window.location.hash, '#test-hash');
+    });
+  });
+
+  describe('_starToolTip', () => {
+    it('not logged in', () => {
+      element._isLoggedIn = false;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `Login to star this resource.`);
+    });
+
+    it('no permission to star', () => {
+      element._isLoggedIn = true;
+      element._canStar = false;
+      assert.equal(element._starToolTip,
+          `You don't have permission to star this resource.`);
+    });
+
+    it('star is loading', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._requesting = true;
+      assert.equal(element._starToolTip,
+          `Loading star state for this resource.`);
+    });
+
+    it('issue is not starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = false;
+      assert.equal(element._starToolTip,
+          `Star this resource.`);
+    });
+
+    it('issue is starred', () => {
+      element._isLoggedIn = true;
+      element._canStar = true;
+      element._isStarred = true;
+      assert.equal(element._starToolTip,
+          `Unstar this resource.`);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.js b/static_src/elements/framework/mr-tabs/mr-tabs.js
new file mode 100644
index 0000000..d14688e
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.js
@@ -0,0 +1,99 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import 'shared/typedef.js';
+
+/**
+ * `<mr-tabs>`
+ *
+ * A Material Design tabs strip. https://material.io/components/tabs/
+ *
+ */
+export class MrTabs extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      ul {
+        display: flex;
+        list-style: none;
+        margin: 0;
+        padding: 0;
+      }
+      li {
+        color: var(--chops-choice-color);
+      }
+      li.selected {
+        color: var(--chops-active-choice-color);
+      }
+      li:hover {
+        background: var(--chops-primary-accent-bg);
+        color: var(--chops-active-choice-color);
+      }
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        display: inline-block;
+        line-height: 38px;
+        padding: 0 24px;
+      }
+      li.selected a {
+        border-bottom: solid 2px;
+      }
+      i.material-icons {
+        vertical-align: middle;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <ul>
+        ${this.items.map(this._renderTab.bind(this))}
+      </ul>
+    `;
+  }
+
+  /**
+   * Renders one tab.
+   * @param {MenuItem} item
+   * @param {number} index
+   * @return {TemplateResult}
+   */
+  _renderTab(item, index) {
+    return html`
+      <li class=${index === this.selected ? 'selected' : ''}>
+        <a href=${item.url}>
+          <i class="material-icons" ?hidden=${!item.icon}>
+            ${item.icon}
+          </i>
+          ${item.text}
+        </a>
+      </li>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      items: {type: Array},
+      selected: {type: Number},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {Array<MenuItem>} */
+    this.items = [];
+    this.selected = 0;
+  }
+}
+
+customElements.define('mr-tabs', MrTabs);
diff --git a/static_src/elements/framework/mr-tabs/mr-tabs.test.js b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
new file mode 100644
index 0000000..1d55c39
--- /dev/null
+++ b/static_src/elements/framework/mr-tabs/mr-tabs.test.js
@@ -0,0 +1,38 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrTabs} from './mr-tabs.js';
+
+/** @type {MrTabs} */
+let element;
+
+describe('mr-tabs', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-tabs');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrTabs);
+  });
+
+  it('renders tabs', async () => {
+    element.items = [
+      {text: 'Text 1'},
+      {text: 'Text 2', icon: 'done', url: 'https://url'},
+    ];
+    element.selected = 1;
+    await element.updateComplete;
+
+    const items = element.shadowRoot.querySelectorAll('li');
+    assert.equal(items[0].className, '');
+    assert.equal(items[1].className, 'selected');
+  });
+});
diff --git a/static_src/elements/framework/mr-upload/mr-upload.js b/static_src/elements/framework/mr-upload/mr-upload.js
new file mode 100644
index 0000000..5fee672
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.js
@@ -0,0 +1,322 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-upload>`
+ *
+ * A file uploading widget for use in adding attachments and similar things.
+ *
+ */
+export class MrUpload extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          width: 100%;
+          padding: 0.25em 4px;
+          border: 1px dashed var(--chops-gray-300);
+          box-sizing: border-box;
+          border-radius: 8px;
+          transition: background 0.2s ease-in-out,
+            border-color 0.2s ease-in-out;
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        :host([expanded]) {
+          /* Expand the drag and drop area when a file is being dragged. */
+          min-height: 120px;
+        }
+        :host([highlighted]) {
+          border-color: var(--chops-primary-accent-color);
+          background: var(--chops-active-choice-bg);
+        }
+        input[type="file"] {
+          /* We need the file uploader to be hidden but still accessible. */
+          opacity: 0;
+          width: 0;
+          height: 0;
+          position: absolute;
+          top: -9999;
+          left: -9999;
+        }
+        input[type="file"]:focus + label {
+          /* TODO(zhangtiff): Find a way to either mimic native browser focus
+           * styles or make focus styles more consistent. */
+          box-shadow: 0 0 3px 1px hsl(193, 82%, 63%);
+        }
+        label.button {
+          margin-right: 8px;
+          padding: 0.1em 4px;
+          display: inline-flex;
+          width: auto;
+          cursor: pointer;
+          border: var(--chops-normal-border);
+          margin-left: 0;
+        }
+        label.button i.material-icons {
+          font-size: var(--chops-icon-font-size);
+        }
+        ul {
+          display: flex;
+          align-items: flex-start;
+          justify-content: flex-start;
+          flex-direction: column;
+        }
+        ul[hidden] {
+          display: none;
+        }
+        li {
+          display: inline-flex;
+          align-items: center;
+        }
+        li i.material-icons {
+          font-size: 14px;
+          margin: 0;
+        }
+        /* TODO(zhangtiff): Create a shared Material icon button component. */
+        button {
+          border-radius: 50%;
+          cursor: pointer;
+          background: 0;
+          border: 0;
+          padding: 0.25em;
+          margin-left: 4px;
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          transition: background 0.2s ease-in-out;
+        }
+        button:hover {
+          background: var(--chops-gray-200);
+        }
+        .controls {
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+          justify-content: flex-start;
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <div class="controls">
+        <input id="file-uploader" type="file" multiple @change=${this._filesChanged}>
+        <label class="button" for="file-uploader">
+          <i class="material-icons" role="presentation">attach_file</i>Add attachments
+        </label>
+        Drop files here to add them (Max: 10.0 MB per comment)
+      </div>
+      <ul ?hidden=${!this.files || !this.files.length}>
+        ${this.files.map((file, i) => html`
+          <li>
+            ${file.name}
+            <button data-index=${i} @click=${this._removeFile}>
+              <i class="material-icons">clear</i>
+            </button>
+          </li>
+        `)}
+      </ul>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      files: {type: Array},
+      highlighted: {
+        type: Boolean,
+        reflect: true,
+      },
+      expanded: {
+        type: Boolean,
+        reflect: true,
+      },
+      _boundOnDragIntoWindow: {type: Object},
+      _boundOnDragOutOfWindow: {type: Object},
+      _boundOnDragInto: {type: Object},
+      _boundOnDragLeave: {type: Object},
+      _boundOnDrop: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.expanded = false;
+    this.highlighted = false;
+    this.files = [];
+    this._boundOnDragIntoWindow = this._onDragIntoWindow.bind(this);
+    this._boundOnDragOutOfWindow = this._onDragOutOfWindow.bind(this);
+    this._boundOnDragInto = this._onDragInto.bind(this);
+    this._boundOnDragLeave = this._onDragLeave.bind(this);
+    this._boundOnDrop = this._onDrop.bind(this);
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this.addEventListener('dragenter', this._boundOnDragInto);
+    this.addEventListener('dragover', this._boundOnDragInto);
+
+    this.addEventListener('dragleave', this._boundOnDragLeave);
+    this.addEventListener('drop', this._boundOnDrop);
+
+    window.addEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.addEventListener('dragover', this._boundOnDragIntoWindow);
+    window.addEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.addEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('dragenter', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragover', this._boundOnDragIntoWindow);
+    window.removeEventListener('dragleave', this._boundOnDragOutOfWindow);
+    window.removeEventListener('drop', this._boundOnDragOutOfWindow);
+  }
+
+  reset() {
+    this.files = [];
+  }
+
+  get hasAttachments() {
+    return this.files.length !== 0;
+  }
+
+  async loadFiles() {
+    // TODO(zhangtiff): Add preloading of files on change.
+    if (!this.files || !this.files.length) return [];
+    const loads = this.files.map(this._loadLocalFile);
+    return await Promise.all(loads);
+  }
+
+  _onDragInto(e) {
+    // Combined event handler for dragenter and dragover.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = true;
+  }
+
+  _onDragLeave(e) {
+    // Unhighlight the drop area when the user undrops the component.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.highlighted = false;
+  }
+
+  _onDrop(e) {
+    // Add the files the user is dragging when dragging into the component.
+    const files = this._eventGetFiles(e);
+    if (!files.length) return;
+    e.preventDefault();
+    this.highlighted = false;
+    this._addFiles(files);
+  }
+
+  _onDragIntoWindow(e) {
+    // Expand the drop area when any file is being dragged in the window.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = true;
+  }
+
+  _onDragOutOfWindow(e) {
+    // Unexpand the component when a file is no longer being dragged.
+    if (!this._eventGetFiles(e).length) return;
+    e.preventDefault();
+    this.expanded = false;
+  }
+
+  _eventGetFiles(e) {
+    if (!e || !e.dataTransfer) return [];
+    const dt = e.dataTransfer;
+
+    if (dt.items && dt.items.length) {
+      const filteredItems = [...dt.items].filter(
+          (item) => item.kind === 'file');
+      return filteredItems.map((item) => item.getAsFile());
+    }
+
+    return [...dt.files];
+  }
+
+  _loadLocalFile(f) {
+    // The FileReader API only accepts callbacks for asynchronous handling,
+    // so it's easier to use Promises here. But by wrapping this logic
+    // in a Promise, we can use async/await in outer code.
+    return new Promise((resolve, reject) => {
+      const r = new FileReader();
+      r.onloadend = () => {
+        resolve({filename: f.name, content: btoa(r.result)});
+      };
+      r.onerror = () => {
+        reject(r.error);
+      };
+
+      r.readAsBinaryString(f);
+    });
+  }
+
+  /**
+   * @param {Event} e
+   * @fires CustomEvent#change
+   * @private
+   */
+  _filesChanged(e) {
+    const input = e.currentTarget;
+    if (!input.files) return;
+    this._addFiles(input.files);
+    this.dispatchEvent(new CustomEvent('change'));
+  }
+
+  _addFiles(newFiles) {
+    if (!newFiles) return;
+    // Spread files to convert it from a FileList to an Array.
+    const files = [...newFiles].filter((f1) => {
+      const matchingFile = this.files.some((f2) => this._filesMatch(f1, f2));
+      return !matchingFile;
+    });
+
+    this.files = this.files.concat(files);
+  }
+
+  _filesMatch(a, b) {
+    // NOTE: This function could return a false positive if two files have the
+    // exact same name, lastModified time, size, and type but different
+    // content. This is extremely unlikely, however.
+    return a.name === b.name && a.lastModified === b.lastModified &&
+      a.size === b.size && a.type === b.type;
+  }
+
+  _removeFile(e) {
+    const target = e.currentTarget;
+
+    // This should always be an int.
+    const index = Number.parseInt(target.dataset.index);
+    if (index < 0 || index >= this.files.length) return;
+
+    this.files.splice(index, 1);
+
+    // Trigger an update.
+    this.files = [...this.files];
+  }
+}
+customElements.define('mr-upload', MrUpload);
diff --git a/static_src/elements/framework/mr-upload/mr-upload.test.js b/static_src/elements/framework/mr-upload/mr-upload.test.js
new file mode 100644
index 0000000..0a0b1e8
--- /dev/null
+++ b/static_src/elements/framework/mr-upload/mr-upload.test.js
@@ -0,0 +1,218 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrUpload} from './mr-upload.js';
+
+let element;
+let preventDefault;
+let mockEvent;
+
+
+describe('mr-upload', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-upload');
+    document.body.appendChild(element);
+
+    preventDefault = sinon.stub();
+
+    mockEvent = (properties) => {
+      return Object.assign({
+        preventDefault: preventDefault,
+      }, properties);
+    };
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrUpload);
+  });
+
+  it('reset clears files', () => {
+    element.files = [new File([''], 'filename.txt'), new File([''], 'hello')];
+
+    element.reset();
+
+    assert.deepEqual(element.files, []);
+  });
+
+  it('editing file selector adds files', () => {
+    const files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+    assert.deepEqual(element.files, []);
+
+    // NOTE: There is currently no way to use JavaScript to set the value of
+    // an HTML file input.
+
+    element._filesChanged({
+      currentTarget: {
+        files: files,
+      },
+    });
+
+    assert.deepEqual(element.files, files);
+  });
+
+  it('files are rendered', async () => {
+    element.files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+      new File([''], 'file.png'),
+    ];
+
+    await element.updateComplete;
+
+    const items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 3);
+
+    assert.include(items[0].textContent, 'filename.txt');
+    assert.include(items[1].textContent, 'hello');
+    assert.include(items[2].textContent, 'file.png');
+  });
+
+  it('clicking removes file', async () => {
+    element.files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+      new File([''], 'file.png'),
+    ];
+
+    await element.updateComplete;
+
+    let items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 3);
+
+    items[1].querySelector('button').click();
+
+    await element.updateComplete;
+
+    items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 2);
+
+    assert.include(items[0].textContent, 'filename.txt');
+    assert.include(items[1].textContent, 'file.png');
+
+    // Make sure clicking works even for children targets.
+    items[0].querySelector('i.material-icons').click();
+
+    await element.updateComplete;
+
+    items = element.shadowRoot.querySelectorAll('li');
+
+    assert.equal(items.length, 1);
+
+    assert.include(items[0].textContent, 'file.png');
+  });
+
+  it('duplicate files are ignored', () => {
+    const file1 = new File([''], 'filename.txt');
+    const file2 = new File([''], 'woahhh');
+    const file3 = new File([''], 'filename');
+
+    element.files = [file1, file2];
+
+    element._addFiles([file2, file3]);
+
+    assert.deepEqual(element.files, [file1, file2, file3]);
+  });
+
+  it('dragging file into window expands element', () => {
+    assert.isFalse(element.expanded);
+    assert.deepEqual(element.files, []);
+
+    element._onDragIntoWindow(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isTrue(element.expanded);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledOnce);
+
+    element._onDragOutOfWindow(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isFalse(element.expanded);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledTwice);
+  });
+
+  it('dragging non-file into window does not expands element', () => {
+    assert.isFalse(element.expanded);
+
+    element._onDragIntoWindow(mockEvent(
+        {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+    ));
+
+    assert.isFalse(element.expanded);
+    assert.isFalse(preventDefault.called);
+
+    element._onDragOutOfWindow(mockEvent(
+        {dataTransfer: {files: [], items: [{kind: 'notFile'}]}},
+    ));
+
+    assert.isFalse(element.expanded);
+    assert.isFalse(preventDefault.called);
+  });
+
+  it('dragging file over element highlights it', () => {
+    assert.isFalse(element.highlighted);
+    assert.deepEqual(element.files, []);
+
+    element._onDragInto(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isTrue(element.highlighted);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledOnce);
+
+    element._onDragLeave(mockEvent({dataTransfer: {files: [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ]}}));
+
+    assert.isFalse(element.highlighted);
+    assert.deepEqual(element.files, []);
+    assert.isTrue(preventDefault.calledTwice);
+  });
+
+  it('dropping file over element selects it', () => {
+    const files = [
+      new File([''], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+    assert.deepEqual(element.files, []);
+
+    element._onDrop(mockEvent({dataTransfer: {files: files}}));
+
+    assert.isTrue(preventDefault.calledOnce);
+    assert.deepEqual(element.files, files);
+  });
+
+  it('loadFiles loads files', async () => {
+    element.files = [
+      new File(['some content'], 'filename.txt'),
+      new File([''], 'hello'),
+    ];
+
+    const uploads = await element.loadFiles();
+
+    assert.deepEqual(uploads, [
+      {content: 'c29tZSBjb250ZW50', filename: 'filename.txt'},
+      {content: '', filename: 'hello'},
+    ]);
+  });
+});
diff --git a/static_src/elements/framework/mr-warning/mr-warning.js b/static_src/elements/framework/mr-warning/mr-warning.js
new file mode 100644
index 0000000..51de376
--- /dev/null
+++ b/static_src/elements/framework/mr-warning/mr-warning.js
@@ -0,0 +1,51 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+
+/**
+ * `<mr-warning>`
+ *
+ * A container for showing warnings.
+ *
+ */
+export class MrWarning extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: flex;
+        align-items: center;
+        flex-direction: row;
+        justify-content: flex-start;
+        box-sizing: border-box;
+        width: 100%;
+        margin: 0.5em 0;
+        padding: 0.25em 8px;
+        border: 1px solid #FF6F00;
+        border-radius: 4px;
+        background: #FFF8E1;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: #FF6F00;
+        margin-right: 4px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <i class="material-icons">warning</i>
+      <slot></slot>
+    `;
+  }
+}
+
+customElements.define('mr-warning', MrWarning);