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. closeSelector = '[data-ecl-popover-close]',
  29. attachClickListener = true,
  30. attachKeyListener = true,
  31. } = {},
  32. ) {
  33. // Check element
  34. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  35. throw new TypeError(
  36. 'DOM element should be given to initialize this widget.',
  37. );
  38. }
  39. this.element = element;
  40. // Options
  41. this.toggleSelector = toggleSelector;
  42. this.closeSelector = closeSelector;
  43. this.attachClickListener = attachClickListener;
  44. this.attachKeyListener = attachKeyListener;
  45. // Private variables
  46. this.toggle = null;
  47. this.close = null;
  48. this.target = null;
  49. this.container = null;
  50. this.resizeTimer = null;
  51. // Bind `this` for use in callbacks
  52. this.openPopover = this.openPopover.bind(this);
  53. this.closePopover = this.closePopover.bind(this);
  54. this.positionPopover = this.positionPopover.bind(this);
  55. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  56. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  57. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  58. this.checkPosition = this.checkPosition.bind(this);
  59. this.resetStyles = this.resetStyles.bind(this);
  60. this.POPOVER_CLASSES = {
  61. TOP: 'ecl-popover--top',
  62. BOTTOM: 'ecl-popover--bottom',
  63. LEFT: 'ecl-popover--left',
  64. RIGHT: 'ecl-popover--right',
  65. PUSH_TOP: 'ecl-popover--push-top',
  66. PUSH_BOTTOM: 'ecl-popover--push-bottom',
  67. PUSH_LEFT: 'ecl-popover--push-left',
  68. PUSH_RIGHT: 'ecl-popover--push-right',
  69. };
  70. }
  71. /**
  72. * Initialise component.
  73. */
  74. init() {
  75. if (!ECL) {
  76. throw new TypeError('Called init but ECL is not present');
  77. }
  78. ECL.components = ECL.components || new Map();
  79. this.toggle = queryOne(this.toggleSelector, this.element);
  80. this.close = queryOne(this.closeSelector, this.element);
  81. this.container = queryOne('.ecl-popover__container', this.element);
  82. // Bind global events
  83. if (this.attachKeyListener) {
  84. document.addEventListener('keyup', this.handleKeyboardGlobal);
  85. }
  86. if (this.attachClickListener) {
  87. document.addEventListener('click', this.handleClickGlobal);
  88. if (this.close) {
  89. this.close.addEventListener('click', this.handleClickOnToggle);
  90. }
  91. }
  92. // Get target element
  93. this.target = document.querySelector(
  94. `#${this.toggle.getAttribute('aria-controls')}`,
  95. );
  96. // Exit if no target found
  97. if (!this.target) {
  98. throw new TypeError(
  99. 'Target has to be provided for popover (aria-controls)',
  100. );
  101. }
  102. window.addEventListener('resize', this.checkPosition);
  103. document.addEventListener('scroll', this.checkPosition);
  104. // Bind click event on toggle
  105. if (this.attachClickListener && this.toggle) {
  106. this.toggle.addEventListener('click', this.handleClickOnToggle);
  107. }
  108. // Set ecl initialized attribute
  109. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  110. ECL.components.set(this.element, this);
  111. }
  112. /**
  113. * Destroy component.
  114. */
  115. destroy() {
  116. if (this.attachClickListener && this.toggle) {
  117. this.toggle.removeEventListener('click', this.handleClickOnToggle);
  118. }
  119. if (this.attachClickListener && this.close) {
  120. this.close.removeEventListener('click', this.handleClickOnToggle);
  121. }
  122. window.removeEventListener('resize', this.checkPosition);
  123. document.removeEventListener('scroll', this.checkPosition);
  124. if (this.attachKeyListener) {
  125. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  126. }
  127. if (this.attachClickListener) {
  128. document.removeEventListener('click', this.handleClickGlobal);
  129. }
  130. if (this.toggle.getAttribute('aria-expanded') === 'true') {
  131. this.closePopover();
  132. }
  133. if (this.element) {
  134. this.element.removeAttribute('data-ecl-auto-initialized');
  135. ECL.components.delete(this.element);
  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 popover
  148. if (isExpanded) {
  149. this.closePopover();
  150. return;
  151. }
  152. this.openPopover();
  153. this.positionPopover();
  154. }
  155. /**
  156. * Open the popover.
  157. */
  158. openPopover() {
  159. this.toggle.setAttribute('aria-expanded', 'true');
  160. this.target.hidden = false;
  161. }
  162. /**
  163. * Close the popover.
  164. */
  165. closePopover() {
  166. this.toggle.setAttribute('aria-expanded', 'false');
  167. // Reset all the selectors and styles
  168. this.resetStyles();
  169. this.target.hidden = true;
  170. }
  171. /**
  172. * Resets the popover selectors and styles.
  173. */
  174. resetStyles() {
  175. Object.keys(this.POPOVER_CLASSES).forEach((className) => {
  176. if (
  177. Object.prototype.hasOwnProperty.call(this.POPOVER_CLASSES, className)
  178. ) {
  179. this.element.classList.remove(this.POPOVER_CLASSES[className]);
  180. }
  181. });
  182. this.target.style.setProperty('--ecl-popover-position', '');
  183. this.container.style.left = '';
  184. this.container.style.right = '';
  185. this.container.style.top = '';
  186. this.container.style.bottom = '';
  187. this.container.style.transform = '';
  188. this.target.firstElementChild.width = '';
  189. }
  190. /**
  191. * Manage popover position.
  192. */
  193. positionPopover() {
  194. this.resetStyles();
  195. const toggleRect = this.toggle.getBoundingClientRect();
  196. const screenHeight = window.innerHeight;
  197. const screenWidth = window.innerWidth;
  198. // Calculate available space in each direction
  199. const spaceTop = toggleRect.top;
  200. const spaceBottom = screenHeight - toggleRect.bottom;
  201. const spaceLeft = toggleRect.left;
  202. const spaceRight = screenWidth - toggleRect.right;
  203. // Find the direction with the most available space
  204. const positioningClass = 'ecl-popover--';
  205. let direction = '';
  206. if (
  207. spaceTop > spaceBottom &&
  208. spaceTop > spaceLeft &&
  209. spaceTop > spaceRight
  210. ) {
  211. direction = 'top';
  212. } else if (spaceBottom > spaceLeft && spaceBottom > spaceRight) {
  213. direction = 'bottom';
  214. } else if (spaceLeft > spaceRight) {
  215. direction = 'left';
  216. } else {
  217. direction = 'right';
  218. }
  219. this.element.classList.add(`${positioningClass}${direction}`);
  220. this.handlePushClass(screenWidth, screenHeight, direction);
  221. // Try to use as much of the available width, respecting the max-width set.
  222. const scrollable = this.target.firstElementChild;
  223. const styles = window.getComputedStyle(scrollable);
  224. const maxWidth = parseInt(styles.getPropertyValue('max-width'), 10);
  225. const minWidth = parseInt(styles.getPropertyValue('min-width'), 10);
  226. const padding = parseInt(styles.getPropertyValue('padding-left'), 10) * 2;
  227. let availableSpace = '';
  228. if (direction === 'left' || direction === 'right') {
  229. availableSpace = (direction === 'left' ? spaceLeft : spaceRight) * 0.9;
  230. } else {
  231. const centerPosition =
  232. (this.toggle.getBoundingClientRect().right -
  233. this.toggle.getBoundingClientRect().left) /
  234. 2;
  235. availableSpace =
  236. (screenWidth - centerPosition + this.target.offsetWidth / 2) * 0.9;
  237. }
  238. if (maxWidth + padding < availableSpace) {
  239. scrollable.style.width = `${maxWidth}px`;
  240. } else if (availableSpace < minWidth + padding) {
  241. scrollable.style.width = `${minWidth}px`;
  242. } else {
  243. scrollable.style.width = `${availableSpace - padding}px`;
  244. }
  245. }
  246. handlePushClass(screenWidth, screenHeight, direction) {
  247. const toggleRect = this.toggle.getBoundingClientRect();
  248. const popoverRect = this.target.getBoundingClientRect();
  249. if (direction === 'left' || direction === 'right') {
  250. if (popoverRect.top < 0) {
  251. this.element.classList.add(this.POPOVER_CLASSES.PUSH_TOP);
  252. this.container.style.top = `-${Math.round(toggleRect.top)}px`;
  253. this.container.style.bottom = '';
  254. this.container.style.transform = '';
  255. } else if (popoverRect.bottom > screenHeight) {
  256. this.element.classList.add(this.POPOVER_CLASSES.PUSH_BOTTOM);
  257. // We add 0.5rem to the calculus to avoid vertical scrollbars.
  258. this.container.style.bottom = `-${Math.round(
  259. screenHeight - (toggleRect.bottom + 8),
  260. )}px`;
  261. this.container.style.top = '';
  262. this.container.style.transform = '';
  263. }
  264. } else {
  265. if (popoverRect.left < 0) {
  266. this.element.classList.add(this.POPOVER_CLASSES.PUSH_LEFT);
  267. this.container.style.left = `-${toggleRect.left}px`;
  268. this.container.style.right = 'auto';
  269. this.container.style.transform = 'none';
  270. }
  271. if (popoverRect.right > screenWidth) {
  272. this.element.classList.add(this.POPOVER_CLASSES.PUSH_RIGHT);
  273. this.container.style.right = `-${screenWidth - toggleRect.right}px`;
  274. this.container.style.left = 'auto';
  275. this.container.style.transform = 'none';
  276. }
  277. }
  278. this.handleArrowPosition(direction);
  279. }
  280. handleArrowPosition(direction) {
  281. const toggleRect = this.toggle.getBoundingClientRect();
  282. const popoverRect = this.target.getBoundingClientRect();
  283. if (direction === 'left' || direction === 'right') {
  284. if (this.element.classList.contains(this.POPOVER_CLASSES.PUSH_BOTTOM)) {
  285. this.target.style.setProperty(
  286. '--ecl-popover-position',
  287. `${Math.round(
  288. toggleRect.top - popoverRect.top + toggleRect.height / 2,
  289. )}px`,
  290. );
  291. } else if (
  292. this.element.classList.contains(this.POPOVER_CLASSES.PUSH_TOP)
  293. ) {
  294. this.target.style.setProperty(
  295. '--ecl-popover-position',
  296. `${Math.round(
  297. popoverRect.top + toggleRect.top + toggleRect.height / 2,
  298. )}px`,
  299. );
  300. }
  301. } else {
  302. // eslint-disable-next-line no-lonely-if
  303. if (this.element.classList.contains(this.POPOVER_CLASSES.PUSH_RIGHT)) {
  304. this.target.style.setProperty(
  305. '--ecl-popover-position',
  306. `${Math.round(
  307. popoverRect.right - (toggleRect.right - toggleRect.width / 2),
  308. )}px`,
  309. );
  310. } else if (
  311. this.element.classList.contains(this.POPOVER_CLASSES.PUSH_LEFT)
  312. ) {
  313. this.target.style.setProperty(
  314. '--ecl-popover-position',
  315. `${Math.round(
  316. popoverRect.left + toggleRect.left + toggleRect.width / 2,
  317. )}px`,
  318. );
  319. }
  320. }
  321. }
  322. /**
  323. * Trigger events on resize
  324. * Uses a debounce, for performance
  325. */
  326. checkPosition() {
  327. clearTimeout(this.resizeTimer);
  328. this.resizeTimer = setTimeout(() => {
  329. if (this.toggle.getAttribute('aria-expanded') === 'true') {
  330. this.positionPopover();
  331. }
  332. }, 200);
  333. }
  334. /**
  335. * Handles global keyboard events, triggered outside of the popover.
  336. *
  337. * @param {Event} e
  338. */
  339. handleKeyboardGlobal(e) {
  340. if (!this.target) return;
  341. // Detect press on Escape
  342. if (e.key === 'Escape' || e.key === 'Esc') {
  343. this.closePopover();
  344. }
  345. }
  346. /**
  347. * Handles global click events, triggered outside of the popover.
  348. *
  349. * @param {Event} e
  350. */
  351. handleClickGlobal(e) {
  352. if (!this.target) return;
  353. // Check if the popover is open
  354. if (this.toggle.getAttribute('aria-expanded') === 'true') {
  355. // Check if the click occured on the popover
  356. if (!this.target.contains(e.target) && !this.toggle.contains(e.target)) {
  357. this.closePopover();
  358. }
  359. }
  360. }
  361. }
  362. export default Popover;