Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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);
+    });
+  });
+});