popup.js 8.0 KB

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