banner.js

  1. import { queryOne } from '@ecl/dom-utils';
  2. import EventManager from '@ecl/event-manager';
  3. /**
  4. * @param {HTMLElement} element DOM element for component instantiation and scope
  5. * @param {Object} options
  6. * @param {String} options.bannerContainer Selector for the banner content
  7. * @param {String} options.bannerFooter Selector for the banner footer
  8. * @param {String} options.bannerVPadding Optional additional padding
  9. * @param {String} options.bannerPicture Selector for the banner picture
  10. * @param {String} options.bannerVideo Selector for the banner video
  11. * @param {String} options.bannerPlay Selector for the banner play button
  12. * @param {String} options.bannerPause Selector for the banner pause button
  13. * @param {String} options.maxIterations Used to limit the number of iterations when looking for css values
  14. * @param {String} options.breakpoint Breakpoint from which the script starts operating
  15. * @param {Boolean} options.attachResizeListener Whether to attach a listener on resize
  16. */
  17. export class Banner {
  18. /**
  19. * @static
  20. * Shorthand for instance creation and initialisation.
  21. *
  22. * @param {HTMLElement} root DOM element for component instantiation and scope
  23. *
  24. * @return {Banner} An instance of Banner.
  25. */
  26. static autoInit(root, { BANNER: defaultOptions = {} } = {}) {
  27. const banner = new Banner(root, defaultOptions);
  28. banner.init();
  29. root.ECLBanner = banner;
  30. return banner;
  31. }
  32. /**
  33. * An array of supported events for this component.
  34. *
  35. * @type {Array<string>}
  36. * @event Banner#onCtaClick
  37. * @event Banner#onPlayClick
  38. * @event Banner#onPauseClick
  39. * @memberof Banner
  40. */
  41. supportedEvents = ['onCtaClick', 'onPlayClick', 'onPauseClick'];
  42. constructor(
  43. element,
  44. {
  45. bannerContainer = '[data-ecl-banner-container]',
  46. bannerFooter = '[data-ecl-banner-footer]',
  47. bannerVPadding = '8',
  48. bannerPicture = '[data-ecl-banner-image]',
  49. bannerVideo = '[data-ecl-banner-video]',
  50. bannerPlay = '[data-ecl-banner-play]',
  51. bannerPause = '[data-ecl-banner-pause]',
  52. breakpoint = '996',
  53. attachResizeListener = true,
  54. maxIterations = 10,
  55. } = {},
  56. ) {
  57. // Check element
  58. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  59. throw new TypeError(
  60. 'DOM element should be given to initialize this widget.',
  61. );
  62. }
  63. this.element = element;
  64. this.eventManager = new EventManager();
  65. this.bannerVPadding = bannerVPadding;
  66. this.resizeTimer = null;
  67. this.bannerContainer = queryOne(bannerContainer, this.element);
  68. this.bannerFooter = queryOne(bannerFooter, this.element);
  69. this.bannerPicture = queryOne(bannerPicture, this.element);
  70. this.bannerVideo = queryOne(bannerVideo, this.element);
  71. this.bannerPlay = queryOne(bannerPlay, this.element);
  72. this.bannerPause = queryOne(bannerPause, this.element);
  73. this.bannerImage = this.bannerPicture
  74. ? queryOne('img', this.bannerPicture)
  75. : false;
  76. this.bannerCTA = this.bannerPicture
  77. ? queryOne('.ecl-banner__cta', this.element)
  78. : false;
  79. this.breakpoint = breakpoint;
  80. this.attachResizeListener = attachResizeListener;
  81. this.maxIterations = maxIterations;
  82. // Bind `this` for use in callbacks
  83. this.setBannerHeight = this.setBannerHeight.bind(this);
  84. this.checkViewport = this.checkViewport.bind(this);
  85. this.resetBannerHeight = this.resetBannerHeight.bind(this);
  86. this.handleResize = this.handleResize.bind(this);
  87. this.waitForAspectRatioToBeDefined =
  88. this.waitForAspectRatioToBeDefined.bind(this);
  89. this.setHeight = this.setHeight.bind(this);
  90. }
  91. /**
  92. * Initialise component.
  93. */
  94. init() {
  95. if (!ECL) {
  96. throw new TypeError('Called init but ECL is not present');
  97. }
  98. ECL.components = ECL.components || new Map();
  99. this.defaultRatio = () => {
  100. if (this.element.classList.contains('ecl-banner--xs')) {
  101. return '6/1';
  102. }
  103. if (this.element.classList.contains('ecl-banner--s')) {
  104. return '5/1';
  105. }
  106. if (this.element.classList.contains('ecl-banner--l')) {
  107. return '3/1';
  108. }
  109. return '4/1';
  110. };
  111. if (this.attachResizeListener) {
  112. window.addEventListener('resize', this.handleResize);
  113. }
  114. if (this.bannerCTA) {
  115. this.bannerCTA.addEventListener('click', (e) => this.handleCtaClick(e));
  116. }
  117. if (this.bannerPlay) {
  118. this.bannerPlay.addEventListener('click', (e) => this.handlePlayClick(e));
  119. this.bannerPlay.style.display = 'none';
  120. }
  121. if (this.bannerPause) {
  122. this.bannerPause.addEventListener('click', (e) =>
  123. this.handlePauseClick(e),
  124. );
  125. this.bannerPause.style.display = 'flex';
  126. }
  127. this.checkViewport();
  128. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  129. ECL.components.set(this.element, this);
  130. }
  131. /**
  132. * Register a callback function for a specific event.
  133. *
  134. * @param {string} eventName - The name of the event to listen for.
  135. * @param {Function} callback - The callback function to be invoked when the event occurs.
  136. * @returns {void}
  137. * @memberof Banner
  138. * @instance
  139. *
  140. * @example
  141. * // Registering a callback for the 'onCtaClick' event
  142. * banner.on('onCtaClick', (event) => {
  143. * console.log('The cta was clicked', event);
  144. * });
  145. */
  146. on(eventName, callback) {
  147. this.eventManager.on(eventName, callback);
  148. }
  149. /**
  150. * Trigger a component event.
  151. *
  152. * @param {string} eventName - The name of the event to trigger.
  153. * @param {any} eventData - Data associated with the event.
  154. *
  155. * @memberof Banner
  156. */
  157. trigger(eventName, eventData) {
  158. this.eventManager.trigger(eventName, eventData);
  159. }
  160. /**
  161. * Retrieve the value of the aspect ratio in the styles.
  162. */
  163. waitForAspectRatioToBeDefined() {
  164. this.attemptCounter = (this.attemptCounter || 0) + 1;
  165. let aspectRatio = '';
  166. if (this.bannerVideo) {
  167. // Ensure that the video is loaded (width > 0) before passing the ratio
  168. if (this.bannerVideo.videoWidth > 0) {
  169. aspectRatio = this.defaultRatio();
  170. }
  171. } else if (this.bannerImage) {
  172. aspectRatio = getComputedStyle(this.bannerImage).getPropertyValue(
  173. '--css-aspect-ratio',
  174. );
  175. }
  176. if (
  177. (typeof aspectRatio === 'undefined' || aspectRatio === '') &&
  178. this.maxIterations > this.attemptCounter
  179. ) {
  180. setTimeout(() => this.waitForAspectRatioToBeDefined(), 100);
  181. } else {
  182. this.setHeight(aspectRatio);
  183. }
  184. }
  185. /**
  186. * Sets or resets the banner height
  187. *
  188. * @param {string} aspect ratio
  189. */
  190. setHeight(ratio) {
  191. const bannerHeight =
  192. this.bannerContainer.offsetHeight + 2 * parseInt(this.bannerVPadding, 10);
  193. const bannerWidth = parseInt(
  194. getComputedStyle(this.element).getPropertyValue('width'),
  195. 10,
  196. );
  197. const [denominator, numerator] = ratio.split('/').map(Number);
  198. const currentHeight = (bannerWidth * numerator) / denominator;
  199. if (bannerHeight > currentHeight) {
  200. if (this.bannerImage) {
  201. this.bannerImage.style.aspectRatio = 'auto';
  202. }
  203. if (this.bannerVideo) {
  204. this.bannerVideo.style.aspectRatio = 'auto';
  205. }
  206. this.element.style.height = `${bannerHeight}px`;
  207. } else {
  208. this.resetBannerHeight();
  209. }
  210. // Add margin to the banner container when there is a footer
  211. // This is needed to keep the vertical alignment
  212. if (this.bannerFooter) {
  213. this.element.style.setProperty(
  214. '--banner-footer-height',
  215. `${this.bannerFooter.offsetHeight}px`,
  216. );
  217. }
  218. }
  219. /**
  220. * Prepare to set the banner height
  221. */
  222. setBannerHeight() {
  223. if (this.bannerImage || this.bannerVideo) {
  224. this.waitForAspectRatioToBeDefined();
  225. } else {
  226. this.setHeight(this.defaultRatio());
  227. }
  228. }
  229. /**
  230. * Remove any override and get back the css
  231. */
  232. resetBannerHeight() {
  233. if (this.bannerImage) {
  234. const computedStyle = getComputedStyle(this.bannerImage);
  235. this.bannerImage.style.aspectRatio =
  236. computedStyle.getPropertyValue('--css-aspect-ratio');
  237. }
  238. if (this.bannerVideo) {
  239. this.bannerVideo.style.aspectRatio = this.defaultRatio();
  240. }
  241. this.element.style.height = 'auto';
  242. if (this.bannerFooter) {
  243. this.element.style.setProperty(
  244. '--banner-footer-height',
  245. `${this.bannerFooter.offsetHeight}px`,
  246. );
  247. }
  248. }
  249. /**
  250. * Check the current viewport width and act accordingly.
  251. */
  252. checkViewport() {
  253. if (window.innerWidth > this.breakpoint) {
  254. this.setBannerHeight();
  255. } else {
  256. this.resetBannerHeight();
  257. }
  258. }
  259. /**
  260. * Trigger events on resize
  261. * Uses a debounce, for performance
  262. */
  263. handleResize() {
  264. clearTimeout(this.resizeTimer);
  265. this.resizeTimer = setTimeout(() => {
  266. this.checkViewport();
  267. }, 200);
  268. }
  269. /**
  270. * Triggers a custom event when clicking on the cta.
  271. *
  272. * @param {e} Event
  273. * @fires Banner#onCtaClick
  274. */
  275. handleCtaClick(e) {
  276. let href = null;
  277. const anchor = e.target.closest('a');
  278. if (anchor) {
  279. href = anchor.getAttribute('href');
  280. }
  281. const eventData = { item: this.bannerCTA, target: href || e.target };
  282. this.trigger('onCtaClick', eventData);
  283. }
  284. /**
  285. * Triggers a custom event when clicking on the play button.
  286. *
  287. * @param {e} Event
  288. * @fires Banner#onPlayClick
  289. */
  290. handlePlayClick() {
  291. if (this.bannerVideo) {
  292. this.bannerVideo.play();
  293. }
  294. this.bannerPlay.style.display = 'none';
  295. if (this.bannerPause) {
  296. this.bannerPause.style.display = 'flex';
  297. this.bannerPause.focus();
  298. }
  299. const eventData = { item: this.bannerPlay };
  300. this.trigger('onPlayClick', eventData);
  301. }
  302. /**
  303. * Triggers a custom event when clicking on the pause button.
  304. *
  305. * @param {e} Event
  306. * @fires Banner#onPauseClick
  307. */
  308. handlePauseClick() {
  309. if (this.bannerVideo) {
  310. this.bannerVideo.pause();
  311. }
  312. this.bannerPause.style.display = 'none';
  313. if (this.bannerPlay) {
  314. this.bannerPlay.style.display = 'flex';
  315. this.bannerPlay.focus();
  316. }
  317. const eventData = { item: this.bannerPause };
  318. this.trigger('onPauseClick', eventData);
  319. }
  320. /**
  321. * Destroy component.
  322. */
  323. destroy() {
  324. this.resetBannerHeight();
  325. this.element.removeAttribute('data-ecl-auto-initialized');
  326. ECL.components.delete(this.element);
  327. if (this.attachResizeListener) {
  328. window.removeEventListener('resize', this.handleResize);
  329. }
  330. if (this.bannerCTA) {
  331. this.bannerCTA.removeEventListener('click', this.handleCtaClick);
  332. }
  333. if (this.bannerPlay) {
  334. this.bannerPlay.removeEventListener('click', this.handlePlayClick);
  335. }
  336. if (this.bannerPause) {
  337. this.bannerPause.removeEventListener('click', this.handlePauseClick);
  338. }
  339. }
  340. }
  341. export default Banner;