foundation.reveal.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. 'use strict';
  2. import $ from 'jquery';
  3. import { onLoad } from './foundation.core.utils';
  4. import { Keyboard } from './foundation.util.keyboard';
  5. import { MediaQuery } from './foundation.util.mediaQuery';
  6. import { Motion } from './foundation.util.motion';
  7. import { Plugin } from './foundation.core.plugin';
  8. import { Triggers } from './foundation.util.triggers';
  9. import { Touch } from './foundation.util.touch'
  10. /**
  11. * Reveal module.
  12. * @module foundation.reveal
  13. * @requires foundation.util.keyboard
  14. * @requires foundation.util.triggers
  15. * @requires foundation.util.mediaQuery
  16. * @requires foundation.util.motion if using animations
  17. */
  18. class Reveal extends Plugin {
  19. /**
  20. * Creates a new instance of Reveal.
  21. * @class
  22. * @name Reveal
  23. * @param {jQuery} element - jQuery object to use for the modal.
  24. * @param {Object} options - optional parameters.
  25. */
  26. _setup(element, options) {
  27. this.$element = element;
  28. this.options = $.extend({}, Reveal.defaults, this.$element.data(), options);
  29. this.className = 'Reveal'; // ie9 back compat
  30. this._init();
  31. // Triggers init is idempotent, just need to make sure it is initialized
  32. Triggers.init($);
  33. Keyboard.register('Reveal', {
  34. 'ESCAPE': 'close',
  35. });
  36. }
  37. /**
  38. * Initializes the modal by adding the overlay and close buttons, (if selected).
  39. * @private
  40. */
  41. _init() {
  42. MediaQuery._init();
  43. this.id = this.$element.attr('id');
  44. this.isActive = false;
  45. this.cached = {mq: MediaQuery.current};
  46. this.$anchor = $(`[data-open="${this.id}"]`).length ? $(`[data-open="${this.id}"]`) : $(`[data-toggle="${this.id}"]`);
  47. this.$anchor.attr({
  48. 'aria-controls': this.id,
  49. 'aria-haspopup': true,
  50. 'tabindex': 0
  51. });
  52. if (this.options.fullScreen || this.$element.hasClass('full')) {
  53. this.options.fullScreen = true;
  54. this.options.overlay = false;
  55. }
  56. if (this.options.overlay && !this.$overlay) {
  57. this.$overlay = this._makeOverlay(this.id);
  58. }
  59. this.$element.attr({
  60. 'role': 'dialog',
  61. 'aria-hidden': true,
  62. 'data-yeti-box': this.id,
  63. 'data-resize': this.id
  64. });
  65. if(this.$overlay) {
  66. this.$element.detach().appendTo(this.$overlay);
  67. } else {
  68. this.$element.detach().appendTo($(this.options.appendTo));
  69. this.$element.addClass('without-overlay');
  70. }
  71. this._events();
  72. if (this.options.deepLink && window.location.hash === ( `#${this.id}`)) {
  73. this.onLoadListener = onLoad($(window), () => this.open());
  74. }
  75. }
  76. /**
  77. * Creates an overlay div to display behind the modal.
  78. * @private
  79. */
  80. _makeOverlay() {
  81. var additionalOverlayClasses = '';
  82. if (this.options.additionalOverlayClasses) {
  83. additionalOverlayClasses = ' ' + this.options.additionalOverlayClasses;
  84. }
  85. return $('<div></div>')
  86. .addClass('reveal-overlay' + additionalOverlayClasses)
  87. .appendTo(this.options.appendTo);
  88. }
  89. /**
  90. * Updates position of modal
  91. * TODO: Figure out if we actually need to cache these values or if it doesn't matter
  92. * @private
  93. */
  94. _updatePosition() {
  95. var width = this.$element.outerWidth();
  96. var outerWidth = $(window).width();
  97. var height = this.$element.outerHeight();
  98. var outerHeight = $(window).height();
  99. var left, top = null;
  100. if (this.options.hOffset === 'auto') {
  101. left = parseInt((outerWidth - width) / 2, 10);
  102. } else {
  103. left = parseInt(this.options.hOffset, 10);
  104. }
  105. if (this.options.vOffset === 'auto') {
  106. if (height > outerHeight) {
  107. top = parseInt(Math.min(100, outerHeight / 10), 10);
  108. } else {
  109. top = parseInt((outerHeight - height) / 4, 10);
  110. }
  111. } else if (this.options.vOffset !== null) {
  112. top = parseInt(this.options.vOffset, 10);
  113. }
  114. if (top !== null) {
  115. this.$element.css({top: top + 'px'});
  116. }
  117. // only worry about left if we don't have an overlay or we have a horizontal offset,
  118. // otherwise we're perfectly in the middle
  119. if (!this.$overlay || (this.options.hOffset !== 'auto')) {
  120. this.$element.css({left: left + 'px'});
  121. this.$element.css({margin: '0px'});
  122. }
  123. }
  124. /**
  125. * Adds event handlers for the modal.
  126. * @private
  127. */
  128. _events() {
  129. var _this = this;
  130. this.$element.on({
  131. 'open.zf.trigger': this.open.bind(this),
  132. 'close.zf.trigger': (event, $element) => {
  133. if ((event.target === _this.$element[0]) ||
  134. ($(event.target).parents('[data-closable]')[0] === $element)) { // only close reveal when it's explicitly called
  135. return this.close.apply(this);
  136. }
  137. },
  138. 'toggle.zf.trigger': this.toggle.bind(this),
  139. 'resizeme.zf.trigger': function() {
  140. _this._updatePosition();
  141. }
  142. });
  143. if (this.options.closeOnClick && this.options.overlay) {
  144. this.$overlay.off('.zf.reveal').on('click.zf.reveal', function(e) {
  145. if (e.target === _this.$element[0] ||
  146. $.contains(_this.$element[0], e.target) ||
  147. !$.contains(document, e.target)) {
  148. return;
  149. }
  150. _this.close();
  151. });
  152. }
  153. if (this.options.deepLink) {
  154. $(window).on(`hashchange.zf.reveal:${this.id}`, this._handleState.bind(this));
  155. }
  156. }
  157. /**
  158. * Handles modal methods on back/forward button clicks or any other event that triggers hashchange.
  159. * @private
  160. */
  161. _handleState(e) {
  162. if(window.location.hash === ( '#' + this.id) && !this.isActive){ this.open(); }
  163. else{ this.close(); }
  164. }
  165. /**
  166. * Disables the scroll when Reveal is shown to prevent the background from shifting
  167. * @param {number} scrollTop - Scroll to visually apply, window current scroll by default
  168. */
  169. _disableScroll(scrollTop) {
  170. scrollTop = scrollTop || $(window).scrollTop();
  171. if ($(document).height() > $(window).height()) {
  172. $("html")
  173. .css("top", -scrollTop);
  174. }
  175. }
  176. /**
  177. * Reenables the scroll when Reveal closes
  178. * @param {number} scrollTop - Scroll to restore, html "top" property by default (as set by `_disableScroll`)
  179. */
  180. _enableScroll(scrollTop) {
  181. scrollTop = scrollTop || parseInt($("html").css("top"));
  182. if ($(document).height() > $(window).height()) {
  183. $("html")
  184. .css("top", "");
  185. $(window).scrollTop(-scrollTop);
  186. }
  187. }
  188. /**
  189. * Opens the modal controlled by `this.$anchor`, and closes all others by default.
  190. * @function
  191. * @fires Reveal#closeme
  192. * @fires Reveal#open
  193. */
  194. open() {
  195. // either update or replace browser history
  196. const hash = `#${this.id}`;
  197. if (this.options.deepLink && window.location.hash !== hash) {
  198. if (window.history.pushState) {
  199. if (this.options.updateHistory) {
  200. window.history.pushState({}, '', hash);
  201. } else {
  202. window.history.replaceState({}, '', hash);
  203. }
  204. } else {
  205. window.location.hash = hash;
  206. }
  207. }
  208. // Remember anchor that opened it to set focus back later, have general anchors as fallback
  209. this.$activeAnchor = $(document.activeElement).is(this.$anchor) ? $(document.activeElement) : this.$anchor;
  210. this.isActive = true;
  211. // Make elements invisible, but remove display: none so we can get size and positioning
  212. this.$element
  213. .css({ 'visibility': 'hidden' })
  214. .show()
  215. .scrollTop(0);
  216. if (this.options.overlay) {
  217. this.$overlay.css({'visibility': 'hidden'}).show();
  218. }
  219. this._updatePosition();
  220. this.$element
  221. .hide()
  222. .css({ 'visibility': '' });
  223. if(this.$overlay) {
  224. this.$overlay.css({'visibility': ''}).hide();
  225. if(this.$element.hasClass('fast')) {
  226. this.$overlay.addClass('fast');
  227. } else if (this.$element.hasClass('slow')) {
  228. this.$overlay.addClass('slow');
  229. }
  230. }
  231. if (!this.options.multipleOpened) {
  232. /**
  233. * Fires immediately before the modal opens.
  234. * Closes any other modals that are currently open
  235. * @event Reveal#closeme
  236. */
  237. this.$element.trigger('closeme.zf.reveal', this.id);
  238. }
  239. this._disableScroll();
  240. var _this = this;
  241. // Motion UI method of reveal
  242. if (this.options.animationIn) {
  243. function afterAnimation(){
  244. _this.$element
  245. .attr({
  246. 'aria-hidden': false,
  247. 'tabindex': -1
  248. })
  249. .focus();
  250. _this._addGlobalClasses();
  251. Keyboard.trapFocus(_this.$element);
  252. }
  253. if (this.options.overlay) {
  254. Motion.animateIn(this.$overlay, 'fade-in');
  255. }
  256. Motion.animateIn(this.$element, this.options.animationIn, () => {
  257. if(this.$element) { // protect against object having been removed
  258. this.focusableElements = Keyboard.findFocusable(this.$element);
  259. afterAnimation();
  260. }
  261. });
  262. }
  263. // jQuery method of reveal
  264. else {
  265. if (this.options.overlay) {
  266. this.$overlay.show(0);
  267. }
  268. this.$element.show(this.options.showDelay);
  269. }
  270. // handle accessibility
  271. this.$element
  272. .attr({
  273. 'aria-hidden': false,
  274. 'tabindex': -1
  275. })
  276. .focus();
  277. Keyboard.trapFocus(this.$element);
  278. this._addGlobalClasses();
  279. this._addGlobalListeners();
  280. /**
  281. * Fires when the modal has successfully opened.
  282. * @event Reveal#open
  283. */
  284. this.$element.trigger('open.zf.reveal');
  285. }
  286. /**
  287. * Adds classes and listeners on document required by open modals.
  288. *
  289. * The following classes are added and updated:
  290. * - `.is-reveal-open` - Prevents the scroll on document
  291. * - `.zf-has-scroll` - Displays a disabled scrollbar on document if required like if the
  292. * scroll was not disabled. This prevent a "shift" of the page content due
  293. * the scrollbar disappearing when the modal opens.
  294. *
  295. * @private
  296. */
  297. _addGlobalClasses() {
  298. const updateScrollbarClass = () => {
  299. $('html').toggleClass('zf-has-scroll', !!($(document).height() > $(window).height()));
  300. };
  301. this.$element.on('resizeme.zf.trigger.revealScrollbarListener', () => updateScrollbarClass());
  302. updateScrollbarClass();
  303. $('html').addClass('is-reveal-open');
  304. }
  305. /**
  306. * Removes classes and listeners on document that were required by open modals.
  307. * @private
  308. */
  309. _removeGlobalClasses() {
  310. this.$element.off('resizeme.zf.trigger.revealScrollbarListener');
  311. $('html').removeClass('is-reveal-open');
  312. $('html').removeClass('zf-has-scroll');
  313. }
  314. /**
  315. * Adds extra event handlers for the body and window if necessary.
  316. * @private
  317. */
  318. _addGlobalListeners() {
  319. var _this = this;
  320. if(!this.$element) { return; } // If we're in the middle of cleanup, don't freak out
  321. this.focusableElements = Keyboard.findFocusable(this.$element);
  322. if (!this.options.overlay && this.options.closeOnClick && !this.options.fullScreen) {
  323. $('body').on('click.zf.reveal', function(e) {
  324. if (e.target === _this.$element[0] ||
  325. $.contains(_this.$element[0], e.target) ||
  326. !$.contains(document, e.target)) { return; }
  327. _this.close();
  328. });
  329. }
  330. if (this.options.closeOnEsc) {
  331. $(window).on('keydown.zf.reveal', function(e) {
  332. Keyboard.handleKey(e, 'Reveal', {
  333. close: function() {
  334. if (_this.options.closeOnEsc) {
  335. _this.close();
  336. }
  337. }
  338. });
  339. });
  340. }
  341. }
  342. /**
  343. * Closes the modal.
  344. * @function
  345. * @fires Reveal#closed
  346. */
  347. close() {
  348. if (!this.isActive || !this.$element.is(':visible')) {
  349. return false;
  350. }
  351. var _this = this;
  352. // Motion UI method of hiding
  353. if (this.options.animationOut) {
  354. if (this.options.overlay) {
  355. Motion.animateOut(this.$overlay, 'fade-out');
  356. }
  357. Motion.animateOut(this.$element, this.options.animationOut, finishUp);
  358. }
  359. // jQuery method of hiding
  360. else {
  361. this.$element.hide(this.options.hideDelay);
  362. if (this.options.overlay) {
  363. this.$overlay.hide(0, finishUp);
  364. }
  365. else {
  366. finishUp();
  367. }
  368. }
  369. // Conditionals to remove extra event listeners added on open
  370. if (this.options.closeOnEsc) {
  371. $(window).off('keydown.zf.reveal');
  372. }
  373. if (!this.options.overlay && this.options.closeOnClick) {
  374. $('body').off('click.zf.reveal');
  375. }
  376. this.$element.off('keydown.zf.reveal');
  377. function finishUp() {
  378. // Get the current top before the modal is closed and restore the scroll after.
  379. // TODO: use component properties instead of HTML properties
  380. // See https://github.com/zurb/foundation-sites/pull/10786
  381. var scrollTop = parseInt($("html").css("top"));
  382. if ($('.reveal:visible').length === 0) {
  383. _this._removeGlobalClasses(); // also remove .is-reveal-open from the html element when there is no opened reveal
  384. }
  385. Keyboard.releaseFocus(_this.$element);
  386. _this.$element.attr('aria-hidden', true);
  387. _this._enableScroll(scrollTop);
  388. /**
  389. * Fires when the modal is done closing.
  390. * @event Reveal#closed
  391. */
  392. _this.$element.trigger('closed.zf.reveal');
  393. }
  394. /**
  395. * Resets the modal content
  396. * This prevents a running video to keep going in the background
  397. */
  398. if (this.options.resetOnClose) {
  399. this.$element.html(this.$element.html());
  400. }
  401. this.isActive = false;
  402. // If deepLink and we did not switched to an other modal...
  403. if (_this.options.deepLink && window.location.hash === `#${this.id}`) {
  404. // Remove the history hash
  405. if (window.history.replaceState) {
  406. const urlWithoutHash = window.location.pathname + window.location.search;
  407. if (this.options.updateHistory) {
  408. window.history.pushState({}, '', urlWithoutHash); // remove the hash
  409. } else {
  410. window.history.replaceState('', document.title, urlWithoutHash);
  411. }
  412. } else {
  413. window.location.hash = '';
  414. }
  415. }
  416. this.$activeAnchor.focus();
  417. }
  418. /**
  419. * Toggles the open/closed state of a modal.
  420. * @function
  421. */
  422. toggle() {
  423. if (this.isActive) {
  424. this.close();
  425. } else {
  426. this.open();
  427. }
  428. };
  429. /**
  430. * Destroys an instance of a modal.
  431. * @function
  432. */
  433. _destroy() {
  434. if (this.options.overlay) {
  435. this.$element.appendTo($(this.options.appendTo)); // move $element outside of $overlay to prevent error unregisterPlugin()
  436. this.$overlay.hide().off().remove();
  437. }
  438. this.$element.hide().off();
  439. this.$anchor.off('.zf');
  440. $(window).off(`.zf.reveal:${this.id}`)
  441. if (this.onLoadListener) $(window).off(this.onLoadListener);
  442. if ($('.reveal:visible').length === 0) {
  443. this._removeGlobalClasses(); // also remove .is-reveal-open from the html element when there is no opened reveal
  444. }
  445. };
  446. }
  447. Reveal.defaults = {
  448. /**
  449. * Motion-UI class to use for animated elements. If none used, defaults to simple show/hide.
  450. * @option
  451. * @type {string}
  452. * @default ''
  453. */
  454. animationIn: '',
  455. /**
  456. * Motion-UI class to use for animated elements. If none used, defaults to simple show/hide.
  457. * @option
  458. * @type {string}
  459. * @default ''
  460. */
  461. animationOut: '',
  462. /**
  463. * Time, in ms, to delay the opening of a modal after a click if no animation used.
  464. * @option
  465. * @type {number}
  466. * @default 0
  467. */
  468. showDelay: 0,
  469. /**
  470. * Time, in ms, to delay the closing of a modal after a click if no animation used.
  471. * @option
  472. * @type {number}
  473. * @default 0
  474. */
  475. hideDelay: 0,
  476. /**
  477. * Allows a click on the body/overlay to close the modal.
  478. * @option
  479. * @type {boolean}
  480. * @default true
  481. */
  482. closeOnClick: true,
  483. /**
  484. * Allows the modal to close if the user presses the `ESCAPE` key.
  485. * @option
  486. * @type {boolean}
  487. * @default true
  488. */
  489. closeOnEsc: true,
  490. /**
  491. * If true, allows multiple modals to be displayed at once.
  492. * @option
  493. * @type {boolean}
  494. * @default false
  495. */
  496. multipleOpened: false,
  497. /**
  498. * Distance, in pixels, the modal should push down from the top of the screen.
  499. * @option
  500. * @type {number|string}
  501. * @default auto
  502. */
  503. vOffset: 'auto',
  504. /**
  505. * Distance, in pixels, the modal should push in from the side of the screen.
  506. * @option
  507. * @type {number|string}
  508. * @default auto
  509. */
  510. hOffset: 'auto',
  511. /**
  512. * Allows the modal to be fullscreen, completely blocking out the rest of the view. JS checks for this as well.
  513. * @option
  514. * @type {boolean}
  515. * @default false
  516. */
  517. fullScreen: false,
  518. /**
  519. * Allows the modal to generate an overlay div, which will cover the view when modal opens.
  520. * @option
  521. * @type {boolean}
  522. * @default true
  523. */
  524. overlay: true,
  525. /**
  526. * Allows the modal to remove and reinject markup on close. Should be true if using video elements w/o using provider's api, otherwise, videos will continue to play in the background.
  527. * @option
  528. * @type {boolean}
  529. * @default false
  530. */
  531. resetOnClose: false,
  532. /**
  533. * Link the location hash to the modal.
  534. * Set the location hash when the modal is opened/closed, and open/close the modal when the location changes.
  535. * @option
  536. * @type {boolean}
  537. * @default false
  538. */
  539. deepLink: false,
  540. /**
  541. * If `deepLink` is enabled, update the browser history with the open modal
  542. * @option
  543. * @default false
  544. */
  545. updateHistory: false,
  546. /**
  547. * Allows the modal to append to custom div.
  548. * @option
  549. * @type {string}
  550. * @default "body"
  551. */
  552. appendTo: "body",
  553. /**
  554. * Allows adding additional class names to the reveal overlay.
  555. * @option
  556. * @type {string}
  557. * @default ''
  558. */
  559. additionalOverlayClasses: ''
  560. };
  561. export {Reveal};