context_menu.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. /**
  2. * Copyright (c) UNA, Inc - https://una.io
  3. * MIT License - https://opensource.org/licenses/MIT
  4. *
  5. * @defgroup UnaStudio UNA Studio
  6. * @{
  7. */
  8. /**
  9. * Context menus. An element with data-bx-context-menu="#popup" opens that popup (a transBox holding a [role="menu"] list,
  10. * see studio/template/menu_context.html) on right-click, on the keyboard's Menu key or Shift+F10 (both fire 'contextmenu')
  11. * and on a long press on a touch screen; open(oTrigger) opens it from a click on a visible button. The popup plugin
  12. * handles the outside click, Escape and the fade; this adds the ARIA menu behaviour: focus lands on the first item,
  13. * arrows, Home and End move, Tab leaves, checkbox and radio items keep their state, and focus returns to the trigger.
  14. */
  15. function BxDolStudioContextMenu() {
  16. this.sAttr = 'data-bx-context-menu';
  17. this.sItems = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
  18. this.iPressDelay = 500;
  19. this.iGap = 4; // between the trigger and the menu
  20. this.oTrigger = null;
  21. this.oPopup = null;
  22. this._iPressTimer = 0;
  23. this._iPressedAt = 0;
  24. this.init();
  25. }
  26. BxDolStudioContextMenu.prototype.init = function() {
  27. var $this = this;
  28. document.addEventListener('contextmenu', function(oEvent) {
  29. var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
  30. if(!oTrigger)
  31. return;
  32. oEvent.preventDefault();
  33. if(Date.now() - $this._iPressedAt < 800)
  34. return; // the long press has already opened it (Android fires contextmenu as well)
  35. $this.open(oTrigger);
  36. });
  37. //--- Touch: a long press (iOS never fires contextmenu), cancelled by lifting or moving the finger.
  38. document.addEventListener('pointerdown', function(oEvent) {
  39. if(oEvent.pointerType != 'touch')
  40. return;
  41. var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
  42. if(!oTrigger)
  43. return;
  44. clearTimeout($this._iPressTimer);
  45. $this._iPressTimer = setTimeout(function() {
  46. $this._iPressedAt = Date.now();
  47. $this.open(oTrigger);
  48. }, $this.iPressDelay);
  49. });
  50. ['pointerup', 'pointercancel', 'pointermove'].forEach(function(sEvent) {
  51. document.addEventListener(sEvent, function() {
  52. clearTimeout($this._iPressTimer);
  53. });
  54. });
  55. document.addEventListener('click', function(oEvent) {
  56. // the tap that ends a long press must not follow the trigger's link
  57. if(Date.now() - $this._iPressedAt < 800 && oEvent.target.closest('[' + $this.sAttr + ']'))
  58. oEvent.preventDefault();
  59. }, true);
  60. //--- Inside the menu: arrows move between the items, Tab or Escape leaves, an activated item closes it.
  61. document.addEventListener('keydown', function(oEvent) {
  62. var oMenu = oEvent.target.closest('[role="menu"]');
  63. if(!oMenu)
  64. return;
  65. var aItems = $this.getItems(oMenu);
  66. var iIndex = aItems.indexOf(document.activeElement);
  67. var iCount = aItems.length;
  68. switch(oEvent.key) {
  69. case 'ArrowDown':
  70. aItems[(iIndex + 1) % iCount].focus();
  71. break;
  72. case 'ArrowUp':
  73. aItems[(iIndex - 1 + iCount) % iCount].focus();
  74. break;
  75. case 'Home':
  76. aItems[0].focus();
  77. break;
  78. case 'End':
  79. aItems[iCount - 1].focus();
  80. break;
  81. case 'Tab':
  82. case 'Escape':
  83. $this.close();
  84. break;
  85. default:
  86. return;
  87. }
  88. oEvent.preventDefault();
  89. });
  90. document.addEventListener('click', function(oEvent) {
  91. var oItem = oEvent.target.closest($this.sItems);
  92. if(!oItem || !oItem.closest('[role="menu"]'))
  93. return;
  94. var sRole = oItem.getAttribute('role');
  95. if(sRole == 'menuitemcheckbox')
  96. oItem.setAttribute('aria-checked', oItem.getAttribute('aria-checked') == 'true' ? 'false' : 'true');
  97. else if(sRole == 'menuitemradio') {
  98. $this.getItems(oItem.closest('[role="menu"]')).forEach(function(oOther) {
  99. if(oOther.getAttribute('role') == 'menuitemradio')
  100. oOther.setAttribute('aria-checked', 'false');
  101. });
  102. oItem.setAttribute('aria-checked', 'true');
  103. }
  104. $this.close();
  105. });
  106. };
  107. /**
  108. * Open a trigger's menu next to it, however it was asked for (pointer, keyboard, long press, a button's click): 4px under
  109. * the trigger, flush with its start edge; fit() moves it above, or flush with the end edge, when the window runs out.
  110. */
  111. BxDolStudioContextMenu.prototype.open = function(oTrigger) {
  112. var $this = this;
  113. var oPopup = $(oTrigger.getAttribute(this.sAttr));
  114. if(!oPopup.length)
  115. return this.load(oTrigger);
  116. // another menu is up: it goes first
  117. if(this.oPopup && this.oPopup.get(0) !== oPopup.get(0) && this.oPopup.is(':visible'))
  118. this.oPopup.dolPopupHide();
  119. this.oTrigger = oTrigger;
  120. this.oPopup = oPopup;
  121. var oAnchor = this.getAnchor(oTrigger);
  122. if(oPopup.is(':visible')) {
  123. // still fading out, or asked to show a moment ago: the plugin would toggle it, so come back when that is over
  124. if(!oPopup.hasClass('bx-popup-active')) {
  125. setTimeout(function() {
  126. $this.open(oTrigger);
  127. }, 250);
  128. return false;
  129. }
  130. // the same menu asked for again: it just settles next to its trigger
  131. oPopup._dolPopupSetPosition({position: 'absolute', left: oAnchor.x, top: oAnchor.y, pointer: false});
  132. this.fit(oPopup, oTrigger);
  133. this.focusFirst(oPopup);
  134. return false;
  135. }
  136. oPopup.dolPopup({
  137. position: 'absolute',
  138. left: oAnchor.x,
  139. top: oAnchor.y,
  140. pointer: false,
  141. fog: false,
  142. closeOnOuterClick: true,
  143. cssClass: 'bx-popup-menu',
  144. onBeforeShow: function(oShowing) {
  145. $this.fit(oShowing, $this.oTrigger); // laid out but still invisible here: no jump when it has to flip
  146. },
  147. onShow: function(oShown) {
  148. $this.fit(oShown, $this.oTrigger);
  149. if($this.oTrigger.hasAttribute('aria-expanded'))
  150. $this.oTrigger.setAttribute('aria-expanded', 'true');
  151. $this.focusFirst(oShown);
  152. },
  153. onHide: function(oHidden) {
  154. if($this.oTrigger.hasAttribute('aria-expanded'))
  155. $this.oTrigger.setAttribute('aria-expanded', 'false');
  156. // focus went nowhere in particular (the item that was activated is gone from view): back to the trigger
  157. if(document.activeElement == document.body || oHidden.get(0).contains(document.activeElement))
  158. $this.oTrigger.focus();
  159. }
  160. });
  161. return false;
  162. };
  163. /**
  164. * A menu that is not on the page yet (launcher tiles, dock items name theirs in data-bx-context-menu-load as "page:tileId"):
  165. * fetch it from the page's module endpoint, keep it in the document, then open it.
  166. */
  167. BxDolStudioContextMenu.prototype.load = function(oTrigger) {
  168. var $this = this;
  169. var sLoad = oTrigger.getAttribute(this.sAttr + '-load');
  170. if(!sLoad || this._bLoading)
  171. return false;
  172. var aLoad = sLoad.split(':');
  173. this._bLoading = true;
  174. $.get(sUrlStudio + 'module.php', {mod_action: 'context', mod_value: aLoad[0], mod_widget_id: parseInt(aLoad[1]) || 0, _t: Date.now()}, function(oData) {
  175. if(!oData || oData.code != 0 || !oData.content)
  176. return;
  177. $('body').append(oData.content);
  178. $this.open(oTrigger);
  179. }, 'json').always(function() {
  180. $this._bLoading = false;
  181. });
  182. return false;
  183. };
  184. /**
  185. * Whether a long press opened a menu just now (the launcher's own long press then leaves edit mode alone).
  186. */
  187. BxDolStudioContextMenu.prototype.justOpened = function() {
  188. return Date.now() - this._iPressedAt < 1500;
  189. };
  190. /**
  191. * Where the menu goes first: 4px under the trigger, flush with its start edge (page coordinates). In RTL the start edge is
  192. * the right one, which fit() lines the menu up with once its width is known.
  193. */
  194. BxDolStudioContextMenu.prototype.getAnchor = function(oTrigger) {
  195. var oRect = oTrigger.getBoundingClientRect();
  196. return {x: oRect.left + window.scrollX, y: oRect.bottom + window.scrollY + this.iGap};
  197. };
  198. BxDolStudioContextMenu.prototype.isRtl = function() {
  199. return document.documentElement.dir == 'rtl' || document.body.classList.contains('bx-dir-rtl');
  200. };
  201. /**
  202. * 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
  203. * window (start edge in RTL), 4px above the trigger when it would run past the bottom.
  204. */
  205. BxDolStudioContextMenu.prototype.fit = function(oPopup, oTrigger) {
  206. // layout size, not the rendered box: the popup is scaled while it fades in, and this may run mid-fade
  207. var iWidth = oPopup.get(0).offsetWidth;
  208. var iHeight = oPopup.get(0).offsetHeight;
  209. var oTriggerRect = oTrigger.getBoundingClientRect();
  210. var oCss = {};
  211. var iLeft = this.isRtl() ? oTriggerRect.right - iWidth : oTriggerRect.left;
  212. if(iLeft + iWidth > window.innerWidth - 8)
  213. iLeft = oTriggerRect.right - iWidth;
  214. if(iLeft < 8)
  215. iLeft = this.isRtl() ? oTriggerRect.left : 8;
  216. oCss.left = Math.round(iLeft + window.scrollX);
  217. oCss.top = Math.round(oTriggerRect.bottom + this.iGap + window.scrollY);
  218. if(oTriggerRect.bottom + this.iGap + iHeight > window.innerHeight - 8 && oTriggerRect.top - this.iGap - iHeight >= 8)
  219. oCss.top = Math.round(oTriggerRect.top - this.iGap - iHeight + window.scrollY);
  220. oPopup.css(oCss);
  221. };
  222. BxDolStudioContextMenu.prototype.focusFirst = function(oPopup) {
  223. var aItems = this.getItems(oPopup.get(0));
  224. if(aItems.length)
  225. aItems[0].focus();
  226. };
  227. BxDolStudioContextMenu.prototype.getItems = function(oMenu) {
  228. return Array.prototype.filter.call(oMenu.querySelectorAll(this.sItems), function(oItem) {
  229. return !oItem.disabled && oItem.getAttribute('aria-disabled') != 'true';
  230. });
  231. };
  232. BxDolStudioContextMenu.prototype.close = function() {
  233. if(this.oPopup && this.oPopup.is(':visible'))
  234. this.oPopup.dolPopupHide();
  235. };
  236. var oBxDolStudioContextMenu = new BxDolStudioContextMenu();
  237. /** @} */