blob: de5b3b9967953982a337b455880f831c0df943f9 [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001/**
2 * @license
3 * Copyright 2015 Google Inc. All Rights Reserved.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18(function() {
19 'use strict';
20
21 /**
22 * Class constructor for dropdown MDL component.
23 * Implements MDL component design pattern defined at:
24 * https://github.com/jasonmayes/mdl-component-design-pattern
25 *
26 * @constructor
27 * @param {HTMLElement} element The element that will be upgraded.
28 */
29 var MaterialMenu = function MaterialMenu(element) {
30 this.element_ = element;
31
32 // Initialize instance.
33 this.init();
34 };
35 window['MaterialMenu'] = MaterialMenu;
36
37 /**
38 * Store constants in one place so they can be updated easily.
39 *
40 * @enum {string | number}
41 * @private
42 */
43 MaterialMenu.prototype.Constant_ = {
44 // Total duration of the menu animation.
45 TRANSITION_DURATION_SECONDS: 0.3,
46 // The fraction of the total duration we want to use for menu item animations.
47 TRANSITION_DURATION_FRACTION: 0.8,
48 // How long the menu stays open after choosing an option (so the user can see
49 // the ripple).
50 CLOSE_TIMEOUT: 150
51 };
52
53 /**
54 * Keycodes, for code readability.
55 *
56 * @enum {number}
57 * @private
58 */
59 MaterialMenu.prototype.Keycodes_ = {
60 ENTER: 13,
61 ESCAPE: 27,
62 SPACE: 32,
63 UP_ARROW: 38,
64 DOWN_ARROW: 40
65 };
66
67 /**
68 * Store strings for class names defined by this component that are used in
69 * JavaScript. This allows us to simply change it in one place should we
70 * decide to modify at a later date.
71 *
72 * @enum {string}
73 * @private
74 */
75 MaterialMenu.prototype.CssClasses_ = {
76 CONTAINER: 'mdl-menu__container',
77 OUTLINE: 'mdl-menu__outline',
78 ITEM: 'mdl-menu__item',
79 ITEM_RIPPLE_CONTAINER: 'mdl-menu__item-ripple-container',
80 RIPPLE_EFFECT: 'mdl-js-ripple-effect',
81 RIPPLE_IGNORE_EVENTS: 'mdl-js-ripple-effect--ignore-events',
82 RIPPLE: 'mdl-ripple',
83 // Statuses
84 IS_UPGRADED: 'is-upgraded',
85 IS_VISIBLE: 'is-visible',
86 IS_ANIMATING: 'is-animating',
87 // Alignment options
88 BOTTOM_LEFT: 'mdl-menu--bottom-left', // This is the default.
89 BOTTOM_RIGHT: 'mdl-menu--bottom-right',
90 TOP_LEFT: 'mdl-menu--top-left',
91 TOP_RIGHT: 'mdl-menu--top-right',
92 UNALIGNED: 'mdl-menu--unaligned'
93 };
94
95 /**
96 * Initialize element.
97 */
98 MaterialMenu.prototype.init = function() {
99 if (this.element_) {
100 // Create container for the menu.
101 var container = document.createElement('div');
102 container.classList.add(this.CssClasses_.CONTAINER);
103 this.element_.parentElement.insertBefore(container, this.element_);
104 this.element_.parentElement.removeChild(this.element_);
105 container.appendChild(this.element_);
106 this.container_ = container;
107
108 // Create outline for the menu (shadow and background).
109 var outline = document.createElement('div');
110 outline.classList.add(this.CssClasses_.OUTLINE);
111 this.outline_ = outline;
112 container.insertBefore(outline, this.element_);
113
114 // Find the "for" element and bind events to it.
115 var forElId = this.element_.getAttribute('for') ||
116 this.element_.getAttribute('data-mdl-for');
117 var forEl = null;
118 if (forElId) {
119 forEl = document.getElementById(forElId);
120 if (forEl) {
121 this.forElement_ = forEl;
122 forEl.addEventListener('click', this.handleForClick_.bind(this));
123 forEl.addEventListener('keydown',
124 this.handleForKeyboardEvent_.bind(this));
125 }
126 }
127
128 var items = this.element_.querySelectorAll('.' + this.CssClasses_.ITEM);
129 this.boundItemKeydown_ = this.handleItemKeyboardEvent_.bind(this);
130 this.boundItemClick_ = this.handleItemClick_.bind(this);
131 for (var i = 0; i < items.length; i++) {
132 // Add a listener to each menu item.
133 items[i].addEventListener('click', this.boundItemClick_);
134 // Add a tab index to each menu item.
135 items[i].tabIndex = '-1';
136 // Add a keyboard listener to each menu item.
137 items[i].addEventListener('keydown', this.boundItemKeydown_);
138 }
139
140 // Add ripple classes to each item, if the user has enabled ripples.
141 if (this.element_.classList.contains(this.CssClasses_.RIPPLE_EFFECT)) {
142 this.element_.classList.add(this.CssClasses_.RIPPLE_IGNORE_EVENTS);
143
144 for (i = 0; i < items.length; i++) {
145 var item = items[i];
146
147 var rippleContainer = document.createElement('span');
148 rippleContainer.classList.add(this.CssClasses_.ITEM_RIPPLE_CONTAINER);
149
150 var ripple = document.createElement('span');
151 ripple.classList.add(this.CssClasses_.RIPPLE);
152 rippleContainer.appendChild(ripple);
153
154 item.appendChild(rippleContainer);
155 item.classList.add(this.CssClasses_.RIPPLE_EFFECT);
156 }
157 }
158
159 // Copy alignment classes to the container, so the outline can use them.
160 if (this.element_.classList.contains(this.CssClasses_.BOTTOM_LEFT)) {
161 this.outline_.classList.add(this.CssClasses_.BOTTOM_LEFT);
162 }
163 if (this.element_.classList.contains(this.CssClasses_.BOTTOM_RIGHT)) {
164 this.outline_.classList.add(this.CssClasses_.BOTTOM_RIGHT);
165 }
166 if (this.element_.classList.contains(this.CssClasses_.TOP_LEFT)) {
167 this.outline_.classList.add(this.CssClasses_.TOP_LEFT);
168 }
169 if (this.element_.classList.contains(this.CssClasses_.TOP_RIGHT)) {
170 this.outline_.classList.add(this.CssClasses_.TOP_RIGHT);
171 }
172 if (this.element_.classList.contains(this.CssClasses_.UNALIGNED)) {
173 this.outline_.classList.add(this.CssClasses_.UNALIGNED);
174 }
175
176 container.classList.add(this.CssClasses_.IS_UPGRADED);
177 }
178 };
179
180 /**
181 * Handles a click on the "for" element, by positioning the menu and then
182 * toggling it.
183 *
184 * @param {Event} evt The event that fired.
185 * @private
186 */
187 MaterialMenu.prototype.handleForClick_ = function(evt) {
188 if (this.element_ && this.forElement_) {
189 var rect = this.forElement_.getBoundingClientRect();
190 var forRect = this.forElement_.parentElement.getBoundingClientRect();
191
192 if (this.element_.classList.contains(this.CssClasses_.UNALIGNED)) {
193 // Do not position the menu automatically. Requires the developer to
194 // manually specify position.
195 } else if (this.element_.classList.contains(
196 this.CssClasses_.BOTTOM_RIGHT)) {
197 // Position below the "for" element, aligned to its right.
198 this.container_.style.right = (forRect.right - rect.right) + 'px';
199 this.container_.style.top =
200 this.forElement_.offsetTop + this.forElement_.offsetHeight + 'px';
201 } else if (this.element_.classList.contains(this.CssClasses_.TOP_LEFT)) {
202 // Position above the "for" element, aligned to its left.
203 this.container_.style.left = this.forElement_.offsetLeft + 'px';
204 this.container_.style.bottom = (forRect.bottom - rect.top) + 'px';
205 } else if (this.element_.classList.contains(this.CssClasses_.TOP_RIGHT)) {
206 // Position above the "for" element, aligned to its right.
207 this.container_.style.right = (forRect.right - rect.right) + 'px';
208 this.container_.style.bottom = (forRect.bottom - rect.top) + 'px';
209 } else {
210 // Default: position below the "for" element, aligned to its left.
211 this.container_.style.left = this.forElement_.offsetLeft + 'px';
212 this.container_.style.top =
213 this.forElement_.offsetTop + this.forElement_.offsetHeight + 'px';
214 }
215 }
216
217 this.toggle(evt);
218 };
219
220 /**
221 * Handles a keyboard event on the "for" element.
222 *
223 * @param {Event} evt The event that fired.
224 * @private
225 */
226 MaterialMenu.prototype.handleForKeyboardEvent_ = function(evt) {
227 if (this.element_ && this.container_ && this.forElement_) {
228 var items = this.element_.querySelectorAll('.' + this.CssClasses_.ITEM +
229 ':not([disabled])');
230
231 if (items && items.length > 0 &&
232 this.container_.classList.contains(this.CssClasses_.IS_VISIBLE)) {
233 if (evt.keyCode === this.Keycodes_.UP_ARROW) {
234 evt.preventDefault();
235 items[items.length - 1].focus();
236 } else if (evt.keyCode === this.Keycodes_.DOWN_ARROW) {
237 evt.preventDefault();
238 items[0].focus();
239 }
240 }
241 }
242 };
243
244 /**
245 * Handles a keyboard event on an item.
246 *
247 * @param {Event} evt The event that fired.
248 * @private
249 */
250 MaterialMenu.prototype.handleItemKeyboardEvent_ = function(evt) {
251 if (this.element_ && this.container_) {
252 var items = this.element_.querySelectorAll('.' + this.CssClasses_.ITEM +
253 ':not([disabled])');
254
255 if (items && items.length > 0 &&
256 this.container_.classList.contains(this.CssClasses_.IS_VISIBLE)) {
257 var currentIndex = Array.prototype.slice.call(items).indexOf(evt.target);
258
259 if (evt.keyCode === this.Keycodes_.UP_ARROW) {
260 evt.preventDefault();
261 if (currentIndex > 0) {
262 items[currentIndex - 1].focus();
263 } else {
264 items[items.length - 1].focus();
265 }
266 } else if (evt.keyCode === this.Keycodes_.DOWN_ARROW) {
267 evt.preventDefault();
268 if (items.length > currentIndex + 1) {
269 items[currentIndex + 1].focus();
270 } else {
271 items[0].focus();
272 }
273 } else if (evt.keyCode === this.Keycodes_.SPACE ||
274 evt.keyCode === this.Keycodes_.ENTER) {
275 evt.preventDefault();
276 // Send mousedown and mouseup to trigger ripple.
277 var e = new MouseEvent('mousedown');
278 evt.target.dispatchEvent(e);
279 e = new MouseEvent('mouseup');
280 evt.target.dispatchEvent(e);
281 // Send click.
282 evt.target.click();
283 } else if (evt.keyCode === this.Keycodes_.ESCAPE) {
284 evt.preventDefault();
285 this.hide();
286 }
287 }
288 }
289 };
290
291 /**
292 * Handles a click event on an item.
293 *
294 * @param {Event} evt The event that fired.
295 * @private
296 */
297 MaterialMenu.prototype.handleItemClick_ = function(evt) {
298 if (evt.target.hasAttribute('disabled')) {
299 evt.stopPropagation();
300 } else {
301 // Wait some time before closing menu, so the user can see the ripple.
302 this.closing_ = true;
303 window.setTimeout(function(evt) {
304 this.hide();
305 this.closing_ = false;
306 }.bind(this), /** @type {number} */ (this.Constant_.CLOSE_TIMEOUT));
307 }
308 };
309
310 /**
311 * Calculates the initial clip (for opening the menu) or final clip (for closing
312 * it), and applies it. This allows us to animate from or to the correct point,
313 * that is, the point it's aligned to in the "for" element.
314 *
315 * @param {number} height Height of the clip rectangle
316 * @param {number} width Width of the clip rectangle
317 * @private
318 */
319 MaterialMenu.prototype.applyClip_ = function(height, width) {
320 if (this.element_.classList.contains(this.CssClasses_.UNALIGNED)) {
321 // Do not clip.
322 this.element_.style.clip = '';
323 } else if (this.element_.classList.contains(this.CssClasses_.BOTTOM_RIGHT)) {
324 // Clip to the top right corner of the menu.
325 this.element_.style.clip =
326 'rect(0 ' + width + 'px ' + '0 ' + width + 'px)';
327 } else if (this.element_.classList.contains(this.CssClasses_.TOP_LEFT)) {
328 // Clip to the bottom left corner of the menu.
329 this.element_.style.clip =
330 'rect(' + height + 'px 0 ' + height + 'px 0)';
331 } else if (this.element_.classList.contains(this.CssClasses_.TOP_RIGHT)) {
332 // Clip to the bottom right corner of the menu.
333 this.element_.style.clip = 'rect(' + height + 'px ' + width + 'px ' +
334 height + 'px ' + width + 'px)';
335 } else {
336 // Default: do not clip (same as clipping to the top left corner).
337 this.element_.style.clip = '';
338 }
339 };
340
341 /**
342 * Cleanup function to remove animation listeners.
343 *
344 * @param {Event} evt
345 * @private
346 */
347
348 MaterialMenu.prototype.removeAnimationEndListener_ = function(evt) {
349 evt.target.classList.remove(MaterialMenu.prototype.CssClasses_.IS_ANIMATING);
350 };
351
352 /**
353 * Adds an event listener to clean up after the animation ends.
354 *
355 * @private
356 */
357 MaterialMenu.prototype.addAnimationEndListener_ = function() {
358 this.element_.addEventListener('transitionend', this.removeAnimationEndListener_);
359 this.element_.addEventListener('webkitTransitionEnd', this.removeAnimationEndListener_);
360 };
361
362 /**
363 * Displays the menu.
364 *
365 * @public
366 */
367 MaterialMenu.prototype.show = function(evt) {
368 if (this.element_ && this.container_ && this.outline_) {
369 // Measure the inner element.
370 var height = this.element_.getBoundingClientRect().height;
371 var width = this.element_.getBoundingClientRect().width;
372
373 // Apply the inner element's size to the container and outline.
374 this.container_.style.width = width + 'px';
375 this.container_.style.height = height + 'px';
376 this.outline_.style.width = width + 'px';
377 this.outline_.style.height = height + 'px';
378
379 var transitionDuration = this.Constant_.TRANSITION_DURATION_SECONDS *
380 this.Constant_.TRANSITION_DURATION_FRACTION;
381
382 // Calculate transition delays for individual menu items, so that they fade
383 // in one at a time.
384 var items = this.element_.querySelectorAll('.' + this.CssClasses_.ITEM);
385 for (var i = 0; i < items.length; i++) {
386 var itemDelay = null;
387 if (this.element_.classList.contains(this.CssClasses_.TOP_LEFT) ||
388 this.element_.classList.contains(this.CssClasses_.TOP_RIGHT)) {
389 itemDelay = ((height - items[i].offsetTop - items[i].offsetHeight) /
390 height * transitionDuration) + 's';
391 } else {
392 itemDelay = (items[i].offsetTop / height * transitionDuration) + 's';
393 }
394 items[i].style.transitionDelay = itemDelay;
395 }
396
397 // Apply the initial clip to the text before we start animating.
398 this.applyClip_(height, width);
399
400 // Wait for the next frame, turn on animation, and apply the final clip.
401 // Also make it visible. This triggers the transitions.
402 window.requestAnimationFrame(function() {
403 this.element_.classList.add(this.CssClasses_.IS_ANIMATING);
404 this.element_.style.clip = 'rect(0 ' + width + 'px ' + height + 'px 0)';
405 this.container_.classList.add(this.CssClasses_.IS_VISIBLE);
406 }.bind(this));
407
408 // Clean up after the animation is complete.
409 this.addAnimationEndListener_();
410
411 // Add a click listener to the document, to close the menu.
412 var callback = function(e) {
413 // Check to see if the document is processing the same event that
414 // displayed the menu in the first place. If so, do nothing.
415 // Also check to see if the menu is in the process of closing itself, and
416 // do nothing in that case.
417 // Also check if the clicked element is a menu item
418 // if so, do nothing.
419 if (e !== evt && !this.closing_ && e.target.parentNode !== this.element_) {
420 document.removeEventListener('click', callback);
421 this.hide();
422 }
423 }.bind(this);
424 document.addEventListener('click', callback);
425 }
426 };
427 MaterialMenu.prototype['show'] = MaterialMenu.prototype.show;
428
429 /**
430 * Hides the menu.
431 *
432 * @public
433 */
434 MaterialMenu.prototype.hide = function() {
435 if (this.element_ && this.container_ && this.outline_) {
436 var items = this.element_.querySelectorAll('.' + this.CssClasses_.ITEM);
437
438 // Remove all transition delays; menu items fade out concurrently.
439 for (var i = 0; i < items.length; i++) {
440 items[i].style.removeProperty('transition-delay');
441 }
442
443 // Measure the inner element.
444 var rect = this.element_.getBoundingClientRect();
445 var height = rect.height;
446 var width = rect.width;
447
448 // Turn on animation, and apply the final clip. Also make invisible.
449 // This triggers the transitions.
450 this.element_.classList.add(this.CssClasses_.IS_ANIMATING);
451 this.applyClip_(height, width);
452 this.container_.classList.remove(this.CssClasses_.IS_VISIBLE);
453
454 // Clean up after the animation is complete.
455 this.addAnimationEndListener_();
456 }
457 };
458 MaterialMenu.prototype['hide'] = MaterialMenu.prototype.hide;
459
460 /**
461 * Displays or hides the menu, depending on current state.
462 *
463 * @public
464 */
465 MaterialMenu.prototype.toggle = function(evt) {
466 if (this.container_.classList.contains(this.CssClasses_.IS_VISIBLE)) {
467 this.hide();
468 } else {
469 this.show(evt);
470 }
471 };
472 MaterialMenu.prototype['toggle'] = MaterialMenu.prototype.toggle;
473
474 // The component registers itself. It can assume componentHandler is available
475 // in the global scope.
476 componentHandler.register({
477 constructor: MaterialMenu,
478 classAsString: 'MaterialMenu',
479 cssClass: 'mdl-js-menu',
480 widget: true
481 });
482})();