modal.js

  1. import { queryAll, queryOne } from '@ecl/dom-utils';
  2. import { createFocusTrap } from 'focus-trap';
  3. /**
  4. * @param {HTMLElement} element DOM element for component instantiation and scope
  5. * @param {Object} options
  6. * @param {String} options.toggleSelector Selector for the modal toggle
  7. * @param {String} options.closeSelector Selector for closing the modal
  8. * @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle
  9. * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
  10. */
  11. export class Modal {
  12. /**
  13. * @static
  14. * Shorthand for instance creation and initialisation.
  15. *
  16. * @param {HTMLElement} root DOM element for component instantiation and scope
  17. *
  18. * @return {Modal} An instance of Modal.
  19. */
  20. static autoInit(root, { MODAL: defaultOptions = {} } = {}) {
  21. const modal = new Modal(root, defaultOptions);
  22. modal.init();
  23. root.ECLModal = modal;
  24. return modal;
  25. }
  26. constructor(
  27. element,
  28. {
  29. toggleSelector = '',
  30. closeSelector = '[data-ecl-modal-close]',
  31. scrollSelector = '[data-ecl-modal-scroll]',
  32. attachClickListener = true,
  33. attachKeyListener = true,
  34. } = {},
  35. ) {
  36. // Check element
  37. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  38. throw new TypeError(
  39. 'DOM element should be given to initialize this widget.',
  40. );
  41. }
  42. this.element = element;
  43. // Options
  44. this.toggleSelector = toggleSelector;
  45. this.closeSelector = closeSelector;
  46. this.scrollSelector = scrollSelector;
  47. this.attachClickListener = attachClickListener;
  48. this.attachKeyListener = attachKeyListener;
  49. // Private variables
  50. this.toggle = null;
  51. this.close = null;
  52. this.scroll = null;
  53. this.focusTrap = null;
  54. // Bind `this` for use in callbacks
  55. this.openModal = this.openModal.bind(this);
  56. this.closeModal = this.closeModal.bind(this);
  57. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  58. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  59. }
  60. /**
  61. * Initialise component.
  62. */
  63. init() {
  64. if (!ECL) {
  65. throw new TypeError('Called init but ECL is not present');
  66. }
  67. ECL.components = ECL.components || new Map();
  68. // Bind global events
  69. if (this.attachKeyListener) {
  70. document.addEventListener('keyup', this.handleKeyboardGlobal);
  71. }
  72. // Get toggle element
  73. if (this.toggleSelector === '') {
  74. this.toggleSelector = `#${this.element.getAttribute(
  75. 'data-ecl-modal-toggle',
  76. )}`;
  77. }
  78. this.toggle = document.querySelector(this.toggleSelector);
  79. // Apply aria to toggle
  80. if (this.toggle) {
  81. this.toggle.setAttribute('aria-controls', this.element.id);
  82. if (!this.toggle.getAttribute('aria-haspopup')) {
  83. this.toggle.setAttribute('aria-haspopup', 'dialog');
  84. }
  85. }
  86. // Get other elements
  87. this.close = queryAll(this.closeSelector, this.element);
  88. this.scroll = queryOne(this.scrollSelector, this.element);
  89. // Create focus trap
  90. this.focusTrap = createFocusTrap(this.element);
  91. // Polyfill to support <dialog>
  92. this.isDialogSupported = true;
  93. if (!window.HTMLDialogElement) {
  94. this.isDialogSupported = false;
  95. }
  96. // Bind click event on toggle
  97. if (this.toggle && this.attachClickListener) {
  98. this.toggle.addEventListener('click', this.handleClickOnToggle);
  99. }
  100. // Bind click event on close buttons
  101. if (this.close && this.attachClickListener) {
  102. this.close.forEach((close) => {
  103. close.addEventListener('click', this.closeModal);
  104. });
  105. }
  106. // Set ecl initialized attribute
  107. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  108. ECL.components.set(this.element, this);
  109. }
  110. /**
  111. * Destroy component.
  112. */
  113. destroy() {
  114. if (this.toggle && this.attachClickListener) {
  115. this.toggle.removeEventListener('click', this.handleClickOnToggle);
  116. }
  117. if (this.attachKeyListener) {
  118. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  119. }
  120. if (this.close && this.attachClickListener) {
  121. this.close.forEach((close) => {
  122. close.removeEventListener('click', this.closeModal);
  123. });
  124. }
  125. this.element.removeAttribute('data-ecl-auto-initialized');
  126. ECL.components.delete(this.element);
  127. }
  128. /**
  129. * Check if there is a scroll and display overflow.
  130. */
  131. checkScroll() {
  132. if (!this.scroll) return;
  133. this.scroll.parentNode.classList.remove('ecl-modal__body--has-scroll');
  134. if (this.scroll.scrollHeight > this.scroll.clientHeight) {
  135. this.scroll.parentNode.classList.add('ecl-modal__body--has-scroll');
  136. }
  137. }
  138. /**
  139. * Toggles between collapsed/expanded states.
  140. *
  141. * @param {Event} e
  142. */
  143. handleClickOnToggle(e) {
  144. e.preventDefault();
  145. // Get current status
  146. const isExpanded = this.toggle.getAttribute('aria-expanded') === 'true';
  147. // Toggle the modal
  148. if (isExpanded) {
  149. this.closeModal();
  150. return;
  151. }
  152. this.openModal();
  153. }
  154. /**
  155. * Open the modal.
  156. */
  157. openModal() {
  158. if (this.isDialogSupported) {
  159. this.element.showModal();
  160. } else {
  161. this.element.setAttribute('open', '');
  162. }
  163. // Check scroll
  164. this.checkScroll();
  165. // Trap focus
  166. this.focusTrap.activate();
  167. }
  168. /**
  169. * Close the modal.
  170. */
  171. closeModal() {
  172. if (this.isDialogSupported) {
  173. this.element.close();
  174. } else {
  175. this.element.removeAttribute('open');
  176. }
  177. // Untrap focus
  178. if (this.focusTrap.active) {
  179. this.focusTrap.deactivate();
  180. }
  181. }
  182. /**
  183. * Handles global keyboard events, triggered outside of the modal.
  184. *
  185. * @param {Event} e
  186. */
  187. handleKeyboardGlobal(e) {
  188. // Detect press on Escape
  189. if (e.key === 'Escape' || e.key === 'Esc') {
  190. this.closeModal();
  191. }
  192. }
  193. }
  194. export default Modal;