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. if (this.bannerContainer) {
  192. const bannerHeight =
  193. this.bannerContainer.offsetHeight +
  194. 2 * parseInt(this.bannerVPadding, 10);
  195. const bannerWidth = parseInt(
  196. getComputedStyle(this.element).getPropertyValue('width'),
  197. 10,
  198. );
  199. const [denominator, numerator] = ratio.split('/').map(Number);
  200. const currentHeight = (bannerWidth * numerator) / denominator;
  201. if (bannerHeight > currentHeight) {
  202. if (this.bannerImage) {
  203. this.bannerImage.style.aspectRatio = 'auto';
  204. }
  205. if (this.bannerVideo) {
  206. this.bannerVideo.style.aspectRatio = 'auto';
  207. }
  208. this.element.style.height = `${bannerHeight}px`;
  209. } else {
  210. this.resetBannerHeight();
  211. }
  212. }
  213. // Add margin to the banner container when there is a footer
  214. // This is needed to keep the vertical alignment
  215. if (this.bannerFooter) {
  216. this.element.style.setProperty(
  217. '--banner-footer-height',
  218. `${this.bannerFooter.offsetHeight}px`,
  219. );
  220. }
  221. }
  222. /**
  223. * Prepare to set the banner height
  224. */
  225. setBannerHeight() {
  226. if (this.bannerImage || this.bannerVideo) {
  227. this.waitForAspectRatioToBeDefined();
  228. } else {
  229. this.setHeight(this.defaultRatio());
  230. }
  231. }
  232. /**
  233. * Remove any override and get back the css
  234. */
  235. resetBannerHeight() {
  236. if (this.bannerImage) {
  237. const computedStyle = getComputedStyle(this.bannerImage);
  238. this.bannerImage.style.aspectRatio =
  239. computedStyle.getPropertyValue('--css-aspect-ratio');
  240. }
  241. if (this.bannerVideo) {
  242. this.bannerVideo.style.aspectRatio = this.defaultRatio();
  243. }
  244. this.element.style.height = 'auto';
  245. if (this.bannerFooter) {
  246. this.element.style.setProperty(
  247. '--banner-footer-height',
  248. `${this.bannerFooter.offsetHeight}px`,
  249. );
  250. }
  251. }
  252. /**
  253. * Check the current viewport width and act accordingly.
  254. */
  255. checkViewport() {
  256. if (window.innerWidth > this.breakpoint) {
  257. this.setBannerHeight();
  258. } else {
  259. this.resetBannerHeight();
  260. }
  261. }
  262. /**
  263. * Trigger events on resize
  264. * Uses a debounce, for performance
  265. */
  266. handleResize() {
  267. clearTimeout(this.resizeTimer);
  268. this.resizeTimer = setTimeout(() => {
  269. this.checkViewport();
  270. }, 200);
  271. }
  272. /**
  273. * Triggers a custom event when clicking on the cta.
  274. *
  275. * @param {e} Event
  276. * @fires Banner#onCtaClick
  277. */
  278. handleCtaClick(e) {
  279. let href = null;
  280. const anchor = e.target.closest('a');
  281. if (anchor) {
  282. href = anchor.getAttribute('href');
  283. }
  284. const eventData = { item: this.bannerCTA, target: href || e.target };
  285. this.trigger('onCtaClick', eventData);
  286. }
  287. /**
  288. * Triggers a custom event when clicking on the play button.
  289. *
  290. * @param {e} Event
  291. * @fires Banner#onPlayClick
  292. */
  293. handlePlayClick() {
  294. if (this.bannerVideo) {
  295. this.bannerVideo.play();
  296. }
  297. this.bannerPlay.style.display = 'none';
  298. if (this.bannerPause) {
  299. this.bannerPause.style.display = 'flex';
  300. this.bannerPause.focus();
  301. }
  302. const eventData = { item: this.bannerPlay };
  303. this.trigger('onPlayClick', eventData);
  304. }
  305. /**
  306. * Triggers a custom event when clicking on the pause button.
  307. *
  308. * @param {e} Event
  309. * @fires Banner#onPauseClick
  310. */
  311. handlePauseClick() {
  312. if (this.bannerVideo) {
  313. this.bannerVideo.pause();
  314. }
  315. this.bannerPause.style.display = 'none';
  316. if (this.bannerPlay) {
  317. this.bannerPlay.style.display = 'flex';
  318. this.bannerPlay.focus();
  319. }
  320. const eventData = { item: this.bannerPause };
  321. this.trigger('onPauseClick', eventData);
  322. }
  323. /**
  324. * Destroy component.
  325. */
  326. destroy() {
  327. this.resetBannerHeight();
  328. this.element.removeAttribute('data-ecl-auto-initialized');
  329. ECL.components.delete(this.element);
  330. if (this.attachResizeListener) {
  331. window.removeEventListener('resize', this.handleResize);
  332. }
  333. if (this.bannerCTA) {
  334. this.bannerCTA.removeEventListener('click', this.handleCtaClick);
  335. }
  336. if (this.bannerPlay) {
  337. this.bannerPlay.removeEventListener('click', this.handlePlayClick);
  338. }
  339. if (this.bannerPause) {
  340. this.bannerPause.removeEventListener('click', this.handlePauseClick);
  341. }
  342. }
  343. }
  344. export default Banner;