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
5cr.define('cr.ui', function() {
6
7  /** @const */ var MenuItem = cr.ui.MenuItem;
8
9  /**
10   * Creates a new menu element. Menu dispatches all commands on the element it
11   * was shown for.
12   *
13   * @param {Object=} opt_propertyBag Optional properties.
14   * @constructor
15   * @extends {HTMLMenuElement}
16   */
17  var Menu = cr.ui.define('menu');
18
19  Menu.prototype = {
20    __proto__: HTMLMenuElement.prototype,
21
22    selectedIndex_: -1,
23
24    /**
25     * Element for which menu is being shown.
26     */
27    contextElement: null,
28
29    /**
30     * Selector for children which are menu items.
31     */
32    menuItemSelector: '*',
33
34    /**
35     * Initializes the menu element.
36     */
37    decorate: function() {
38      this.addEventListener('mouseover', this.handleMouseOver_);
39      this.addEventListener('mouseout', this.handleMouseOut_);
40
41      this.classList.add('decorated');
42      this.setAttribute('role', 'menu');
43      this.hidden = true;  // Hide the menu by default.
44
45      // Decorate the children as menu items.
46      var menuItems = this.menuItems;
47      for (var i = 0, menuItem; menuItem = menuItems[i]; i++) {
48        cr.ui.decorate(menuItem, MenuItem);
49      }
50    },
51
52    /**
53     * Adds menu item at the end of the list.
54     * @param {Object} item Menu item properties.
55     * @return {cr.ui.MenuItem} The created menu item.
56     */
57    addMenuItem: function(item) {
58      var menuItem = this.ownerDocument.createElement('menuitem');
59      this.appendChild(menuItem);
60
61      cr.ui.decorate(menuItem, MenuItem);
62
63      if (item.label)
64        menuItem.label = item.label;
65
66      if (item.iconUrl)
67        menuItem.iconUrl = item.iconUrl;
68
69      return menuItem;
70    },
71
72    /**
73     * Adds separator at the end of the list.
74     */
75    addSeparator: function() {
76      var separator = this.ownerDocument.createElement('hr');
77      cr.ui.decorate(separator, MenuItem);
78      this.appendChild(separator);
79    },
80
81    /**
82     * Clears menu.
83     */
84    clear: function() {
85      this.textContent = '';
86    },
87
88    /**
89     * Walks up the ancestors of |el| until a menu item belonging to this menu
90     * is found.
91     * @param {Element} el The element to start searching from.
92     * @return {cr.ui.MenuItem} The found menu item or null.
93     * @private
94     */
95    findMenuItem_: function(el) {
96      while (el && el.parentNode != this) {
97        el = el.parentNode;
98      }
99      return el;
100    },
101
102    /**
103     * Handles mouseover events and selects the hovered item.
104     * @param {Event} e The mouseover event.
105     * @private
106     */
107    handleMouseOver_: function(e) {
108      var overItem = this.findMenuItem_(e.target);
109      this.selectedItem = overItem;
110    },
111
112    /**
113     * Handles mouseout events and deselects any selected item.
114     * @param {Event} e The mouseout event.
115     * @private
116     */
117    handleMouseOut_: function(e) {
118      this.selectedItem = null;
119    },
120
121    get menuItems() {
122      return this.querySelectorAll(this.menuItemSelector);
123    },
124
125    /**
126     * The selected menu item or null if none.
127     * @type {cr.ui.MenuItem}
128     */
129    get selectedItem() {
130      return this.menuItems[this.selectedIndex];
131    },
132    set selectedItem(item) {
133      var index = Array.prototype.indexOf.call(this.menuItems, item);
134      this.selectedIndex = index;
135    },
136
137    /**
138     * Focuses the selected item. If selectedIndex is invalid, set it to 0
139     * first.
140     */
141    focusSelectedItem: function() {
142      if (this.selectedIndex < 0 ||
143          this.selectedIndex > this.menuItems.length) {
144        this.selectedIndex = 0;
145      }
146
147      if (this.selectedItem) {
148        this.selectedItem.focus();
149        this.setAttribute('aria-activedescendant', this.selectedItem.id);
150      }
151    },
152
153    /**
154     * Menu length
155     */
156    get length() {
157      return this.menuItems.length;
158    },
159
160    /**
161     * Returns if the menu has any visible item.
162     * @return {boolean} True if the menu has visible item. Otherwise, false.
163     */
164    hasVisibleItems: function() {
165      var menuItems = this.menuItems;  // Cache.
166      for (var i = 0, menuItem; menuItem = menuItems[i]; i++) {
167        if (!menuItem.hidden)
168          return true;
169      }
170      return false;
171    },
172
173    /**
174     * This is the function that handles keyboard navigation. This is usually
175     * called by the element responsible for managing the menu.
176     * @param {Event} e The keydown event object.
177     * @return {boolean} Whether the event was handled be the menu.
178     */
179    handleKeyDown: function(e) {
180      var item = this.selectedItem;
181
182      var self = this;
183      function selectNextAvailable(m) {
184        var menuItems = self.menuItems;
185        var len = menuItems.length;
186        if (!len) {
187          // Edge case when there are no items.
188          return;
189        }
190        var i = self.selectedIndex;
191        if (i == -1 && m == -1) {
192          // Edge case when needed to go the last item first.
193          i = 0;
194        }
195
196        // "i" may be negative(-1), so modulus operation and cycle below
197        // wouldn't work as assumed. This trick makes startPosition positive
198        // without altering it's modulo.
199        var startPosition = (i + len) % len;
200
201        while (true) {
202          i = (i + m + len) % len;
203
204          // Check not to enter into infinite loop if all items are hidden or
205          // disabled.
206          if (i == startPosition)
207            break;
208
209          item = menuItems[i];
210          if (item && !item.isSeparator() && !item.hidden && !item.disabled)
211            break;
212        }
213        if (item && !item.disabled)
214          self.selectedIndex = i;
215      }
216
217      switch (e.keyIdentifier) {
218        case 'Down':
219          selectNextAvailable(1);
220          this.focusSelectedItem();
221          return true;
222        case 'Up':
223          selectNextAvailable(-1);
224          this.focusSelectedItem();
225          return true;
226        case 'Enter':
227        case 'U+0020': // Space
228          if (item) {
229            var activationEvent = cr.doc.createEvent('Event');
230            activationEvent.initEvent('activate', true, true);
231            activationEvent.originalEvent = e;
232            if (item.dispatchEvent(activationEvent)) {
233              if (item.command)
234                item.command.execute();
235            }
236          }
237          return true;
238      }
239
240      return false;
241    },
242
243    /**
244     * Updates menu items command according to context.
245     * @param {Node=} node Node for which to actuate commands state.
246     */
247    updateCommands: function(node) {
248      var menuItems = this.menuItems;
249
250      for (var i = 0, menuItem; menuItem = menuItems[i]; i++) {
251        if (!menuItem.isSeparator())
252          menuItem.updateCommand(node);
253      }
254    }
255  };
256
257  function selectedIndexChanged(selectedIndex, oldSelectedIndex) {
258    var oldSelectedItem = this.menuItems[oldSelectedIndex];
259    if (oldSelectedItem) {
260      oldSelectedItem.selected = false;
261      oldSelectedItem.blur();
262    }
263    var item = this.selectedItem;
264    if (item)
265      item.selected = true;
266  }
267
268  /**
269   * The selected menu item.
270   * @type {number}
271   */
272  cr.defineProperty(Menu, 'selectedIndex', cr.PropertyKind.JS,
273      selectedIndexChanged);
274
275  // Export
276  return {
277    Menu: Menu
278  };
279});
280