blob: 4564ab0112982ac21ca6bcdf0f263aa2c0123219 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import {ifDefined} from 'lit-html/directives/if-defined';
7import {SHARED_STYLES} from 'shared/shared-styles.js';
8import 'shared/typedef.js';
9
10export const SCREENREADER_ATTRIBUTE_ERROR = `For screenreader support,
11 mr-dropdown must always have either a label or a text property defined.`;
12
13/**
14 * `<mr-dropdown>`
15 *
16 * Dropdown menu for Monorail.
17 *
18 */
19export class MrDropdown extends LitElement {
20 /** @override */
21 static get styles() {
22 return [
23 SHARED_STYLES,
24 css`
25 :host {
26 position: relative;
27 display: inline-block;
28 height: 100%;
29 font-size: inherit;
30 font-family: var(--chops-font-family);
31 --mr-dropdown-icon-color: var(--chops-primary-icon-color);
32 --mr-dropdown-icon-font-size: var(--chops-icon-font-size);
33 --mr-dropdown-anchor-font-weight: var(--chops-link-font-weight);
34 --mr-dropdown-anchor-padding: 4px 0.25em;
35 --mr-dropdown-anchor-justify-content: center;
36 --mr-dropdown-menu-max-height: initial;
37 --mr-dropdown-menu-overflow: initial;
38 --mr-dropdown-menu-min-width: 120%;
39 --mr-dropdown-menu-font-size: var(--chops-large-font-size);
40 --mr-dropdown-menu-icon-size: var(--chops-icon-font-size);
41 }
42 :host([hidden]) {
43 display: none;
44 visibility: hidden;
45 }
46 :host(:not([opened])) .menu {
47 display: none;
48 visibility: hidden;
49 }
50 strong {
51 font-size: var(--chops-large-font-size);
52 }
53 i.material-icons {
54 font-size: var(--mr-dropdown-icon-font-size);
55 display: inline-block;
56 color: var(--mr-dropdown-icon-color);
57 padding: 0 2px;
58 box-sizing: border-box;
59 }
60 i.material-icons[hidden],
61 .menu-item > i.material-icons[hidden] {
62 display: none;
63 }
64 .menu-item > i.material-icons {
65 display: block;
66 font-size: var(--mr-dropdown-menu-icon-size);
67 width: var(--mr-dropdown-menu-icon-size);
68 height: var(--mr-dropdown-menu-icon-size);
69 margin-right: 8px;
70 }
71 .anchor:disabled {
72 color: var(--chops-button-disabled-color);
73 }
74 button.anchor {
75 box-sizing: border-box;
76 background: none;
77 border: none;
78 font-size: inherit;
79 width: 100%;
80 height: 100%;
81 display: flex;
82 align-items: center;
83 justify-content: var(--mr-dropdown-anchor-justify-content);
84 cursor: pointer;
85 padding: var(--mr-dropdown-anchor-padding);
86 color: var(--chops-link-color);
87 font-weight: var(--mr-dropdown-anchor-font-weight);
88 font-family: inherit;
89 }
90 /* menuAlignment options: right, left, side. */
91 .menu.right {
92 right: 0px;
93 }
94 .menu.left {
95 left: 0px;
96 }
97 .menu.side {
98 left: 100%;
99 top: 0;
100 }
101 .menu {
102 font-size: var(--mr-dropdown-menu-font-size);
103 position: absolute;
104 min-width: var(--mr-dropdown-menu-min-width);
105 max-height: var(--mr-dropdown-menu-max-height);
106 overflow: var(--mr-dropdown-menu-overflow);
107 top: 90%;
108 display: block;
109 background: var(--chops-white);
110 border: var(--chops-accessible-border);
111 z-index: 990;
112 box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
113 font-family: inherit;
114 }
115 .menu-item {
116 background: none;
117 margin: 0;
118 border: 0;
119 box-sizing: border-box;
120 text-decoration: none;
121 white-space: nowrap;
122 display: flex;
123 align-items: center;
124 justify-content: left;
125 width: 100%;
126 padding: 0.25em 8px;
127 transition: 0.2s background ease-in-out;
128
129 }
130 .menu-item[hidden] {
131 display: none;
132 }
133 mr-dropdown.menu-item {
134 width: 100%;
135 padding: 0;
136 --mr-dropdown-anchor-padding: 0.25em 8px;
137 --mr-dropdown-anchor-justify-content: space-between;
138 }
139 .menu hr {
140 width: 96%;
141 margin: 0 2%;
142 border: 0;
143 height: 1px;
144 background: hsl(0, 0%, 80%);
145 }
146 .menu a {
147 cursor: pointer;
148 color: var(--chops-link-color);
149 }
150 .menu a:hover, .menu a:focus {
151 background: var(--chops-active-choice-bg);
152 }
153 `,
154 ];
155 }
156
157 /** @override */
158 render() {
159 return html`
160 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
161 <button class="anchor"
162 @click=${this.toggle}
163 @keydown=${this._exitMenuOnEsc}
164 ?disabled=${this.disabled}
165 title=${this.title || this.label}
166 aria-label=${this.label}
167 aria-expanded=${this.opened}
168 >
169 ${this.text}
170 <i class="material-icons" aria-hidden="true">${this.icon}</i>
171 </button>
172 <div class="menu ${this.menuAlignment}">
173 ${this.items.map((item, index) => this._renderItem(item, index))}
174 <slot></slot>
175 </div>
176 `;
177 }
178
179 /**
180 * Render a single dropdown menu item.
181 * @param {MenuItem} item
182 * @param {number} index The item's position in the list of items.
183 * @return {TemplateResult}
184 */
185 _renderItem(item, index) {
186 if (item.separator) {
187 // The menu item is a no-op divider between sections.
188 return html`
189 <strong ?hidden=${!item.text} class="menu-item">
190 ${item.text}
191 </strong>
192 <hr />
193 `;
194 }
195 if (item.items && item.items.length) {
196 // The menu contains a sub-menu.
197 return html`
198 <mr-dropdown
199 .text=${item.text}
200 .items=${item.items}
201 menuAlignment="side"
202 icon="arrow_right"
203 data-idx=${index}
204 class="menu-item"
205 ></mr-dropdown>
206 `;
207 }
208
209 return html`
210 <a
211 href=${ifDefined(item.url)}
212 @click=${this._runItemHandler}
213 @keydown=${this._onItemKeydown}
214 data-idx=${index}
215 tabindex="0"
216 class="menu-item"
217 >
218 <i
219 class="material-icons"
220 ?hidden=${item.icon === undefined}
221 >${item.icon}</i>
222 ${item.text}
223 </a>
224 `;
225 }
226
227 /** @override */
228 constructor() {
229 super();
230
231 this.label = '';
232 this.text = '';
233 this.items = [];
234 this.icon = 'arrow_drop_down';
235 this.menuAlignment = 'right';
236 this.opened = false;
237 this.disabled = false;
238
239 this._boundCloseOnOutsideClick = this._closeOnOutsideClick.bind(this);
240 }
241
242 /** @override */
243 static get properties() {
244 return {
245 title: {type: String},
246 label: {type: String},
247 text: {type: String},
248 items: {type: Array},
249 icon: {type: String},
250 menuAlignment: {type: String},
251 opened: {type: Boolean, reflect: true},
252 disabled: {type: Boolean},
253 };
254 }
255
256 /**
257 * Either runs the click handler attached to the clicked item and closes the
258 * menu.
259 * @param {MouseEvent|KeyboardEvent} e
260 */
261 _runItemHandler(e) {
262 if (e instanceof MouseEvent || e.code === 'Enter') {
263 const idx = e.target.dataset.idx;
264 if (idx !== undefined && this.items[idx].handler) {
265 this.items[idx].handler();
266 }
267 this.close();
268 }
269 }
270
271 /**
272 * Runs multiple event handlers when a user types a key while
273 * focusing a menu item.
274 * @param {KeyboardEvent} e
275 */
276 _onItemKeydown(e) {
277 this._runItemHandler(e);
278 this._exitMenuOnEsc(e);
279 }
280
281 /**
282 * If the user types Esc while focusing any dropdown item, then
283 * exit the dropdown.
284 * @param {KeyboardEvent} e
285 */
286 _exitMenuOnEsc(e) {
287 if (e.key === 'Escape') {
288 this.close();
289
290 // Return focus to the anchor of the dropdown on closing, so that
291 // users don't lose their overall focus position within the page.
292 const anchor = this.shadowRoot.querySelector('.anchor');
293 anchor.focus();
294 }
295 }
296
297 /** @override */
298 connectedCallback() {
299 super.connectedCallback();
300 window.addEventListener('click', this._boundCloseOnOutsideClick, true);
301 }
302
303 /** @override */
304 disconnectedCallback() {
305 super.disconnectedCallback();
306 window.removeEventListener('click', this._boundCloseOnOutsideClick, true);
307 }
308
309 /** @override */
310 updated(changedProperties) {
311 if (changedProperties.has('label') || changedProperties.has('text')) {
312 if (!this.label && !this.text) {
313 console.error(SCREENREADER_ATTRIBUTE_ERROR);
314 }
315 }
316 }
317
318 /**
319 * Closes and opens the dropdown menu.
320 */
321 toggle() {
322 this.opened = !this.opened;
323 }
324
325 /**
326 * Opens the dropdown menu.
327 */
328 open() {
329 this.opened = true;
330 }
331
332 /**
333 * Closes the dropdown menu.
334 */
335 close() {
336 this.opened = false;
337 }
338
339 /**
340 * Click a specific item in mr-dropdown, using JavaScript. Useful for testing.
341 *
342 * @param {number} i index of the item to click.
343 */
344 clickItem(i) {
345 const items = this.shadowRoot.querySelectorAll('.menu-item');
346 items[i].click();
347 }
348
349 /**
350 * @param {MouseEvent} evt
351 * @private
352 */
353 _closeOnOutsideClick(evt) {
354 if (!this.opened) return;
355
356 const hasMenu = evt.composedPath().find(
357 (node) => {
358 return node === this;
359 },
360 );
361 if (hasMenu) return;
362
363 this.close();
364 }
365}
366
367customElements.define('mr-dropdown', MrDropdown);