popup.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. This is the equivalent of a "Signal" in some other
  4. // programming environments.
  5. let windowResizeDep = new Tracker.Dependency()
  6. $(window).on('resize', () => windowResizeDep.changed())
  7. window.Popup = new class {
  8. constructor() {
  9. // The template we use to render popups
  10. this.template = Template.popup
  11. // We only want to display one popup at a time and we keep the view object
  12. // in this `Popup._current` variable. If there is no popup currently opened
  13. // the value is `null`.
  14. this._current = null
  15. // It's possible to open a sub-popup B from a popup A. In that case we keep
  16. // the data of popup A so we can return back to it. Every time we open a new
  17. // popup the stack grows, every time we go back the stack decrease, and if
  18. // we close the popup the stack is reseted to the empty stack [].
  19. this._stack = []
  20. // We invalidate this internal dependency every time the top of the stack
  21. // has changed and we want to re-render a popup with the new top-stack data.
  22. this._dep = new Tracker.Dependency()
  23. }
  24. /// This function returns a callback that can be used in an event map:
  25. ///
  26. /// Template.tplName.events({
  27. /// 'click .elementClass': Popup.open("popupName")
  28. /// })
  29. ///
  30. /// The popup inherit the data context of its parent.
  31. open(name) {
  32. let self = this
  33. const popupName = `${name}Popup`
  34. function clickFromPopup(evt) {
  35. return $(evt.target).closest('.js-pop-over').length !== 0
  36. }
  37. return function(evt) {
  38. // If a popup is already opened, clicking again on the opener element
  39. // should close it -- and interrupt the current `open` function.
  40. if (self.isOpen()) {
  41. let previousOpenerElement = self._getTopStack().openerElement
  42. if (previousOpenerElement === evt.currentTarget) {
  43. return self.close()
  44. } else {
  45. $(previousOpenerElement).removeClass('is-active')
  46. }
  47. }
  48. // We determine the `openerElement` (the DOM element that is being clicked
  49. // and the one we take in reference to position the popup) from the event
  50. // if the popup has no parent, or from the parent `openerElement` if it
  51. // has one. This allows us to position a sub-popup exactly at the same
  52. // position than its parent.
  53. let openerElement
  54. if (clickFromPopup(evt)) {
  55. openerElement = self._getTopStack().openerElement
  56. } else {
  57. self._stack = []
  58. openerElement = evt.currentTarget
  59. }
  60. $(openerElement).addClass('is-active')
  61. evt.preventDefault()
  62. // We push our popup data to the stack. The top of the stack is always
  63. // used as the data source for our current popup.
  64. self._stack.push({
  65. popupName,
  66. openerElement,
  67. hasPopupParent: clickFromPopup(evt),
  68. title: self._getTitle(popupName),
  69. depth: self._stack.length,
  70. offset: self._getOffset(openerElement),
  71. dataContext: this.currentData && this.currentData() || this,
  72. })
  73. // If there are no popup currently opened we use the Blaze API to render
  74. // one into the DOM. We use a reactive function as the data parameter that
  75. // return the the complete along with its top element and depends on our
  76. // internal dependency that is being invalidated every time the top
  77. // element of the stack has changed and we want to update the popup.
  78. //
  79. // Otherwise if there is already a popup open we just need to invalidate
  80. // our internal dependency, and since we just changed the top element of
  81. // our internal stack, the popup will be updated with the new data.
  82. if (! self.isOpen()) {
  83. self.current = Blaze.renderWithData(self.template, () => {
  84. self._dep.depend()
  85. return _.extend(self._getTopStack(), { stack: self._stack })
  86. }, document.body)
  87. } else {
  88. self._dep.changed()
  89. }
  90. }
  91. }
  92. /// This function returns a callback that can be used in an event map:
  93. ///
  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. let self = this
  101. return function(evt, tpl) {
  102. let 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 !! 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. let openerElement = this._getTopStack().openerElement
  131. $(openerElement).removeClass('is-active')
  132. this._stack = []
  133. }
  134. }
  135. // An utility fonction that returns the top element of the internal stack
  136. _getTopStack() {
  137. return this._stack[this._stack.length - 1]
  138. }
  139. // We automatically calculate the popup offset from the reference element
  140. // position and dimensions. We also reactively use the window dimensions to
  141. // ensure that the popup is always visible on the screen.
  142. _getOffset(element) {
  143. let $element = $(element)
  144. return () => {
  145. windowResizeDep.depend()
  146. const offset = $element.offset()
  147. const popupWidth = 300 + 15
  148. return {
  149. left: Math.min(offset.left, $(window).width() - popupWidth),
  150. top: offset.top + $element.outerHeight(),
  151. }
  152. }
  153. }
  154. // We get the title from the translation files. Instead of returning the
  155. // result, we return a function that compute the result and since `TAPi18n.__`
  156. // is a reactive data source, the title will be changed reactively.
  157. _getTitle(popupName) {
  158. return () => {
  159. const translationKey = `${popupName}-title`
  160. // XXX There is no public API to check if there is an available
  161. // translation for a given key. So we try to translate the key and if the
  162. // translation output equals the key input we deduce that no translation
  163. // was available and returns `false`. There is a (small) risk a false
  164. // positives.
  165. const title = TAPi18n.__(translationKey)
  166. return title !== translationKey ? title : false
  167. }
  168. }
  169. }
  170. // We close a potential opened popup on any left click on the document, or go
  171. // one step back by pressing escape.
  172. const escapeActions = ['back', 'close']
  173. _.each(escapeActions, (actionName) => {
  174. EscapeActions.register(`popup-${actionName}`,
  175. () => Popup[actionName](),
  176. () => Popup.isOpen(),
  177. {
  178. noClickEscapeOn: '.js-pop-over',
  179. enabledOnClick: actionName === 'close',
  180. }
  181. )
  182. })