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