blob: e6fe543782cfc8a16c84144ddda07c3faeef91c3 [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// 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);