popup.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { TAPi18n } from '/imports/i18n';
  2. window.Popup = new (class {
  3. constructor() {
  4. // The template we use to render popups
  5. this.template = Template.popup;
  6. // We only want to display one popup at a time and we keep the view object
  7. // in this `Popup.current` variable. If there is no popup currently opened
  8. // the value is `null`.
  9. this.current = null;
  10. // It's possible to open a sub-popup B from a popup A. In that case we keep
  11. // the data of popup A so we can return back to it. Every time we open a new
  12. // popup the stack grows, every time we go back the stack decrease, and if
  13. // we close the popup the stack is reseted to the empty stack [].
  14. this._stack = [];
  15. // We invalidate this internal dependency every time the top of the stack
  16. // has changed and we want to re-render a popup with the new top-stack data.
  17. this._dep = new Tracker.Dependency();
  18. }
  19. /// This function returns a callback that can be used in an event map:
  20. /// Template.tplName.events({
  21. /// 'click .elementClass': Popup.open("popupName"),
  22. /// });
  23. /// The popup inherit the data context of its parent.
  24. open(name) {
  25. const self = this;
  26. const popupName = `${name}Popup`;
  27. function clickFromPopup(evt) {
  28. return $(evt.target).closest('.js-pop-over').length !== 0;
  29. }
  30. /** opens the popup
  31. * @param evt the current event
  32. * @param options options (dataContextIfCurrentDataIsUndefined use this dataContext if this.currentData() is undefined)
  33. */
  34. return function(evt, options) {
  35. // If a popup is already opened, clicking again on the opener element
  36. // should close it -- and interrupt the current `open` function.
  37. if (self.isOpen()) {
  38. const previousOpenerElement = self._getTopStack().openerElement;
  39. if (previousOpenerElement === evt.currentTarget) {
  40. self.close();
  41. return;
  42. } else {
  43. $(previousOpenerElement).removeClass('is-active');
  44. // Clean up previous popup content to prevent mixing
  45. self._cleanupPreviousPopupContent();
  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) && self._getTopStack()) {
  55. openerElement = self._getTopStack().openerElement;
  56. } else {
  57. // For Member Settings sub-popups, always start fresh to avoid content mixing
  58. if (popupName.includes('changeLanguage') || popupName.includes('changeAvatar') ||
  59. popupName.includes('editProfile') || popupName.includes('changePassword') ||
  60. popupName.includes('invitePeople') || popupName.includes('support')) {
  61. self._stack = [];
  62. }
  63. openerElement = evt.currentTarget;
  64. }
  65. $(openerElement).addClass('is-active');
  66. evt.preventDefault();
  67. // We push our popup data to the stack. The top of the stack is always
  68. // used as the data source for our current popup.
  69. self._stack.push({
  70. popupName,
  71. openerElement,
  72. hasPopupParent: clickFromPopup(evt),
  73. title: self._getTitle(popupName),
  74. depth: self._stack.length,
  75. offset: self._getOffset(openerElement),
  76. dataContext: (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
  77. });
  78. const $contentWrapper = $('.content-wrapper')
  79. if ($contentWrapper.length > 0) {
  80. const contentWrapper = $contentWrapper[0];
  81. self._getTopStack().scrollTop = contentWrapper.scrollTop;
  82. // scroll from e.g. delete comment to the top (where the confirm button is)
  83. $contentWrapper.scrollTop(0);
  84. }
  85. // If there are no popup currently opened we use the Blaze API to render
  86. // one into the DOM. We use a reactive function as the data parameter that
  87. // return the complete along with its top element and depends on our
  88. // internal dependency that is being invalidated every time the top
  89. // element of the stack has changed and we want to update the popup.
  90. //
  91. // Otherwise if there is already a popup open we just need to invalidate
  92. // our internal dependency, and since we just changed the top element of
  93. // our internal stack, the popup will be updated with the new data.
  94. if (!self.isOpen()) {
  95. if (!Template[popupName]) {
  96. console.error('Template not found:', popupName);
  97. return;
  98. }
  99. self.current = Blaze.renderWithData(
  100. self.template,
  101. () => {
  102. self._dep.depend();
  103. return { ...self._getTopStack(), stack: self._stack };
  104. },
  105. document.body,
  106. );
  107. } else {
  108. self._dep.changed();
  109. }
  110. };
  111. }
  112. /// This function returns a callback that can be used in an event map:
  113. /// Template.tplName.events({
  114. /// 'click .elementClass': Popup.afterConfirm("popupName", function() {
  115. /// // What to do after the user has confirmed the action
  116. /// }),
  117. /// });
  118. afterConfirm(name, action) {
  119. const self = this;
  120. return function(evt, tpl) {
  121. const context = (this.currentData && this.currentData()) || this;
  122. context.__afterConfirmAction = action;
  123. self.open(name).call(context, evt, tpl);
  124. };
  125. }
  126. /// The public reactive state of the popup.
  127. isOpen() {
  128. this._dep.changed();
  129. return Boolean(this.current);
  130. }
  131. /// In case the popup was opened from a parent popup we can get back to it
  132. /// with this `Popup.back()` function. You can go back several steps at once
  133. /// by providing a number to this function, e.g. `Popup.back(2)`. In this case
  134. /// intermediate popup won't even be rendered on the DOM. If the number of
  135. /// steps back is greater than the popup stack size, the popup will be closed.
  136. back(n = 1) {
  137. if (this._stack.length > n) {
  138. const $contentWrapper = $('.content-wrapper')
  139. if ($contentWrapper.length > 0) {
  140. const contentWrapper = $contentWrapper[0];
  141. const stack = this._stack[this._stack.length - n];
  142. // scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
  143. const scrollTopMax = contentWrapper.scrollTopMax || contentWrapper.scrollHeight - contentWrapper.clientHeight;
  144. if (scrollTopMax && stack.scrollTop > scrollTopMax) {
  145. // sometimes scrollTopMax is lower than scrollTop, so i need this dirty hack
  146. setTimeout(() => {
  147. $contentWrapper.scrollTop(stack.scrollTop);
  148. }, 6);
  149. }
  150. // restore the old popup scroll position
  151. $contentWrapper.scrollTop(stack.scrollTop);
  152. }
  153. _.times(n, () => this._stack.pop());
  154. this._dep.changed();
  155. } else {
  156. this.close();
  157. }
  158. }
  159. /// Close the current opened popup.
  160. close() {
  161. if (this.isOpen()) {
  162. Blaze.remove(this.current);
  163. this.current = null;
  164. const openerElement = this._getTopStack().openerElement;
  165. $(openerElement).removeClass('is-active');
  166. this._stack = [];
  167. // Clean up popup content when closing
  168. this._cleanupPreviousPopupContent();
  169. }
  170. }
  171. getOpenerComponent(n=4) {
  172. const { openerElement } = Template.parentData(n);
  173. return BlazeComponent.getComponentForElement(openerElement);
  174. }
  175. // An utility function that returns the top element of the internal stack
  176. _getTopStack() {
  177. return this._stack[this._stack.length - 1];
  178. }
  179. _cleanupPreviousPopupContent() {
  180. // Force a re-render to ensure proper cleanup
  181. if (this._dep) {
  182. this._dep.changed();
  183. }
  184. }
  185. // We automatically calculate the popup offset from the reference element
  186. // position and dimensions. We also reactively use the window dimensions to
  187. // ensure that the popup is always visible on the screen.
  188. _getOffset(element) {
  189. const $element = $(element);
  190. return () => {
  191. Utils.windowResizeDep.depend();
  192. if (Utils.isMiniScreen()) return { left: 0, top: 0 };
  193. const offset = $element.offset();
  194. // Calculate actual popup width based on CSS: min(380px, 55vw)
  195. const viewportWidth = $(window).width();
  196. const viewportHeight = $(window).height();
  197. const popupWidth = Math.min(380, viewportWidth * 0.55) + 15; // Add 15px for margin
  198. // Calculate available height for popup
  199. const popupTop = offset.top + $element.outerHeight();
  200. // For language popup, don't use dynamic height to avoid overlapping board
  201. const isLanguagePopup = $element.hasClass('js-change-language');
  202. let availableHeight, maxPopupHeight;
  203. if (isLanguagePopup) {
  204. // For language popup, position content area below right vertical scrollbar
  205. const availableHeight = viewportHeight - popupTop - 20; // 20px margin from bottom (near scrollbar)
  206. const calculatedHeight = Math.min(availableHeight, viewportHeight * 0.5); // Max 50% of viewport
  207. return {
  208. left: Math.min(offset.left, viewportWidth - popupWidth),
  209. top: popupTop,
  210. maxHeight: Math.max(calculatedHeight, 200), // Minimum 200px height
  211. };
  212. } else {
  213. // For other popups, use the dynamic height calculation
  214. availableHeight = viewportHeight - popupTop - 20; // 20px margin from bottom
  215. maxPopupHeight = Math.min(availableHeight, viewportHeight * 0.8); // Max 80% of viewport
  216. return {
  217. left: Math.min(offset.left, viewportWidth - popupWidth),
  218. top: popupTop,
  219. maxHeight: Math.max(maxPopupHeight, 200), // Minimum 200px height
  220. };
  221. }
  222. };
  223. }
  224. // We get the title from the translation files. Instead of returning the
  225. // result, we return a function that compute the result and since `TAPi18n.__`
  226. // is a reactive data source, the title will be changed reactively.
  227. _getTitle(popupName) {
  228. return () => {
  229. const translationKey = `${popupName}-title`;
  230. // XXX There is no public API to check if there is an available
  231. // translation for a given key. So we try to translate the key and if the
  232. // translation output equals the key input we deduce that no translation
  233. // was available and returns `false`. There is a (small) risk a false
  234. // positives.
  235. const title = TAPi18n.__(translationKey);
  236. // when popup showed as full of small screen, we need a default header to clearly see [X] button
  237. const defaultTitle = Utils.isMiniScreen() ? '' : false;
  238. return title !== translationKey ? title : defaultTitle;
  239. };
  240. }
  241. })();
  242. // We close a potential opened popup on any left click on the document, or go
  243. // one step back by pressing escape.
  244. const escapeActions = ['back', 'close'];
  245. escapeActions.forEach(actionName => {
  246. EscapeActions.register(
  247. `popup-${actionName}`,
  248. () => Popup[actionName](),
  249. () => Popup.isOpen(),
  250. {
  251. noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup,.js-open-inlined-form',
  252. enabledOnClick: actionName === 'close',
  253. },
  254. );
  255. });