attachablemenu.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. // Copyright 2006 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. /**
  15. * @fileoverview Definition of the AttachableMenu class.
  16. *
  17. */
  18. goog.provide('goog.ui.AttachableMenu');
  19. goog.require('goog.a11y.aria');
  20. goog.require('goog.a11y.aria.State');
  21. goog.require('goog.array');
  22. goog.require('goog.asserts');
  23. goog.require('goog.dom');
  24. goog.require('goog.dom.classlist');
  25. goog.require('goog.events.Event');
  26. goog.require('goog.events.KeyCodes');
  27. goog.require('goog.string');
  28. goog.require('goog.style');
  29. goog.require('goog.ui.ItemEvent');
  30. goog.require('goog.ui.MenuBase');
  31. goog.require('goog.ui.PopupBase');
  32. goog.require('goog.userAgent');
  33. /**
  34. * An implementation of a menu that can attach itself to DOM element that
  35. * are annotated appropriately.
  36. *
  37. * The following attributes are used by the AttachableMenu
  38. *
  39. * menu-item - Should be set on DOM elements that function as items in the
  40. * menu that can be selected.
  41. * classNameSelected - A class that will be added to the element's class names
  42. * when the item is selected via keyboard or mouse.
  43. *
  44. * @param {Element=} opt_element A DOM element for the popup.
  45. * @constructor
  46. * @extends {goog.ui.MenuBase}
  47. * @deprecated Use goog.ui.PopupMenu.
  48. * @final
  49. */
  50. goog.ui.AttachableMenu = function(opt_element) {
  51. goog.ui.MenuBase.call(this, opt_element);
  52. };
  53. goog.inherits(goog.ui.AttachableMenu, goog.ui.MenuBase);
  54. goog.tagUnsealableClass(goog.ui.AttachableMenu);
  55. /**
  56. * The currently selected element (mouse was moved over it or keyboard arrows)
  57. * @type {HTMLElement}
  58. * @private
  59. */
  60. goog.ui.AttachableMenu.prototype.selectedElement_ = null;
  61. /**
  62. * Class name to append to a menu item's class when it's selected
  63. * @type {string}
  64. * @private
  65. */
  66. goog.ui.AttachableMenu.prototype.itemClassName_ = 'menu-item';
  67. /**
  68. * Class name to append to a menu item's class when it's selected
  69. * @type {string}
  70. * @private
  71. */
  72. goog.ui.AttachableMenu.prototype.selectedItemClassName_ = 'menu-item-selected';
  73. /**
  74. * Keep track of when the last key was pressed so that a keydown-scroll doesn't
  75. * trigger a mouseover event
  76. * @type {number}
  77. * @private
  78. */
  79. goog.ui.AttachableMenu.prototype.lastKeyDown_ = goog.now();
  80. /** @override */
  81. goog.ui.AttachableMenu.prototype.disposeInternal = function() {
  82. goog.ui.AttachableMenu.superClass_.disposeInternal.call(this);
  83. this.selectedElement_ = null;
  84. };
  85. /**
  86. * Sets the class name to use for menu items
  87. *
  88. * @return {string} The class name to use for items.
  89. */
  90. goog.ui.AttachableMenu.prototype.getItemClassName = function() {
  91. return this.itemClassName_;
  92. };
  93. /**
  94. * Sets the class name to use for menu items
  95. *
  96. * @param {string} name The class name to use for items.
  97. */
  98. goog.ui.AttachableMenu.prototype.setItemClassName = function(name) {
  99. this.itemClassName_ = name;
  100. };
  101. /**
  102. * Sets the class name to use for selected menu items
  103. * todo(user) - reevaluate if we can simulate pseudo classes in IE
  104. *
  105. * @return {string} The class name to use for selected items.
  106. */
  107. goog.ui.AttachableMenu.prototype.getSelectedItemClassName = function() {
  108. return this.selectedItemClassName_;
  109. };
  110. /**
  111. * Sets the class name to use for selected menu items
  112. * todo(user) - reevaluate if we can simulate pseudo classes in IE
  113. *
  114. * @param {string} name The class name to use for selected items.
  115. */
  116. goog.ui.AttachableMenu.prototype.setSelectedItemClassName = function(name) {
  117. this.selectedItemClassName_ = name;
  118. };
  119. /**
  120. * Returns the selected item
  121. *
  122. * @return {Element} The item selected or null if no item is selected.
  123. * @override
  124. */
  125. goog.ui.AttachableMenu.prototype.getSelectedItem = function() {
  126. return this.selectedElement_;
  127. };
  128. /** @override */
  129. goog.ui.AttachableMenu.prototype.setSelectedItem = function(obj) {
  130. var elt = /** @type {HTMLElement} */ (obj);
  131. if (this.selectedElement_) {
  132. goog.dom.classlist.remove(
  133. this.selectedElement_, this.selectedItemClassName_);
  134. }
  135. this.selectedElement_ = elt;
  136. var el = /** @type {HTMLElement} */ (this.getElement());
  137. goog.asserts.assert(el, 'The attachable menu DOM element cannot be null.');
  138. if (this.selectedElement_) {
  139. goog.dom.classlist.add(this.selectedElement_, this.selectedItemClassName_);
  140. if (elt.id) {
  141. // Update activedescendant to reflect the new selection. ARIA roles for
  142. // menu and menuitem can be set statically (through Soy templates, for
  143. // example) whereas this needs to be updated as the selection changes.
  144. goog.a11y.aria.setState(
  145. el, goog.a11y.aria.State.ACTIVEDESCENDANT, elt.id);
  146. }
  147. var top = this.selectedElement_.offsetTop;
  148. var height = this.selectedElement_.offsetHeight;
  149. var scrollTop = el.scrollTop;
  150. var scrollHeight = el.offsetHeight;
  151. // If the menu is scrollable this scrolls the selected item into view
  152. // (this has no effect when the menu doesn't scroll)
  153. if (top < scrollTop) {
  154. el.scrollTop = top;
  155. } else if (top + height > scrollTop + scrollHeight) {
  156. el.scrollTop = top + height - scrollHeight;
  157. }
  158. } else {
  159. // Clear off activedescendant to reflect no selection.
  160. goog.a11y.aria.setState(el, goog.a11y.aria.State.ACTIVEDESCENDANT, '');
  161. }
  162. };
  163. /** @override */
  164. goog.ui.AttachableMenu.prototype.showPopupElement = function() {
  165. // The scroll position cannot be set for hidden (display: none) elements in
  166. // gecko browsers.
  167. var el = /** @type {Element} */ (this.getElement());
  168. goog.style.setElementShown(el, true);
  169. el.scrollTop = 0;
  170. el.style.visibility = 'visible';
  171. };
  172. /**
  173. * Called after the menu is shown.
  174. * @protected
  175. * @override
  176. */
  177. goog.ui.AttachableMenu.prototype.onShow = function() {
  178. goog.ui.AttachableMenu.superClass_.onShow.call(this);
  179. // In IE, focusing the menu causes weird scrolling to happen. Focusing the
  180. // first child makes the scroll behavior better, and the key handling still
  181. // works. In FF, focusing the first child causes us to lose key events, so we
  182. // still focus the menu.
  183. var el = this.getElement();
  184. goog.userAgent.IE ? el.firstChild.focus() : el.focus();
  185. };
  186. /**
  187. * Returns the next or previous item. Used for up/down arrows.
  188. *
  189. * @param {boolean} prev True to go to the previous element instead of next.
  190. * @return {Element} The next or previous element.
  191. * @protected
  192. */
  193. goog.ui.AttachableMenu.prototype.getNextPrevItem = function(prev) {
  194. // first find the index of the next element
  195. var elements = this.getElement().getElementsByTagName('*');
  196. var elementCount = elements.length;
  197. var index;
  198. // if there is a selected element, find its index and then inc/dec by one
  199. if (this.selectedElement_) {
  200. for (var i = 0; i < elementCount; i++) {
  201. if (elements[i] == this.selectedElement_) {
  202. index = prev ? i - 1 : i + 1;
  203. break;
  204. }
  205. }
  206. }
  207. // if no selected element, start from beginning or end
  208. if (!goog.isDef(index)) {
  209. index = prev ? elementCount - 1 : 0;
  210. }
  211. // iterate forward or backwards through the elements finding the next
  212. // menu item
  213. for (var i = 0; i < elementCount; i++) {
  214. var multiplier = prev ? -1 : 1;
  215. var nextIndex = index + (multiplier * i) % elementCount;
  216. // if overflowed/underflowed, wrap around
  217. if (nextIndex < 0) {
  218. nextIndex += elementCount;
  219. } else if (nextIndex >= elementCount) {
  220. nextIndex -= elementCount;
  221. }
  222. if (this.isMenuItem_(elements[nextIndex])) {
  223. return elements[nextIndex];
  224. }
  225. }
  226. return null;
  227. };
  228. /**
  229. * Mouse over handler for the menu.
  230. * @param {goog.events.Event} e The event object.
  231. * @protected
  232. * @override
  233. */
  234. goog.ui.AttachableMenu.prototype.onMouseOver = function(e) {
  235. var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  236. if (eltItem == null) {
  237. return;
  238. }
  239. // Stop the keydown triggering a mouseover in FF.
  240. if (goog.now() - this.lastKeyDown_ > goog.ui.PopupBase.DEBOUNCE_DELAY_MS) {
  241. this.setSelectedItem(eltItem);
  242. }
  243. };
  244. /**
  245. * Mouse out handler for the menu.
  246. * @param {goog.events.Event} e The event object.
  247. * @protected
  248. * @override
  249. */
  250. goog.ui.AttachableMenu.prototype.onMouseOut = function(e) {
  251. var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  252. if (eltItem == null) {
  253. return;
  254. }
  255. // Stop the keydown triggering a mouseout in FF.
  256. if (goog.now() - this.lastKeyDown_ > goog.ui.PopupBase.DEBOUNCE_DELAY_MS) {
  257. this.setSelectedItem(null);
  258. }
  259. };
  260. /**
  261. * Mouse down handler for the menu. Prevents default to avoid text selection.
  262. * @param {!goog.events.Event} e The event object.
  263. * @protected
  264. * @override
  265. */
  266. goog.ui.AttachableMenu.prototype.onMouseDown = goog.events.Event.preventDefault;
  267. /**
  268. * Mouse up handler for the menu.
  269. * @param {goog.events.Event} e The event object.
  270. * @protected
  271. * @override
  272. */
  273. goog.ui.AttachableMenu.prototype.onMouseUp = function(e) {
  274. var eltItem = this.getAncestorMenuItem_(/** @type {Element} */ (e.target));
  275. if (eltItem == null) {
  276. return;
  277. }
  278. this.setVisible(false);
  279. this.onItemSelected_(eltItem);
  280. };
  281. /**
  282. * Key down handler for the menu.
  283. * @param {goog.events.KeyEvent} e The event object.
  284. * @protected
  285. * @override
  286. */
  287. goog.ui.AttachableMenu.prototype.onKeyDown = function(e) {
  288. switch (e.keyCode) {
  289. case goog.events.KeyCodes.DOWN:
  290. this.setSelectedItem(this.getNextPrevItem(false));
  291. this.lastKeyDown_ = goog.now();
  292. break;
  293. case goog.events.KeyCodes.UP:
  294. this.setSelectedItem(this.getNextPrevItem(true));
  295. this.lastKeyDown_ = goog.now();
  296. break;
  297. case goog.events.KeyCodes.ENTER:
  298. if (this.selectedElement_) {
  299. this.onItemSelected_();
  300. this.setVisible(false);
  301. }
  302. break;
  303. case goog.events.KeyCodes.ESC:
  304. this.setVisible(false);
  305. break;
  306. default:
  307. if (e.charCode) {
  308. var charStr = String.fromCharCode(e.charCode);
  309. this.selectByName_(charStr, 1, true);
  310. }
  311. break;
  312. }
  313. // Prevent the browser's default keydown behaviour when the menu is open,
  314. // e.g. keyboard scrolling.
  315. e.preventDefault();
  316. // Stop propagation to prevent application level keyboard shortcuts from
  317. // firing.
  318. e.stopPropagation();
  319. this.dispatchEvent(e);
  320. };
  321. /**
  322. * Find an item that has the given prefix and select it.
  323. *
  324. * @param {string} prefix The entered prefix, so far.
  325. * @param {number=} opt_direction 1 to search forward from the selection
  326. * (default), -1 to search backward (e.g. to go to the previous match).
  327. * @param {boolean=} opt_skip True if should skip the current selection,
  328. * unless no other item has the given prefix.
  329. * @private
  330. */
  331. goog.ui.AttachableMenu.prototype.selectByName_ = function(
  332. prefix, opt_direction, opt_skip) {
  333. var elements = this.getElement().getElementsByTagName('*');
  334. var elementCount = elements.length;
  335. var index;
  336. if (elementCount == 0) {
  337. return;
  338. }
  339. if (!this.selectedElement_ ||
  340. (index = goog.array.indexOf(elements, this.selectedElement_)) == -1) {
  341. // no selection or selection isn't known => start at the beginning
  342. index = 0;
  343. }
  344. var start = index;
  345. var re = new RegExp('^' + goog.string.regExpEscape(prefix), 'i');
  346. var skip = opt_skip && this.selectedElement_;
  347. var dir = opt_direction || 1;
  348. do {
  349. if (elements[index] != skip && this.isMenuItem_(elements[index])) {
  350. var name = goog.dom.getTextContent(elements[index]);
  351. if (name.match(re)) {
  352. break;
  353. }
  354. }
  355. index += dir;
  356. if (index == elementCount) {
  357. index = 0;
  358. } else if (index < 0) {
  359. index = elementCount - 1;
  360. }
  361. } while (index != start);
  362. if (this.selectedElement_ != elements[index]) {
  363. this.setSelectedItem(elements[index]);
  364. }
  365. };
  366. /**
  367. * Dispatch an ITEM_ACTION event when an item is selected
  368. * @param {Object=} opt_item Item selected.
  369. * @private
  370. */
  371. goog.ui.AttachableMenu.prototype.onItemSelected_ = function(opt_item) {
  372. this.dispatchEvent(
  373. new goog.ui.ItemEvent(
  374. goog.ui.MenuBase.Events.ITEM_ACTION, this,
  375. opt_item || this.selectedElement_));
  376. };
  377. /**
  378. * Returns whether the specified element is a menu item.
  379. * @param {Element} elt The element to find a menu item ancestor of.
  380. * @return {boolean} Whether the specified element is a menu item.
  381. * @private
  382. */
  383. goog.ui.AttachableMenu.prototype.isMenuItem_ = function(elt) {
  384. return !!elt && goog.dom.classlist.contains(elt, this.itemClassName_);
  385. };
  386. /**
  387. * Returns the menu-item scoping the specified element, or null if there is
  388. * none.
  389. * @param {Element|undefined} elt The element to find a menu item ancestor of.
  390. * @return {Element} The menu-item scoping the specified element, or null if
  391. * there is none.
  392. * @private
  393. */
  394. goog.ui.AttachableMenu.prototype.getAncestorMenuItem_ = function(elt) {
  395. if (elt) {
  396. var ownerDocumentBody = goog.dom.getOwnerDocument(elt).body;
  397. while (elt != null && elt != ownerDocumentBody) {
  398. if (this.isMenuItem_(elt)) {
  399. return elt;
  400. }
  401. elt = /** @type {Element} */ (elt.parentNode);
  402. }
  403. }
  404. return null;
  405. };