popup.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. window.Popup = new (class {
  2. constructor() {
  3. // The template we use to render popups
  4. this.template = Template.popup;
  5. // We only want to display one popup at a time and we keep the view object
  6. // in this `Popup.current` variable. If there is no popup currently opened
  7. // the value is `null`.
  8. this.current = null;
  9. // It's possible to open a sub-popup B from a popup A. In that case we keep
  10. // the data of popup A so we can return back to it. Every time we open a new
  11. // popup the stack grows, every time we go back the stack decrease, and if
  12. // we close the popup the stack is reseted to the empty stack [].
  13. this._stack = [];
  14. // We invalidate this internal dependency every time the top of the stack
  15. // has changed and we want to re-render a popup with the new top-stack data.
  16. this._dep = new Tracker.Dependency();
  17. }
  18. /// This function returns a callback that can be used in an event map:
  19. /// Template.tplName.events({
  20. /// 'click .elementClass': Popup.open("popupName"),
  21. /// });
  22. /// The popup inherit the data context of its parent.
  23. open(name) {
  24. const self = this;
  25. const popupName = `${name}Popup`;
  26. function clickFromPopup(evt) {
  27. return $(evt.target).closest('.js-pop-over').length !== 0;
  28. }
  29. /** opens the popup
  30. * @param evt the current event
  31. * @param options options (dataContextIfCurrentDataIsUndefined use this dataContext if this.currentData() is undefined)
  32. */
  33. return function(evt, options) {
  34. // If a popup is already opened, clicking again on the opener element
  35. // should close it -- and interrupt the current `open` function.
  36. if (self.isOpen()) {
  37. const previousOpenerElement = self._getTopStack().openerElement;
  38. if (previousOpenerElement === evt.currentTarget) {
  39. self.close();
  40. return;
  41. } else {
  42. $(previousOpenerElement).removeClass('is-active');
  43. }
  44. }
  45. // We determine the `openerElement` (the DOM element that is being clicked
  46. // and the one we take in reference to position the popup) from the event
  47. // if the popup has no parent, or from the parent `openerElement` if it
  48. // has one. This allows us to position a sub-popup exactly at the same
  49. // position than its parent.
  50. let openerElement;
  51. if (clickFromPopup(evt) && self._getTopStack()) {
  52. openerElement = self._getTopStack().openerElement;
  53. } else {
  54. self._stack = [];
  55. openerElement = evt.currentTarget;
  56. }
  57. $(openerElement).addClass('is-active');
  58. evt.preventDefault();
  59. // We push our popup data to the stack. The top of the stack is always
  60. // used as the data source for our current popup.
  61. self._stack.push({
  62. popupName,
  63. openerElement,
  64. hasPopupParent: clickFromPopup(evt),
  65. title: self._getTitle(popupName),
  66. depth: self._stack.length,
  67. offset: self._getOffset(openerElement),
  68. dataContext: (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
  69. });
  70. // If there are no popup currently opened we use the Blaze API to render
  71. // one into the DOM. We use a reactive function as the data parameter that
  72. // return the complete along with its top element and depends on our
  73. // internal dependency that is being invalidated every time the top
  74. // element of the stack has changed and we want to update the popup.
  75. //
  76. // Otherwise if there is already a popup open we just need to invalidate
  77. // our internal dependency, and since we just changed the top element of
  78. // our internal stack, the popup will be updated with the new data.
  79. if (!self.isOpen()) {
  80. self.current = Blaze.renderWithData(
  81. self.template,
  82. () => {
  83. self._dep.depend();
  84. return { ...self._getTopStack(), stack: self._stack };
  85. },
  86. document.body,
  87. );
  88. } else {
  89. self._dep.changed();
  90. }
  91. };
  92. }
  93. /// This function returns a callback that can be used in an event map:
  94. /// Template.tplName.events({
  95. /// 'click .elementClass': Popup.afterConfirm("popupName", function() {
  96. /// // What to do after the user has confirmed the action
  97. /// }),
  98. /// });
  99. afterConfirm(name, action) {
  100. const self = this;
  101. return function(evt, tpl) {
  102. const context = (this.currentData && this.currentData()) || this;
  103. context.__afterConfirmAction = action;
  104. self.open(name).call(context, evt, tpl);
  105. };
  106. }
  107. /// The public reactive state of the popup.
  108. isOpen() {
  109. this._dep.changed();
  110. return Boolean(this.current);
  111. }
  112. /// In case the popup was opened from a parent popup we can get back to it
  113. /// with this `Popup.back()` function. You can go back several steps at once
  114. /// by providing a number to this function, e.g. `Popup.back(2)`. In this case
  115. /// intermediate popup won't even be rendered on the DOM. If the number of
  116. /// steps back is greater than the popup stack size, the popup will be closed.
  117. back(n = 1) {
  118. if (this._stack.length > n) {
  119. _.times(n, () => this._stack.pop());
  120. this._dep.changed();
  121. } else {
  122. this.close();
  123. }
  124. }
  125. /// Close the current opened popup.
  126. close() {
  127. if (this.isOpen()) {
  128. Blaze.remove(this.current);
  129. this.current = null;
  130. const openerElement = this._getTopStack().openerElement;
  131. $(openerElement).removeClass('is-active');
  132. this._stack = [];
  133. }
  134. }
  135. getOpenerComponent() {
  136. const { openerElement } = Template.parentData(4);
  137. return BlazeComponent.getComponentForElement(openerElement);
  138. }
  139. // An utility fonction that returns the top element of the internal stack
  140. _getTopStack() {
  141. return this._stack[this._stack.length - 1];
  142. }
  143. // We automatically calculate the popup offset from the reference element
  144. // position and dimensions. We also reactively use the window dimensions to
  145. // ensure that the popup is always visible on the screen.
  146. _getOffset(element) {
  147. const $element = $(element);
  148. return () => {
  149. Utils.windowResizeDep.depend();
  150. if (Utils.isMiniScreen()) return { left: 0, top: 0 };
  151. const offset = $element.offset();
  152. const popupWidth = 300 + 15;
  153. return {
  154. left: Math.min(offset.left, $(window).width() - popupWidth),
  155. top: offset.top + $element.outerHeight(),
  156. };
  157. };
  158. }
  159. // We get the title from the translation files. Instead of returning the
  160. // result, we return a function that compute the result and since `TAPi18n.__`
  161. // is a reactive data source, the title will be changed reactively.
  162. _getTitle(popupName) {
  163. return () => {
  164. const translationKey = `${popupName}-title`;
  165. // XXX There is no public API to check if there is an available
  166. // translation for a given key. So we try to translate the key and if the
  167. // translation output equals the key input we deduce that no translation
  168. // was available and returns `false`. There is a (small) risk a false
  169. // positives.
  170. const title = TAPi18n.__(translationKey);
  171. // when popup showed as full of small screen, we need a default header to clearly see [X] button
  172. const defaultTitle = Utils.isMiniScreen() ? '' : false;
  173. return title !== translationKey ? title : defaultTitle;
  174. };
  175. }
  176. })();
  177. // We close a potential opened popup on any left click on the document, or go
  178. // one step back by pressing escape.
  179. const escapeActions = ['back', 'close'];
  180. escapeActions.forEach(actionName => {
  181. EscapeActions.register(
  182. `popup-${actionName}`,
  183. () => Popup[actionName](),
  184. () => Popup.isOpen(),
  185. {
  186. noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup,.js-open-inlined-form',
  187. enabledOnClick: actionName === 'close',
  188. },
  189. );
  190. });