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);
+ });
+ });
+});