[ Index ] |
PHP Cross Reference of Unnamed Project |
[Summary view] [Print] [Text view]
1 /** 2 * Provides drop down menus for list of action links. 3 * 4 * @module moodle-core-actionmenu 5 */ 6 7 var BODY = Y.one(Y.config.doc.body), 8 CSS = { 9 MENUSHOWN: 'action-menu-shown' 10 }, 11 SELECTOR = { 12 CAN_RECEIVE_FOCUS_SELECTOR: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]', 13 MENU: '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]', 14 MENUBAR: '[role="menubar"]', 15 MENUITEM: '[role="menuitem"]', 16 MENUCONTENT: '.menu[data-rel=menu-content]', 17 MENUCONTENTCHILD: 'li a', 18 MENUCHILD: '.menu li a', 19 TOGGLE: '.toggle-display', 20 KEEPOPEN: '[data-keepopen="1"]', 21 MENUBARITEMS: [ 22 '[role="menubar"] > [role="menuitem"]', 23 '[role="menubar"] > [role="presentation"] > [role="menuitem"]' 24 ], 25 MENUITEMS: [ 26 '> [role="menuitem"]', 27 '> [role="presentation"] > [role="menuitem"]' 28 ] 29 }, 30 ACTIONMENU, 31 ALIGN = { 32 TL: 'tl', 33 TR: 'tr', 34 BL: 'bl', 35 BR: 'br' 36 }; 37 38 /** 39 * Action menu support. 40 * This converts a generic list of links into a drop down menu opened by hovering or clicking 41 * on a menu icon. 42 * 43 * @namespace M.core.actionmenu 44 * @class ActionMenu 45 * @constructor 46 * @extends Base 47 */ 48 ACTIONMENU = function() { 49 ACTIONMENU.superclass.constructor.apply(this, arguments); 50 }; 51 ACTIONMENU.prototype = { 52 53 /** 54 * The dialogue used for all action menu displays. 55 * @property type 56 * @type M.core.dialogue 57 * @protected 58 */ 59 dialogue: null, 60 61 /** 62 * An array of events attached during the display of the dialogue. 63 * @property events 64 * @type Object 65 * @protected 66 */ 67 events: [], 68 69 /** 70 * The node that owns the currently displayed menu. 71 * 72 * @property owner 73 * @type Node 74 * @default null 75 */ 76 owner: null, 77 78 /** 79 * The menu button that toggles this open. 80 * 81 * @property menulink 82 * @type Node 83 * @protected 84 */ 85 menulink: null, 86 87 /** 88 * The set of menu nodes. 89 * 90 * @property menuChildren 91 * @type NodeList 92 * @protected 93 */ 94 menuChildren: null, 95 96 /** 97 * The first menu item. 98 * 99 * @property firstMenuChild 100 * @type Node 101 * @protected 102 */ 103 firstMenuChild: null, 104 105 /** 106 * The last menu item. 107 * 108 * @property lastMenuChild 109 * @type Node 110 * @protected 111 */ 112 lastMenuChild: null, 113 114 /** 115 * Called during the initialisation process of the object. 116 * 117 * @method initializer 118 */ 119 initializer: function() { 120 Y.log('Initialising the action menu manager', 'debug', ACTIONMENU.NAME); 121 Y.all(SELECTOR.MENU).each(this.enhance, this); 122 BODY.delegate('key', this.moveMenuItem, 'down:37,39', SELECTOR.MENUBARITEMS.join(','), this); 123 124 BODY.delegate('click', this.toggleMenu, SELECTOR.MENU + ' ' + SELECTOR.TOGGLE, this); 125 BODY.delegate('key', this.showIfHidden, 'down:enter,38,40', SELECTOR.MENU + ' ' + SELECTOR.TOGGLE, this); 126 127 // Ensure that we toggle on menuitems when the spacebar is pressed. 128 BODY.delegate('key', function(e) { 129 e.currentTarget.simulate('click'); 130 e.preventDefault(); 131 }, 'down:32', SELECTOR.MENUBARITEMS.join(',')); 132 }, 133 134 /** 135 * Enhances a menu adding aria attributes and flagging it as functional. 136 * 137 * @method enhance 138 * @param {Node} menu 139 * @return boolean 140 */ 141 enhance: function(menu) { 142 var menucontent = menu.one(SELECTOR.MENUCONTENT), 143 align; 144 if (!menucontent) { 145 return false; 146 } 147 align = menucontent.getData('align') || this.get('align').join('-'); 148 menu.one(SELECTOR.TOGGLE).set('aria-haspopup', true); 149 menucontent.set('aria-hidden', true); 150 if (!menucontent.hasClass('align-' + align)) { 151 menucontent.addClass('align-' + align); 152 } 153 if (menucontent.hasChildNodes()) { 154 menu.setAttribute('data-enhanced', '1'); 155 } 156 }, 157 158 /** 159 * Handle movement between menu items in a menubar. 160 * 161 * @method moveMenuItem 162 * @param {EventFacade} e The event generating the move request 163 * @chainable 164 */ 165 moveMenuItem: function(e) { 166 var nextFocus, 167 menuitem = e.target.ancestor(SELECTOR.MENUITEM, true); 168 169 if (e.keyCode === 37) { 170 nextFocus = this.getMenuItem(menuitem, true); 171 } else if (e.keyCode === 39) { 172 nextFocus = this.getMenuItem(menuitem); 173 } 174 175 if (nextFocus) { 176 nextFocus.focus(); 177 } 178 return this; 179 }, 180 181 /** 182 * Get the next menuitem in a menubar. 183 * 184 * @method getMenuItem 185 * @param {Node} currentItem The currently focused item in the menubar 186 * @param {Boolean} [previous=false] Move backwards in the menubar instead of forwards 187 * @return {Node|null} The next item, or null if none was found 188 */ 189 getMenuItem: function(currentItem, previous) { 190 var menubar = currentItem.ancestor(SELECTOR.MENUBAR), 191 menuitems, 192 next; 193 194 if (!menubar) { 195 return null; 196 } 197 198 menuitems = menubar.all(SELECTOR.MENUITEMS.join(',')); 199 200 if (!menuitems) { 201 return null; 202 } 203 204 var childCount = menuitems.size(); 205 206 if (childCount === 1) { 207 // Only one item, exit now because we should already be on it. 208 return null; 209 } 210 211 // Determine the next child. 212 var index = 0, 213 direction = 1, 214 checkCount = 0; 215 216 // Work out the index of the currently selected item. 217 for (index = 0; index < childCount; index++) { 218 if (menuitems.item(index) === currentItem) { 219 break; 220 } 221 } 222 223 // Check that the menu item was found - otherwise return null. 224 if (menuitems.item(index) !== currentItem) { 225 return null; 226 } 227 228 // Reverse the direction if we want the previous item. 229 if (previous) { 230 direction = -1; 231 } 232 233 do { 234 // Update the index in the direction of travel. 235 index += direction; 236 237 next = menuitems.item(index); 238 239 // Check that we don't loop multiple times. 240 checkCount++; 241 } while (next && next.hasAttribute('hidden')); 242 243 return next; 244 }, 245 246 /** 247 * Hides the menu if it is visible. 248 * @param {EventFacade} e 249 * @method hideMenu 250 */ 251 hideMenu: function(e) { 252 if (this.dialogue) { 253 Y.log('Hiding an action menu', 'debug', ACTIONMENU.NAME); 254 this.dialogue.removeClass('show'); 255 this.dialogue.one(SELECTOR.MENUCONTENT).set('aria-hidden', true); 256 this.dialogue = null; 257 } 258 for (var i in this.events) { 259 if (this.events[i].detach) { 260 this.events[i].detach(); 261 } 262 } 263 this.events = []; 264 if (this.owner) { 265 this.owner.removeClass(CSS.MENUSHOWN); 266 this.owner = null; 267 } 268 269 if (this.menulink) { 270 if (e.type != 'click') { 271 this.menulink.focus(); 272 } 273 this.menulink = null; 274 } 275 }, 276 277 showIfHidden: function(e) { 278 var menu = e.target.ancestor(SELECTOR.MENU), 279 menuvisible = (menu.hasClass('show')); 280 281 if (!menuvisible) { 282 e.preventDefault(); 283 this.showMenu(e, menu); 284 } 285 return this; 286 }, 287 288 /** 289 * Toggles the display of the menu. 290 * @method toggleMenu 291 * @param {EventFacade} e 292 */ 293 toggleMenu: function(e) { 294 var menu = e.target.ancestor(SELECTOR.MENU), 295 menuvisible = (menu.hasClass('show')); 296 297 // Prevent event propagation as it will trigger the hideIfOutside event handler in certain situations. 298 e.halt(true); 299 this.hideMenu(e); 300 if (menuvisible) { 301 // The menu was visible and the user has clicked to toggle it again. 302 return; 303 } 304 this.showMenu(e, menu); 305 }, 306 307 /** 308 * Handle keyboard events when the menu is open. We respond to: 309 * * escape (exit) 310 * * tab (move to next menu item) 311 * * up/down (move to previous/next menu item) 312 * 313 * @method handleKeyboardEvent 314 * @param {EventFacade} e The key event 315 */ 316 handleKeyboardEvent: function(e) { 317 var next; 318 var markEventHandled = function(e) { 319 e.preventDefault(); 320 e.stopPropagation(); 321 }; 322 323 // Handle when the menu is still selected. 324 if (e.currentTarget.ancestor(SELECTOR.TOGGLE, true)) { 325 if ((e.keyCode === 40 || (e.keyCode === 9 && !e.shiftKey)) && this.firstMenuChild) { 326 this.firstMenuChild.focus(); 327 markEventHandled(e); 328 } else if (e.keyCode === 38 && this.lastMenuChild) { 329 this.lastMenuChild.focus(); 330 markEventHandled(e); 331 } else if (e.keyCode === 9 && e.shiftKey) { 332 this.hideMenu(e); 333 markEventHandled(e); 334 } 335 return this; 336 } 337 338 if (e.keyCode === 27) { 339 // The escape key was pressed so close the menu. 340 this.hideMenu(e); 341 markEventHandled(e); 342 343 } else if (e.keyCode === 32) { 344 // The space bar was pressed. Trigger a click. 345 markEventHandled(e); 346 e.currentTarget.simulate('click'); 347 } else if (e.keyCode === 9) { 348 // The tab key was pressed. Tab moves forwards, Shift + Tab moves backwards through the menu options. 349 // We only override the Shift + Tab on the first option, and Tab on the last option to change where the 350 // focus is moved to. 351 if (e.target === this.firstMenuChild && e.shiftKey) { 352 this.hideMenu(e); 353 markEventHandled(e); 354 } else if (e.target === this.lastMenuChild && !e.shiftKey) { 355 if (this.hideMenu(e)) { 356 // Determine the next selector and focus on it. 357 next = this.menulink.next(SELECTOR.CAN_RECEIVE_FOCUS_SELECTOR); 358 if (next) { 359 next.focus(); 360 markEventHandled(e); 361 } 362 } 363 } 364 365 } else if (e.keyCode === 38 || e.keyCode === 40) { 366 // The up (38) or down (40) key was pushed. 367 // On cursor moves we loops through the menu rather than exiting it as in the tab behaviour. 368 var found = false, 369 index = 0, 370 direction = 1, 371 checkCount = 0; 372 373 // Determine which menu item is currently selected. 374 while (!found && index < this.menuChildren.size()) { 375 if (this.menuChildren.item(index) === e.currentTarget) { 376 found = true; 377 } else { 378 index++; 379 } 380 } 381 382 if (!found) { 383 Y.log("Unable to find this menu item in the list of menu children", 'debug', 'moodle-core-actionmenu'); 384 return; 385 } 386 387 if (e.keyCode === 38) { 388 // Moving up so reverse the direction. 389 direction = -1; 390 } 391 392 // Try to find the next 393 do { 394 index += direction; 395 if (index < 0) { 396 index = this.menuChildren.size() - 1; 397 } else if (index >= this.menuChildren.size()) { 398 // Handle wrapping. 399 index = 0; 400 } 401 next = this.menuChildren.item(index); 402 403 // Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item. 404 checkCount++; 405 } while (checkCount < this.menuChildren.size() && next !== e.currentTarget && next.hasClass('hidden')); 406 407 if (next) { 408 next.focus(); 409 markEventHandled(e); 410 } 411 } 412 }, 413 414 /** 415 * Hides the menu if the event happened outside the menu. 416 * 417 * @protected 418 * @method hideIfOutside 419 * @param {EventFacade} e 420 */ 421 hideIfOutside: function(e) { 422 if (!e.target.ancestor(SELECTOR.MENUCONTENT, true)) { 423 this.hideMenu(e); 424 } 425 }, 426 427 /** 428 * Displays the menu with the given content and alignment. 429 * 430 * @method showMenu 431 * @param {EventFacade} e 432 * @param {Node} menu 433 * @return M.core.dialogue 434 */ 435 showMenu: function(e, menu) { 436 Y.log('Displaying an action menu', 'debug', ACTIONMENU.NAME); 437 var ownerselector = menu.getData('owner'), 438 menucontent = menu.one(SELECTOR.MENUCONTENT); 439 this.owner = (ownerselector) ? menu.ancestor(ownerselector) : null; 440 this.dialogue = menu; 441 menu.addClass('show'); 442 if (this.owner) { 443 this.owner.addClass(CSS.MENUSHOWN); 444 this.menulink = this.owner.one(SELECTOR.TOGGLE); 445 } else { 446 this.menulink = e.target.ancestor(SELECTOR.TOGGLE, true); 447 } 448 this.constrain(menucontent.set('aria-hidden', false)); 449 450 this.menuChildren = this.dialogue.all(SELECTOR.MENUCHILD); 451 if (this.menuChildren) { 452 this.firstMenuChild = this.menuChildren.item(0); 453 this.lastMenuChild = this.menuChildren.item(this.menuChildren.size() - 1); 454 455 this.firstMenuChild.focus(); 456 } 457 458 // Close the menu if the user presses escape. 459 this.events.push(BODY.on('key', this.hideMenu, 'esc', this)); 460 461 // Close the menu if the user clicks outside the menu. 462 this.events.push(BODY.on('click', this.hideIfOutside, this)); 463 464 // Close the menu if the user focuses outside the menu. 465 this.events.push(BODY.delegate('focus', this.hideIfOutside, '*', this)); 466 467 // Check keyboard changes. 468 this.events.push( 469 menu.delegate('key', this.handleKeyboardEvent, 470 'down:9, 27, 38, 40, 32', SELECTOR.MENUCHILD + ', ' + SELECTOR.TOGGLE, this) 471 ); 472 473 // Close the menu after a button was pushed. 474 this.events.push(menu.delegate('click', function(e) { 475 if (e.currentTarget.test(SELECTOR.KEEPOPEN)) { 476 return; 477 } 478 this.hideMenu(e); 479 }, SELECTOR.MENUCHILD, this)); 480 481 return true; 482 }, 483 484 /** 485 * Constrains the node to its the page width. 486 * 487 * @method constrain 488 * @param {Node} node 489 */ 490 constrain: function(node) { 491 var selector = node.getData('constraint'), 492 nx = node.getX(), 493 ny = node.getY(), 494 nwidth = node.get('offsetWidth'), 495 nheight = node.get('offsetHeight'), 496 cx = 0, 497 cy = 0, 498 cwidth, 499 cheight, 500 coverflow = 'auto', 501 newwidth = null, 502 newheight = null, 503 newleft = null, 504 newtop = null, 505 boxshadow = null; 506 507 if (selector) { 508 selector = node.ancestor(selector); 509 } 510 if (selector) { 511 cwidth = selector.get('offsetWidth'); 512 cheight = selector.get('offsetHeight'); 513 cx = selector.getX(); 514 cy = selector.getY(); 515 coverflow = selector.getStyle('overflow') || 'auto'; 516 } else { 517 cwidth = node.get('docWidth'); 518 cheight = node.get('docHeight'); 519 } 520 521 // Constrain X. 522 // First up if the width is more than the constrain its easily full width + full height. 523 if (nwidth > cwidth) { 524 // The width of the constraint. 525 newwidth = nwidth = cwidth; 526 // The constraints xpoint. 527 newleft = nx = cx; 528 } else { 529 if (nx < cx) { 530 // If nx is less than cx we need to move it right. 531 newleft = nx = cx; 532 } else if (nx + nwidth >= cx + cwidth) { 533 // The top right of the node is outside of the constraint, move it in. 534 newleft = cx + cwidth - nwidth; 535 } 536 } 537 538 // Constrain Y. 539 if (nheight > cheight && coverflow.toLowerCase() === 'hidden') { 540 // The node extends over the constrained area and would be clipped. 541 // Reduce the height of the node and force its overflow to scroll. 542 newheight = nheight = cheight; 543 node.setStyle('overflow', 'auto'); 544 } 545 // If the node is below the top of the constraint AND 546 // the node is longer than the constraint allows. 547 if (ny >= cy && ny + nheight > cy + cheight) { 548 // Move it up. 549 newtop = cy + cheight - nheight; 550 try { 551 boxshadow = node.getStyle('boxShadow').replace(/.*? (\d+)px \d+px$/, '$1'); 552 if (new RegExp(/^\d+$/).test(boxshadow) && newtop - cy > boxshadow) { 553 newtop -= boxshadow; 554 } 555 } catch (ex) { 556 Y.log('Failed to determine box-shadow margin.', 'warn', ACTIONMENU.NAME); 557 } 558 } 559 560 if (newleft !== null) { 561 node.setX(newleft); 562 } 563 if (newtop !== null) { 564 node.setY(newtop); 565 } 566 if (newwidth !== null) { 567 node.setStyle('width', newwidth.toString() + 'px'); 568 } 569 if (newheight !== null) { 570 node.setStyle('height', newheight.toString() + 'px'); 571 } 572 } 573 }; 574 575 Y.extend(ACTIONMENU, Y.Base, ACTIONMENU.prototype, { 576 NAME: 'moodle-core-actionmenu', 577 ATTRS: { 578 align: { 579 value: [ 580 ALIGN.TR, // The dialogue. 581 ALIGN.BR // The button 582 ] 583 } 584 } 585 }); 586 587 M.core = M.core || {}; 588 M.core.actionmenu = M.core.actionmenu || {}; 589 590 /** 591 * 592 * @static 593 * @property M.core.actionmenu.instance 594 * @type {ACTIONMENU} 595 */ 596 M.core.actionmenu.instance = null; 597 598 /** 599 * Init function - will only ever create one instance of the actionmenu class. 600 * 601 * @method M.core.actionmenu.init 602 * @static 603 * @param {Object} params 604 */ 605 M.core.actionmenu.init = M.core.actionmenu.init || function(params) { 606 M.core.actionmenu.instance = M.core.actionmenu.instance || new ACTIONMENU(params); 607 }; 608 609 /** 610 * Registers a new DOM node with the action menu causing it to be enhanced if required. 611 * 612 * @method M.core.actionmenu.newDOMNode 613 * @param node 614 * @return {boolean} 615 */ 616 M.core.actionmenu.newDOMNode = function(node) { 617 if (M.core.actionmenu.instance === null) { 618 return true; 619 } 620 node.all(SELECTOR.MENU).each(M.core.actionmenu.instance.enhance, M.core.actionmenu.instance); 621 };
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Thu Aug 11 10:00:09 2016 | Cross-referenced by PHPXref 0.7.1 |