foundation.offcanvas.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. 'use strict';
  2. import $ from 'jquery';
  3. import { onLoad, transitionend, RegExpEscape } from './foundation.core.utils';
  4. import { Keyboard } from './foundation.util.keyboard';
  5. import { MediaQuery } from './foundation.util.mediaQuery';
  6. import { Plugin } from './foundation.core.plugin';
  7. import { Triggers } from './foundation.util.triggers';
  8. /**
  9. * OffCanvas module.
  10. * @module foundation.offcanvas
  11. * @requires foundation.util.keyboard
  12. * @requires foundation.util.mediaQuery
  13. * @requires foundation.util.triggers
  14. */
  15. class OffCanvas extends Plugin {
  16. /**
  17. * Creates a new instance of an off-canvas wrapper.
  18. * @class
  19. * @name OffCanvas
  20. * @fires OffCanvas#init
  21. * @param {Object} element - jQuery object to initialize.
  22. * @param {Object} options - Overrides to the default plugin settings.
  23. */
  24. _setup(element, options) {
  25. this.className = 'OffCanvas'; // ie9 back compat
  26. this.$element = element;
  27. this.options = $.extend({}, OffCanvas.defaults, this.$element.data(), options);
  28. this.contentClasses = { base: [], reveal: [] };
  29. this.$lastTrigger = $();
  30. this.$triggers = $();
  31. this.position = 'left';
  32. this.$content = $();
  33. this.nested = !!(this.options.nested);
  34. // Defines the CSS transition/position classes of the off-canvas content container.
  35. $(['push', 'overlap']).each((index, val) => {
  36. this.contentClasses.base.push('has-transition-'+val);
  37. });
  38. $(['left', 'right', 'top', 'bottom']).each((index, val) => {
  39. this.contentClasses.base.push('has-position-'+val);
  40. this.contentClasses.reveal.push('has-reveal-'+val);
  41. });
  42. // Triggers init is idempotent, just need to make sure it is initialized
  43. Triggers.init($);
  44. MediaQuery._init();
  45. this._init();
  46. this._events();
  47. Keyboard.register('OffCanvas', {
  48. 'ESCAPE': 'close'
  49. });
  50. }
  51. /**
  52. * Initializes the off-canvas wrapper by adding the exit overlay (if needed).
  53. * @function
  54. * @private
  55. */
  56. _init() {
  57. var id = this.$element.attr('id');
  58. this.$element.attr('aria-hidden', 'true');
  59. // Find off-canvas content, either by ID (if specified), by siblings or by closest selector (fallback)
  60. if (this.options.contentId) {
  61. this.$content = $('#'+this.options.contentId);
  62. } else if (this.$element.siblings('[data-off-canvas-content]').length) {
  63. this.$content = this.$element.siblings('[data-off-canvas-content]').first();
  64. } else {
  65. this.$content = this.$element.closest('[data-off-canvas-content]').first();
  66. }
  67. if (!this.options.contentId) {
  68. // Assume that the off-canvas element is nested if it isn't a sibling of the content
  69. this.nested = this.$element.siblings('[data-off-canvas-content]').length === 0;
  70. } else if (this.options.contentId && this.options.nested === null) {
  71. // Warning if using content ID without setting the nested option
  72. // Once the element is nested it is required to work properly in this case
  73. console.warn('Remember to use the nested option if using the content ID option!');
  74. }
  75. if (this.nested === true) {
  76. // Force transition overlap if nested
  77. this.options.transition = 'overlap';
  78. // Remove appropriate classes if already assigned in markup
  79. this.$element.removeClass('is-transition-push');
  80. }
  81. this.$element.addClass(`is-transition-${this.options.transition} is-closed`);
  82. // Find triggers that affect this element and add aria-expanded to them
  83. this.$triggers = $(document)
  84. .find('[data-open="'+id+'"], [data-close="'+id+'"], [data-toggle="'+id+'"]')
  85. .attr('aria-expanded', 'false')
  86. .attr('aria-controls', id);
  87. // Get position by checking for related CSS class
  88. this.position = this.$element.is('.position-left, .position-top, .position-right, .position-bottom') ? this.$element.attr('class').match(/position\-(left|top|right|bottom)/)[1] : this.position;
  89. // Add an overlay over the content if necessary
  90. if (this.options.contentOverlay === true) {
  91. var overlay = document.createElement('div');
  92. var overlayPosition = $(this.$element).css("position") === 'fixed' ? 'is-overlay-fixed' : 'is-overlay-absolute';
  93. overlay.setAttribute('class', 'js-off-canvas-overlay ' + overlayPosition);
  94. this.$overlay = $(overlay);
  95. if(overlayPosition === 'is-overlay-fixed') {
  96. $(this.$overlay).insertAfter(this.$element);
  97. } else {
  98. this.$content.append(this.$overlay);
  99. }
  100. }
  101. // Get the revealOn option from the class.
  102. var revealOnRegExp = new RegExp(RegExpEscape(this.options.revealClass) + '([^\\s]+)', 'g');
  103. var revealOnClass = revealOnRegExp.exec(this.$element[0].className);
  104. if (revealOnClass) {
  105. this.options.isRevealed = true;
  106. this.options.revealOn = this.options.revealOn || revealOnClass[1];
  107. }
  108. // Ensure the `reveal-on-*` class is set.
  109. if (this.options.isRevealed === true && this.options.revealOn) {
  110. this.$element.first().addClass(`${this.options.revealClass}${this.options.revealOn}`);
  111. this._setMQChecker();
  112. }
  113. if (this.options.transitionTime) {
  114. this.$element.css('transition-duration', this.options.transitionTime);
  115. }
  116. // Initally remove all transition/position CSS classes from off-canvas content container.
  117. this._removeContentClasses();
  118. }
  119. /**
  120. * Adds event handlers to the off-canvas wrapper and the exit overlay.
  121. * @function
  122. * @private
  123. */
  124. _events() {
  125. this.$element.off('.zf.trigger .zf.offcanvas').on({
  126. 'open.zf.trigger': this.open.bind(this),
  127. 'close.zf.trigger': this.close.bind(this),
  128. 'toggle.zf.trigger': this.toggle.bind(this),
  129. 'keydown.zf.offcanvas': this._handleKeyboard.bind(this)
  130. });
  131. if (this.options.closeOnClick === true) {
  132. var $target = this.options.contentOverlay ? this.$overlay : this.$content;
  133. $target.on({'click.zf.offcanvas': this.close.bind(this)});
  134. }
  135. }
  136. /**
  137. * Applies event listener for elements that will reveal at certain breakpoints.
  138. * @private
  139. */
  140. _setMQChecker() {
  141. var _this = this;
  142. this.onLoadListener = onLoad($(window), function () {
  143. if (MediaQuery.atLeast(_this.options.revealOn)) {
  144. _this.reveal(true);
  145. }
  146. });
  147. $(window).on('changed.zf.mediaquery', function () {
  148. if (MediaQuery.atLeast(_this.options.revealOn)) {
  149. _this.reveal(true);
  150. } else {
  151. _this.reveal(false);
  152. }
  153. });
  154. }
  155. /**
  156. * Removes the CSS transition/position classes of the off-canvas content container.
  157. * Removing the classes is important when another off-canvas gets opened that uses the same content container.
  158. * @param {Boolean} hasReveal - true if related off-canvas element is revealed.
  159. * @private
  160. */
  161. _removeContentClasses(hasReveal) {
  162. if (typeof hasReveal !== 'boolean') {
  163. this.$content.removeClass(this.contentClasses.base.join(' '));
  164. } else if (hasReveal === false) {
  165. this.$content.removeClass(`has-reveal-${this.position}`);
  166. }
  167. }
  168. /**
  169. * Adds the CSS transition/position classes of the off-canvas content container, based on the opening off-canvas element.
  170. * Beforehand any transition/position class gets removed.
  171. * @param {Boolean} hasReveal - true if related off-canvas element is revealed.
  172. * @private
  173. */
  174. _addContentClasses(hasReveal) {
  175. this._removeContentClasses(hasReveal);
  176. if (typeof hasReveal !== 'boolean') {
  177. this.$content.addClass(`has-transition-${this.options.transition} has-position-${this.position}`);
  178. } else if (hasReveal === true) {
  179. this.$content.addClass(`has-reveal-${this.position}`);
  180. }
  181. }
  182. /**
  183. * Handles the revealing/hiding the off-canvas at breakpoints, not the same as open.
  184. * @param {Boolean} isRevealed - true if element should be revealed.
  185. * @function
  186. */
  187. reveal(isRevealed) {
  188. if (isRevealed) {
  189. this.close();
  190. this.isRevealed = true;
  191. this.$element.attr('aria-hidden', 'false');
  192. this.$element.off('open.zf.trigger toggle.zf.trigger');
  193. this.$element.removeClass('is-closed');
  194. } else {
  195. this.isRevealed = false;
  196. this.$element.attr('aria-hidden', 'true');
  197. this.$element.off('open.zf.trigger toggle.zf.trigger').on({
  198. 'open.zf.trigger': this.open.bind(this),
  199. 'toggle.zf.trigger': this.toggle.bind(this)
  200. });
  201. this.$element.addClass('is-closed');
  202. }
  203. this._addContentClasses(isRevealed);
  204. }
  205. /**
  206. * Stops scrolling of the body when offcanvas is open on mobile Safari and other troublesome browsers.
  207. * @private
  208. */
  209. _stopScrolling(event) {
  210. return false;
  211. }
  212. // Taken and adapted from http://stackoverflow.com/questions/16889447/prevent-full-page-scrolling-ios
  213. // Only really works for y, not sure how to extend to x or if we need to.
  214. _recordScrollable(event) {
  215. let elem = this; // called from event handler context with this as elem
  216. // If the element is scrollable (content overflows), then...
  217. if (elem.scrollHeight !== elem.clientHeight) {
  218. // If we're at the top, scroll down one pixel to allow scrolling up
  219. if (elem.scrollTop === 0) {
  220. elem.scrollTop = 1;
  221. }
  222. // If we're at the bottom, scroll up one pixel to allow scrolling down
  223. if (elem.scrollTop === elem.scrollHeight - elem.clientHeight) {
  224. elem.scrollTop = elem.scrollHeight - elem.clientHeight - 1;
  225. }
  226. }
  227. elem.allowUp = elem.scrollTop > 0;
  228. elem.allowDown = elem.scrollTop < (elem.scrollHeight - elem.clientHeight);
  229. elem.lastY = event.originalEvent.pageY;
  230. }
  231. _stopScrollPropagation(event) {
  232. let elem = this; // called from event handler context with this as elem
  233. let up = event.pageY < elem.lastY;
  234. let down = !up;
  235. elem.lastY = event.pageY;
  236. if((up && elem.allowUp) || (down && elem.allowDown)) {
  237. event.stopPropagation();
  238. } else {
  239. event.preventDefault();
  240. }
  241. }
  242. /**
  243. * Opens the off-canvas menu.
  244. * @function
  245. * @param {Object} event - Event object passed from listener.
  246. * @param {jQuery} trigger - element that triggered the off-canvas to open.
  247. * @fires Offcanvas#opened
  248. * @todo also trigger 'open' event?
  249. */
  250. open(event, trigger) {
  251. if (this.$element.hasClass('is-open') || this.isRevealed) { return; }
  252. var _this = this;
  253. if (trigger) {
  254. this.$lastTrigger = trigger;
  255. }
  256. if (this.options.forceTo === 'top') {
  257. window.scrollTo(0, 0);
  258. } else if (this.options.forceTo === 'bottom') {
  259. window.scrollTo(0,document.body.scrollHeight);
  260. }
  261. if (this.options.transitionTime && this.options.transition !== 'overlap') {
  262. this.$element.siblings('[data-off-canvas-content]').css('transition-duration', this.options.transitionTime);
  263. } else {
  264. this.$element.siblings('[data-off-canvas-content]').css('transition-duration', '');
  265. }
  266. this.$element.addClass('is-open').removeClass('is-closed');
  267. this.$triggers.attr('aria-expanded', 'true');
  268. this.$element.attr('aria-hidden', 'false');
  269. this.$content.addClass('is-open-' + this.position);
  270. // If `contentScroll` is set to false, add class and disable scrolling on touch devices.
  271. if (this.options.contentScroll === false) {
  272. $('body').addClass('is-off-canvas-open').on('touchmove', this._stopScrolling);
  273. this.$element.on('touchstart', this._recordScrollable);
  274. this.$element.on('touchmove', this._stopScrollPropagation);
  275. }
  276. if (this.options.contentOverlay === true) {
  277. this.$overlay.addClass('is-visible');
  278. }
  279. if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
  280. this.$overlay.addClass('is-closable');
  281. }
  282. if (this.options.autoFocus === true) {
  283. this.$element.one(transitionend(this.$element), function() {
  284. if (!_this.$element.hasClass('is-open')) {
  285. return; // exit if prematurely closed
  286. }
  287. var canvasFocus = _this.$element.find('[data-autofocus]');
  288. if (canvasFocus.length) {
  289. canvasFocus.eq(0).focus();
  290. } else {
  291. _this.$element.find('a, button').eq(0).focus();
  292. }
  293. });
  294. }
  295. if (this.options.trapFocus === true) {
  296. this.$content.attr('tabindex', '-1');
  297. Keyboard.trapFocus(this.$element);
  298. }
  299. this._addContentClasses();
  300. /**
  301. * Fires when the off-canvas menu opens.
  302. * @event Offcanvas#opened
  303. */
  304. this.$element.trigger('opened.zf.offcanvas');
  305. }
  306. /**
  307. * Closes the off-canvas menu.
  308. * @function
  309. * @param {Function} cb - optional cb to fire after closure.
  310. * @fires Offcanvas#closed
  311. */
  312. close(cb) {
  313. if (!this.$element.hasClass('is-open') || this.isRevealed) { return; }
  314. var _this = this;
  315. this.$element.removeClass('is-open');
  316. this.$element.attr('aria-hidden', 'true')
  317. /**
  318. * Fires when the off-canvas menu opens.
  319. * @event Offcanvas#closed
  320. */
  321. .trigger('closed.zf.offcanvas');
  322. this.$content.removeClass('is-open-left is-open-top is-open-right is-open-bottom');
  323. // If `contentScroll` is set to false, remove class and re-enable scrolling on touch devices.
  324. if (this.options.contentScroll === false) {
  325. $('body').removeClass('is-off-canvas-open').off('touchmove', this._stopScrolling);
  326. this.$element.off('touchstart', this._recordScrollable);
  327. this.$element.off('touchmove', this._stopScrollPropagation);
  328. }
  329. if (this.options.contentOverlay === true) {
  330. this.$overlay.removeClass('is-visible');
  331. }
  332. if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
  333. this.$overlay.removeClass('is-closable');
  334. }
  335. this.$triggers.attr('aria-expanded', 'false');
  336. if (this.options.trapFocus === true) {
  337. this.$content.removeAttr('tabindex');
  338. Keyboard.releaseFocus(this.$element);
  339. }
  340. // Listen to transitionEnd and add class when done.
  341. this.$element.one(transitionend(this.$element), function(e) {
  342. _this.$element.addClass('is-closed');
  343. _this._removeContentClasses();
  344. });
  345. }
  346. /**
  347. * Toggles the off-canvas menu open or closed.
  348. * @function
  349. * @param {Object} event - Event object passed from listener.
  350. * @param {jQuery} trigger - element that triggered the off-canvas to open.
  351. */
  352. toggle(event, trigger) {
  353. if (this.$element.hasClass('is-open')) {
  354. this.close(event, trigger);
  355. }
  356. else {
  357. this.open(event, trigger);
  358. }
  359. }
  360. /**
  361. * Handles keyboard input when detected. When the escape key is pressed, the off-canvas menu closes, and focus is restored to the element that opened the menu.
  362. * @function
  363. * @private
  364. */
  365. _handleKeyboard(e) {
  366. Keyboard.handleKey(e, 'OffCanvas', {
  367. close: () => {
  368. this.close();
  369. this.$lastTrigger.focus();
  370. return true;
  371. },
  372. handled: () => {
  373. e.stopPropagation();
  374. e.preventDefault();
  375. }
  376. });
  377. }
  378. /**
  379. * Destroys the offcanvas plugin.
  380. * @function
  381. */
  382. _destroy() {
  383. this.close();
  384. this.$element.off('.zf.trigger .zf.offcanvas');
  385. this.$overlay.off('.zf.offcanvas');
  386. if (this.onLoadListener) $(window).off(this.onLoadListener);
  387. }
  388. }
  389. OffCanvas.defaults = {
  390. /**
  391. * Allow the user to click outside of the menu to close it.
  392. * @option
  393. * @type {boolean}
  394. * @default true
  395. */
  396. closeOnClick: true,
  397. /**
  398. * Adds an overlay on top of `[data-off-canvas-content]`.
  399. * @option
  400. * @type {boolean}
  401. * @default true
  402. */
  403. contentOverlay: true,
  404. /**
  405. * Target an off-canvas content container by ID that may be placed anywhere. If null the closest content container will be taken.
  406. * @option
  407. * @type {?string}
  408. * @default null
  409. */
  410. contentId: null,
  411. /**
  412. * Define the off-canvas element is nested in an off-canvas content. This is required when using the contentId option for a nested element.
  413. * @option
  414. * @type {boolean}
  415. * @default null
  416. */
  417. nested: null,
  418. /**
  419. * Enable/disable scrolling of the main content when an off canvas panel is open.
  420. * @option
  421. * @type {boolean}
  422. * @default true
  423. */
  424. contentScroll: true,
  425. /**
  426. * Amount of time in ms the open and close transition requires. If none selected, pulls from body style.
  427. * @option
  428. * @type {number}
  429. * @default null
  430. */
  431. transitionTime: null,
  432. /**
  433. * Type of transition for the offcanvas menu. Options are 'push', 'detached' or 'slide'.
  434. * @option
  435. * @type {string}
  436. * @default push
  437. */
  438. transition: 'push',
  439. /**
  440. * Force the page to scroll to top or bottom on open.
  441. * @option
  442. * @type {?string}
  443. * @default null
  444. */
  445. forceTo: null,
  446. /**
  447. * Allow the offcanvas to remain open for certain breakpoints.
  448. * @option
  449. * @type {boolean}
  450. * @default false
  451. */
  452. isRevealed: false,
  453. /**
  454. * Breakpoint at which to reveal. JS will use a RegExp to target standard classes, if changing classnames, pass your class with the `revealClass` option.
  455. * @option
  456. * @type {?string}
  457. * @default null
  458. */
  459. revealOn: null,
  460. /**
  461. * Force focus to the offcanvas on open. If true, will focus the opening trigger on close.
  462. * @option
  463. * @type {boolean}
  464. * @default true
  465. */
  466. autoFocus: true,
  467. /**
  468. * Class used to force an offcanvas to remain open. Foundation defaults for this are `reveal-for-large` & `reveal-for-medium`.
  469. * @option
  470. * @type {string}
  471. * @default reveal-for-
  472. * @todo improve the regex testing for this.
  473. */
  474. revealClass: 'reveal-for-',
  475. /**
  476. * Triggers optional focus trapping when opening an offcanvas. Sets tabindex of [data-off-canvas-content] to -1 for accessibility purposes.
  477. * @option
  478. * @type {boolean}
  479. * @default false
  480. */
  481. trapFocus: false
  482. }
  483. export {OffCanvas};