1// Copyright (c) 2012 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
5// <include src="../../assert.js">
6
7cr.exportPath('cr.ui');
8
9/**
10 * Enum for type of hide. Delayed is used when called by clicking on a
11 * checkable menu item.
12 * @enum {number}
13 */
14cr.ui.HideType = {
15  INSTANT: 0,
16  DELAYED: 1
17};
18
19cr.define('cr.ui', function() {
20  /** @const */
21  var Menu = cr.ui.Menu;
22
23  /** @const */
24  var HideType = cr.ui.HideType;
25
26  /** @const */
27  var positionPopupAroundElement = cr.ui.positionPopupAroundElement;
28
29  /**
30   * Creates a new menu button element.
31   * @param {Object=} opt_propertyBag Optional properties.
32   * @constructor
33   * @extends {HTMLButtonElement}
34   * @implements {EventListener}
35   */
36  var MenuButton = cr.ui.define('button');
37
38  MenuButton.prototype = {
39    __proto__: HTMLButtonElement.prototype,
40
41    /**
42     * Initializes the menu button.
43     */
44    decorate: function() {
45      this.addEventListener('mousedown', this);
46      this.addEventListener('keydown', this);
47
48      // Adding the 'custom-appearance' class prevents widgets.css from changing
49      // the appearance of this element.
50      this.classList.add('custom-appearance');
51      this.classList.add('menu-button');  // For styles in menu_button.css.
52
53      var menu;
54      if ((menu = this.getAttribute('menu')))
55        this.menu = menu;
56
57      // An event tracker for events we only connect to while the menu is
58      // displayed.
59      this.showingEvents_ = new EventTracker();
60
61      this.anchorType = cr.ui.AnchorType.BELOW;
62      this.invertLeftRight = false;
63    },
64
65    /**
66     * The menu associated with the menu button.
67     * @type {cr.ui.Menu}
68     */
69    get menu() {
70      return this.menu_;
71    },
72    set menu(menu) {
73      if (typeof menu == 'string' && menu[0] == '#') {
74        menu = assert(this.ownerDocument.getElementById(menu.slice(1)));
75        cr.ui.decorate(menu, Menu);
76      }
77
78      this.menu_ = menu;
79      if (menu) {
80        if (menu.id)
81          this.setAttribute('menu', '#' + menu.id);
82      }
83    },
84
85    /**
86     * Whether to show the menu on press of the Up or Down arrow keys.
87     */
88    respondToArrowKeys: true,
89
90    /**
91     * Handles event callbacks.
92     * @param {Event} e The event object.
93     */
94    handleEvent: function(e) {
95      if (!this.menu)
96        return;
97
98      switch (e.type) {
99        case 'mousedown':
100          if (e.currentTarget == this.ownerDocument) {
101            if (e.target instanceof Element && !this.contains(e.target) &&
102                !this.menu.contains(e.target)) {
103              this.hideMenu();
104            } else {
105              e.preventDefault();
106            }
107          } else {
108            if (this.isMenuShown()) {
109              this.hideMenu();
110            } else if (e.button == 0) {  // Only show the menu when using left
111                                         // mouse button.
112              this.showMenu(false);
113
114              // Prevent the button from stealing focus on mousedown.
115              e.preventDefault();
116            }
117          }
118
119          // Hide the focus ring on mouse click.
120          this.classList.add('using-mouse');
121          break;
122        case 'keydown':
123          this.handleKeyDown(e);
124          // If the menu is visible we let it handle all the keyboard events.
125          if (this.isMenuShown() && e.currentTarget == this.ownerDocument) {
126            if (this.menu.handleKeyDown(e)) {
127              e.preventDefault();
128              e.stopPropagation();
129            }
130          }
131
132          // Show the focus ring on keypress.
133          this.classList.remove('using-mouse');
134          break;
135        case 'focus':
136          if (e.target instanceof Element && !this.contains(e.target) &&
137              !this.menu.contains(e.target)) {
138            this.hideMenu();
139            // Show the focus ring on focus - if it's come from a mouse event,
140            // the focus ring will be hidden in the mousedown event handler,
141            // executed after this.
142            this.classList.remove('using-mouse');
143          }
144          break;
145        case 'activate':
146          var hideDelayed = e.target instanceof cr.ui.MenuItem &&
147              e.target.checkable;
148          this.hideMenu(hideDelayed ? HideType.DELAYED : HideType.INSTANT);
149          break;
150        case 'scroll':
151          if (!(e.target == this.menu || this.menu.contains(e.target)))
152            this.hideMenu();
153          break;
154        case 'popstate':
155        case 'resize':
156          this.hideMenu();
157          break;
158      }
159    },
160
161    /**
162     * Shows the menu.
163     * @param {boolean} shouldSetFocus Whether to set focus on the
164     *     selected menu item.
165     */
166    showMenu: function(shouldSetFocus) {
167      this.hideMenu();
168
169      this.menu.updateCommands(this);
170
171      var event = document.createEvent('UIEvents');
172      event.initUIEvent('menushow', true, true, window, null);
173
174      if (!this.dispatchEvent(event))
175        return;
176
177      this.menu.hidden = false;
178
179      this.setAttribute('menu-shown', '');
180
181      // When the menu is shown we steal all keyboard events.
182      var doc = this.ownerDocument;
183      var win = doc.defaultView;
184      this.showingEvents_.add(doc, 'keydown', this, true);
185      this.showingEvents_.add(doc, 'mousedown', this, true);
186      this.showingEvents_.add(doc, 'focus', this, true);
187      this.showingEvents_.add(doc, 'scroll', this, true);
188      this.showingEvents_.add(win, 'popstate', this);
189      this.showingEvents_.add(win, 'resize', this);
190      this.showingEvents_.add(this.menu, 'activate', this);
191      this.positionMenu_();
192
193      if (shouldSetFocus)
194        this.menu.focusSelectedItem();
195    },
196
197    /**
198     * Hides the menu. If your menu can go out of scope, make sure to call this
199     * first.
200     * @param {cr.ui.HideType=} opt_hideType Type of hide.
201     *     default: cr.ui.HideType.INSTANT.
202     */
203    hideMenu: function(opt_hideType) {
204      if (!this.isMenuShown())
205        return;
206
207      this.removeAttribute('menu-shown');
208      if (opt_hideType == HideType.DELAYED)
209        this.menu.classList.add('hide-delayed');
210      else
211        this.menu.classList.remove('hide-delayed');
212      this.menu.hidden = true;
213
214      this.showingEvents_.removeAll();
215      this.focus();
216    },
217
218    /**
219     * Whether the menu is shown.
220     */
221    isMenuShown: function() {
222      return this.hasAttribute('menu-shown');
223    },
224
225    /**
226     * Positions the menu below the menu button. At this point we do not use any
227     * advanced positioning logic to ensure the menu fits in the viewport.
228     * @private
229     */
230    positionMenu_: function() {
231      positionPopupAroundElement(this, this.menu, this.anchorType,
232                                 this.invertLeftRight);
233    },
234
235    /**
236     * Handles the keydown event for the menu button.
237     */
238    handleKeyDown: function(e) {
239      switch (e.keyIdentifier) {
240        case 'Down':
241        case 'Up':
242          if (!this.respondToArrowKeys)
243            break;
244        case 'Enter':
245        case 'U+0020': // Space
246          if (!this.isMenuShown())
247            this.showMenu(true);
248          e.preventDefault();
249          break;
250        case 'Esc':
251        case 'U+001B': // Maybe this is remote desktop playing a prank?
252        case 'U+0009': // Tab
253          this.hideMenu();
254          break;
255      }
256    }
257  };
258
259  /**
260   * Helper for styling a menu button with a drop-down arrow indicator.
261   * Creates a new 2D canvas context and draws a downward-facing arrow into it.
262   * @param {string} canvasName The name of the canvas. The canvas can be
263   *     addressed from CSS using -webkit-canvas(<canvasName>).
264   * @param {number} width The width of the canvas and the arrow.
265   * @param {number} height The height of the canvas and the arrow.
266   * @param {string} colorSpec The CSS color to use when drawing the arrow.
267   */
268  function createDropDownArrowCanvas(canvasName, width, height, colorSpec) {
269    var ctx = document.getCSSCanvasContext('2d', canvasName, width, height);
270    ctx.fillStyle = ctx.strokeStyle = colorSpec;
271    ctx.beginPath();
272    ctx.moveTo(0, 0);
273    ctx.lineTo(width, 0);
274    ctx.lineTo(height, height);
275    ctx.closePath();
276    ctx.fill();
277    ctx.stroke();
278  };
279
280  /** @const */ var ARROW_WIDTH = 6;
281  /** @const */ var ARROW_HEIGHT = 3;
282
283  /**
284   * Create the images used to style drop-down-style MenuButtons.
285   * This should be called before creating any MenuButtons that will have the
286   * CSS class 'drop-down'. If no colors are specified, defaults will be used.
287   * @param {string=} opt_normalColor CSS color for the default button state.
288   * @param {string=} opt_hoverColor CSS color for the hover button state.
289   * @param {string=} opt_activeColor CSS color for the active button state.
290   */
291  MenuButton.createDropDownArrows = function(
292      opt_normalColor, opt_hoverColor, opt_activeColor) {
293    opt_normalColor = opt_normalColor || 'rgb(192, 195, 198)';
294    opt_hoverColor = opt_hoverColor || 'rgb(48, 57, 66)';
295    opt_activeColor = opt_activeColor || 'white';
296
297    createDropDownArrowCanvas(
298        'drop-down-arrow', ARROW_WIDTH, ARROW_HEIGHT, opt_normalColor);
299    createDropDownArrowCanvas(
300        'drop-down-arrow-hover', ARROW_WIDTH, ARROW_HEIGHT, opt_hoverColor);
301    createDropDownArrowCanvas(
302        'drop-down-arrow-active', ARROW_WIDTH, ARROW_HEIGHT, opt_activeColor);
303  };
304
305  // Export
306  return {
307    MenuButton: MenuButton,
308  };
309});
310