popover.js

  1. import { queryOne } from '@ecl/dom-utils';
  2. /**
  3. * @param {HTMLElement} element DOM element for component instantiation and scope
  4. * @param {Object} options
  5. * @param {String} options.toggleSelector Selector for toggling element
  6. * @param {Boolean} options.attachClickListener Whether or not to bind click events on toggle
  7. * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
  8. */
  9. export class Popover {
  10. /**
  11. * @static
  12. * Shorthand for instance creation and initialisation.
  13. *
  14. * @param {HTMLElement} root DOM element for component instantiation and scope
  15. *
  16. * @return {Popover} An instance of Popover.
  17. */
  18. static autoInit(root, { POPOVER: defaultOptions = {} } = {}) {
  19. const popover = new Popover(root, defaultOptions);
  20. popover.init();
  21. root.ECLPopover = popover;
  22. return popover;
  23. }
  24. constructor(
  25. element,
  26. {
  27. toggleSelector = '[data-ecl-popover-toggle]',
  28. attachClickListener = true,
  29. attachKeyListener = true,
  30. } = {},
  31. ) {
  32. // Check element
  33. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  34. throw new TypeError(
  35. 'DOM element should be given to initialize this widget.',
  36. );
  37. }
  38. this.element = element;
  39. // Options
  40. this.toggleSelector = toggleSelector;
  41. this.attachClickListener = attachClickListener;
  42. this.attachKeyListener = attachKeyListener;
  43. // Private variables
  44. this.toggle = null;
  45. this.target = null;
  46. // Bind `this` for use in callbacks
  47. this.openPopover = this.openPopover.bind(this);
  48. this.closePopover = this.closePopover.bind(this);
  49. this.positionPopover = this.positionPopover.bind(this);
  50. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  51. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  52. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  53. }
  54. /**
  55. * Initialise component.
  56. */
  57. init() {
  58. if (!ECL) {
  59. throw new TypeError('Called init but ECL is not present');
  60. }
  61. ECL.components = ECL.components || new Map();
  62. this.toggle = queryOne(this.toggleSelector, this.element);
  63. // Bind global events
  64. if (this.attachKeyListener) {
  65. document.addEventListener('keyup', this.handleKeyboardGlobal);
  66. }
  67. if (this.attachClickListener) {
  68. document.addEventListener('click', this.handleClickGlobal);
  69. }
  70. // Get target element
  71. this.target = document.querySelector(
  72. `#${this.toggle.getAttribute('aria-controls')}`,
  73. );
  74. // Exit if no target found
  75. if (!this.target) {
  76. throw new TypeError(
  77. 'Target has to be provided for popover (aria-controls)',
  78. );
  79. }
  80. // Bind click event on toggle
  81. if (this.attachClickListener && this.toggle) {
  82. this.toggle.addEventListener('click', this.handleClickOnToggle);
  83. }
  84. // Set ecl initialized attribute
  85. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  86. ECL.components.set(this.element, this);
  87. }
  88. /**
  89. * Destroy component.
  90. */
  91. destroy() {
  92. if (this.attachClickListener && this.toggle) {
  93. this.toggle.removeEventListener('click', this.handleClickOnToggle);
  94. }
  95. if (this.attachKeyListener) {
  96. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  97. }
  98. if (this.attachClickListener) {
  99. document.removeEventListener('click', this.handleClickGlobal);
  100. }
  101. if (this.element) {
  102. this.element.removeAttribute('data-ecl-auto-initialized');
  103. ECL.components.delete(this.element);
  104. }
  105. }
  106. /**
  107. * Toggles between collapsed/expanded states.
  108. *
  109. * @param {Event} e
  110. */
  111. handleClickOnToggle(e) {
  112. e.preventDefault();
  113. // Get current status
  114. const isExpanded = this.toggle.getAttribute('aria-expanded') === 'true';
  115. // Toggle the popover
  116. if (isExpanded) {
  117. this.closePopover();
  118. return;
  119. }
  120. this.openPopover();
  121. this.positionPopover();
  122. }
  123. /**
  124. * Open the popover.
  125. */
  126. openPopover() {
  127. this.toggle.setAttribute('aria-expanded', 'true');
  128. this.target.hidden = false;
  129. }
  130. /**
  131. * Close the popover.
  132. */
  133. closePopover() {
  134. this.toggle.setAttribute('aria-expanded', 'false');
  135. this.target.hidden = true;
  136. }
  137. /**
  138. * Manage popover position.
  139. */
  140. positionPopover() {
  141. // Check available space
  142. this.element.classList.remove('ecl-popover--top');
  143. this.element.classList.remove('ecl-popover--push-left');
  144. this.element.classList.remove('ecl-popover--push-right');
  145. const toggleRect = this.toggle.getBoundingClientRect();
  146. const popoverRect = this.target.getBoundingClientRect();
  147. const popoverHeight = this.target.clientHeight;
  148. const screenHeight = window.innerHeight;
  149. const screenWidth = window.innerWidth;
  150. if (popoverHeight > 0 && screenHeight - toggleRect.top < popoverHeight) {
  151. this.element.classList.add('ecl-popover--top');
  152. }
  153. if (popoverRect.left < 0) {
  154. this.element.classList.add('ecl-popover--push-left');
  155. // Adapt arrow position
  156. this.target.style.setProperty(
  157. '--ecl-popover-position',
  158. `${toggleRect.width / 2}px`,
  159. );
  160. }
  161. if (popoverRect.right > screenWidth) {
  162. this.element.classList.add('ecl-popover--push-right');
  163. // Adapt arrow position
  164. this.target.style.setProperty(
  165. '--ecl-popover-position',
  166. `calc(${toggleRect.width / 2}px - 0.5rem)`,
  167. );
  168. }
  169. }
  170. /**
  171. * Handles global keyboard events, triggered outside of the popover.
  172. *
  173. * @param {Event} e
  174. */
  175. handleKeyboardGlobal(e) {
  176. if (!this.target) return;
  177. // Detect press on Escape
  178. if (e.key === 'Escape' || e.key === 'Esc') {
  179. this.closePopover();
  180. }
  181. }
  182. /**
  183. * Handles global click events, triggered outside of the popover.
  184. *
  185. * @param {Event} e
  186. */
  187. handleClickGlobal(e) {
  188. if (!this.target) return;
  189. // Check if the popover is open
  190. if (this.toggle.getAttribute('aria-expanded') === 'true') {
  191. // Check if the click occured on the popover
  192. if (!this.target.contains(e.target) && !this.toggle.contains(e.target)) {
  193. this.closePopover();
  194. }
  195. }
  196. }
  197. }
  198. export default Popover;