| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- /**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup UnaStudio UNA Studio
- * @{
- */
- /**
- * Context menus. An element with data-bx-context-menu="#popup" opens that popup (a transBox holding a [role="menu"] list,
- * see studio/template/menu_context.html) on right-click, on the keyboard's Menu key or Shift+F10 (both fire 'contextmenu')
- * and on a long press on a touch screen; open(oTrigger) opens it from a click on a visible button. The popup plugin
- * handles the outside click, Escape and the fade; this adds the ARIA menu behaviour: focus lands on the first item,
- * arrows, Home and End move, Tab leaves, checkbox and radio items keep their state, and focus returns to the trigger.
- */
- function BxDolStudioContextMenu() {
- this.sAttr = 'data-bx-context-menu';
- this.sItems = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
- this.iPressDelay = 500;
- this.iGap = 4; // between the trigger and the menu
- this.oTrigger = null;
- this.oPopup = null;
- this._iPressTimer = 0;
- this._iPressedAt = 0;
- this.init();
- }
- BxDolStudioContextMenu.prototype.init = function() {
- var $this = this;
- document.addEventListener('contextmenu', function(oEvent) {
- var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
- if(!oTrigger)
- return;
- oEvent.preventDefault();
- if(Date.now() - $this._iPressedAt < 800)
- return; // the long press has already opened it (Android fires contextmenu as well)
- $this.open(oTrigger);
- });
- //--- Touch: a long press (iOS never fires contextmenu), cancelled by lifting or moving the finger.
- document.addEventListener('pointerdown', function(oEvent) {
- if(oEvent.pointerType != 'touch')
- return;
- var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
- if(!oTrigger)
- return;
- clearTimeout($this._iPressTimer);
- $this._iPressTimer = setTimeout(function() {
- $this._iPressedAt = Date.now();
- $this.open(oTrigger);
- }, $this.iPressDelay);
- });
- ['pointerup', 'pointercancel', 'pointermove'].forEach(function(sEvent) {
- document.addEventListener(sEvent, function() {
- clearTimeout($this._iPressTimer);
- });
- });
- document.addEventListener('click', function(oEvent) {
- // the tap that ends a long press must not follow the trigger's link
- if(Date.now() - $this._iPressedAt < 800 && oEvent.target.closest('[' + $this.sAttr + ']'))
- oEvent.preventDefault();
- }, true);
- //--- Inside the menu: arrows move between the items, Tab or Escape leaves, an activated item closes it.
- document.addEventListener('keydown', function(oEvent) {
- var oMenu = oEvent.target.closest('[role="menu"]');
- if(!oMenu)
- return;
- var aItems = $this.getItems(oMenu);
- var iIndex = aItems.indexOf(document.activeElement);
- var iCount = aItems.length;
- switch(oEvent.key) {
- case 'ArrowDown':
- aItems[(iIndex + 1) % iCount].focus();
- break;
- case 'ArrowUp':
- aItems[(iIndex - 1 + iCount) % iCount].focus();
- break;
- case 'Home':
- aItems[0].focus();
- break;
- case 'End':
- aItems[iCount - 1].focus();
- break;
- case 'Tab':
- case 'Escape':
- $this.close();
- break;
- default:
- return;
- }
- oEvent.preventDefault();
- });
- document.addEventListener('click', function(oEvent) {
- var oItem = oEvent.target.closest($this.sItems);
- if(!oItem || !oItem.closest('[role="menu"]'))
- return;
- var sRole = oItem.getAttribute('role');
- if(sRole == 'menuitemcheckbox')
- oItem.setAttribute('aria-checked', oItem.getAttribute('aria-checked') == 'true' ? 'false' : 'true');
- else if(sRole == 'menuitemradio') {
- $this.getItems(oItem.closest('[role="menu"]')).forEach(function(oOther) {
- if(oOther.getAttribute('role') == 'menuitemradio')
- oOther.setAttribute('aria-checked', 'false');
- });
- oItem.setAttribute('aria-checked', 'true');
- }
- $this.close();
- });
- };
- /**
- * Open a trigger's menu next to it, however it was asked for (pointer, keyboard, long press, a button's click): 4px under
- * the trigger, flush with its start edge; fit() moves it above, or flush with the end edge, when the window runs out.
- */
- BxDolStudioContextMenu.prototype.open = function(oTrigger) {
- var $this = this;
- var oPopup = $(oTrigger.getAttribute(this.sAttr));
- if(!oPopup.length)
- return this.load(oTrigger);
- // another menu is up: it goes first
- if(this.oPopup && this.oPopup.get(0) !== oPopup.get(0) && this.oPopup.is(':visible'))
- this.oPopup.dolPopupHide();
- this.oTrigger = oTrigger;
- this.oPopup = oPopup;
- var oAnchor = this.getAnchor(oTrigger);
- if(oPopup.is(':visible')) {
- // still fading out, or asked to show a moment ago: the plugin would toggle it, so come back when that is over
- if(!oPopup.hasClass('bx-popup-active')) {
- setTimeout(function() {
- $this.open(oTrigger);
- }, 250);
- return false;
- }
- // the same menu asked for again: it just settles next to its trigger
- oPopup._dolPopupSetPosition({position: 'absolute', left: oAnchor.x, top: oAnchor.y, pointer: false});
- this.fit(oPopup, oTrigger);
- this.focusFirst(oPopup);
- return false;
- }
- oPopup.dolPopup({
- position: 'absolute',
- left: oAnchor.x,
- top: oAnchor.y,
- pointer: false,
- fog: false,
- closeOnOuterClick: true,
- cssClass: 'bx-popup-menu',
- onBeforeShow: function(oShowing) {
- $this.fit(oShowing, $this.oTrigger); // laid out but still invisible here: no jump when it has to flip
- },
- onShow: function(oShown) {
- $this.fit(oShown, $this.oTrigger);
- if($this.oTrigger.hasAttribute('aria-expanded'))
- $this.oTrigger.setAttribute('aria-expanded', 'true');
- $this.focusFirst(oShown);
- },
- onHide: function(oHidden) {
- if($this.oTrigger.hasAttribute('aria-expanded'))
- $this.oTrigger.setAttribute('aria-expanded', 'false');
- // focus went nowhere in particular (the item that was activated is gone from view): back to the trigger
- if(document.activeElement == document.body || oHidden.get(0).contains(document.activeElement))
- $this.oTrigger.focus();
- }
- });
- return false;
- };
- /**
- * A menu that is not on the page yet (launcher tiles, dock items name theirs in data-bx-context-menu-load as "page:tileId"):
- * fetch it from the page's module endpoint, keep it in the document, then open it.
- */
- BxDolStudioContextMenu.prototype.load = function(oTrigger) {
- var $this = this;
- var sLoad = oTrigger.getAttribute(this.sAttr + '-load');
- if(!sLoad || this._bLoading)
- return false;
- var aLoad = sLoad.split(':');
- this._bLoading = true;
- $.get(sUrlStudio + 'module.php', {mod_action: 'context', mod_value: aLoad[0], mod_widget_id: parseInt(aLoad[1]) || 0, _t: Date.now()}, function(oData) {
- if(!oData || oData.code != 0 || !oData.content)
- return;
- $('body').append(oData.content);
- $this.open(oTrigger);
- }, 'json').always(function() {
- $this._bLoading = false;
- });
- return false;
- };
- /**
- * Whether a long press opened a menu just now (the launcher's own long press then leaves edit mode alone).
- */
- BxDolStudioContextMenu.prototype.justOpened = function() {
- return Date.now() - this._iPressedAt < 1500;
- };
- /**
- * Where the menu goes first: 4px under the trigger, flush with its start edge (page coordinates). In RTL the start edge is
- * the right one, which fit() lines the menu up with once its width is known.
- */
- BxDolStudioContextMenu.prototype.getAnchor = function(oTrigger) {
- var oRect = oTrigger.getBoundingClientRect();
- return {x: oRect.left + window.scrollX, y: oRect.bottom + window.scrollY + this.iGap};
- };
- BxDolStudioContextMenu.prototype.isRtl = function() {
- return document.documentElement.dir == 'rtl' || document.body.classList.contains('bx-dir-rtl');
- };
- /**
- * Keep the menu on screen and off its trigger: flush with the trigger's end edge when it would run past the side of the
- * window (start edge in RTL), 4px above the trigger when it would run past the bottom.
- */
- BxDolStudioContextMenu.prototype.fit = function(oPopup, oTrigger) {
- // layout size, not the rendered box: the popup is scaled while it fades in, and this may run mid-fade
- var iWidth = oPopup.get(0).offsetWidth;
- var iHeight = oPopup.get(0).offsetHeight;
- var oTriggerRect = oTrigger.getBoundingClientRect();
- var oCss = {};
- var iLeft = this.isRtl() ? oTriggerRect.right - iWidth : oTriggerRect.left;
- if(iLeft + iWidth > window.innerWidth - 8)
- iLeft = oTriggerRect.right - iWidth;
- if(iLeft < 8)
- iLeft = this.isRtl() ? oTriggerRect.left : 8;
- oCss.left = Math.round(iLeft + window.scrollX);
- oCss.top = Math.round(oTriggerRect.bottom + this.iGap + window.scrollY);
- if(oTriggerRect.bottom + this.iGap + iHeight > window.innerHeight - 8 && oTriggerRect.top - this.iGap - iHeight >= 8)
- oCss.top = Math.round(oTriggerRect.top - this.iGap - iHeight + window.scrollY);
- oPopup.css(oCss);
- };
- BxDolStudioContextMenu.prototype.focusFirst = function(oPopup) {
- var aItems = this.getItems(oPopup.get(0));
- if(aItems.length)
- aItems[0].focus();
- };
- BxDolStudioContextMenu.prototype.getItems = function(oMenu) {
- return Array.prototype.filter.call(oMenu.querySelectorAll(this.sItems), function(oItem) {
- return !oItem.disabled && oItem.getAttribute('aria-disabled') != 'true';
- });
- };
- BxDolStudioContextMenu.prototype.close = function() {
- if(this.oPopup && this.oPopup.is(':visible'))
- this.oPopup.dolPopupHide();
- };
- var oBxDolStudioContextMenu = new BxDolStudioContextMenu();
- /** @} */
|