carousel.js

  1. import { queryOne, queryAll } 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 {String} options.contentClass Selector for the content container
  7. * @param {String} options.slidesClass Selector for the slides container
  8. * @param {String} options.slideClass Selector for the slide items
  9. * @param {String} options.navigationClass Selector for the navigation container
  10. * @param {String} options.currentSlideClass Selector for the counter current slide number
  11. */
  12. export class Carousel {
  13. /**
  14. * @static
  15. * Shorthand for instance creation and initialisation.
  16. *
  17. * @param {HTMLElement} root DOM element for component instantiation and scope
  18. *
  19. * @return {Carousel} An instance of Carousel.
  20. */
  21. static autoInit(root, { CAROUSEL: defaultOptions = {} } = {}) {
  22. const carousel = new Carousel(root, defaultOptions);
  23. carousel.init();
  24. root.ECLCarousel = carousel;
  25. return carousel;
  26. }
  27. constructor(
  28. element,
  29. {
  30. playSelector = '.ecl-carousel__play',
  31. pauseSelector = '.ecl-carousel__pause',
  32. containerClass = '.ecl-carousel__container',
  33. slidesClass = '.ecl-carousel__slides',
  34. slideClass = '.ecl-carousel__slide',
  35. currentSlideClass = '.ecl-carousel__current',
  36. navigationItemsClass = '.ecl-carousel__navigation-item',
  37. controlsClass = '.ecl-carousel__controls',
  38. attachClickListener = true,
  39. attachResizeListener = true,
  40. } = {},
  41. ) {
  42. // Check element
  43. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  44. throw new TypeError(
  45. 'DOM element should be given to initialize this widget.',
  46. );
  47. }
  48. this.element = element;
  49. // Options
  50. this.playSelector = playSelector;
  51. this.pauseSelector = pauseSelector;
  52. this.containerClass = containerClass;
  53. this.slidesClass = slidesClass;
  54. this.slideClass = slideClass;
  55. this.currentSlideClass = currentSlideClass;
  56. this.navigationItemsClass = navigationItemsClass;
  57. this.controlsClass = controlsClass;
  58. this.attachClickListener = attachClickListener;
  59. this.attachResizeListener = attachResizeListener;
  60. // Private variables
  61. this.container = null;
  62. this.slides = null;
  63. this.btnPlay = null;
  64. this.btnPause = null;
  65. this.index = 1;
  66. this.total = 0;
  67. this.allowShift = true;
  68. this.activeNav = null;
  69. this.autoPlay = null;
  70. this.autoPlayInterval = null;
  71. this.hoverAutoPlay = null;
  72. this.resizeTimer = null;
  73. this.posX1 = 0;
  74. this.posX2 = 0;
  75. this.posInitial = 0;
  76. this.posFinal = 0;
  77. this.threshold = 80;
  78. this.navigationItems = null;
  79. this.navigation = null;
  80. this.controls = null;
  81. this.direction = 'ltr';
  82. this.cloneFirstSLide = null;
  83. this.cloneLastSLide = null;
  84. this.executionCount = 0;
  85. this.maxExecutions = 5;
  86. this.slideWidth = 0;
  87. // Bind `this` for use in callbacks
  88. this.handleAutoPlay = this.handleAutoPlay.bind(this);
  89. this.handleMouseOver = this.handleMouseOver.bind(this);
  90. this.handleMouseOut = this.handleMouseOut.bind(this);
  91. this.shiftSlide = this.shiftSlide.bind(this);
  92. this.checkIndex = this.checkIndex.bind(this);
  93. this.moveSlides = this.moveSlides.bind(this);
  94. this.handleResize = this.handleResize.bind(this);
  95. this.dragStart = this.dragStart.bind(this);
  96. this.dragEnd = this.dragEnd.bind(this);
  97. this.dragAction = this.dragAction.bind(this);
  98. this.handleFocus = this.handleFocus.bind(this);
  99. this.handleKeyboardOnPlay = this.handleKeyboardOnPlay.bind(this);
  100. this.handleKeyboardOnBullets = this.handleKeyboardOnBullets.bind(this);
  101. this.checkBannerHeights = this.checkBannerHeights.bind(this);
  102. this.resetBannerHeights = this.resetBannerHeights.bind(this);
  103. }
  104. /**
  105. * Initialise component.
  106. */
  107. init() {
  108. if (!ECL) {
  109. throw new TypeError('Called init but ECL is not present');
  110. }
  111. ECL.components = ECL.components || new Map();
  112. // Hide the carousel initially, we will show it in handleesize()
  113. this.element.style.opacity = 0;
  114. this.btnPlay = queryOne(this.playSelector, this.element);
  115. this.btnPause = queryOne(this.pauseSelector, this.element);
  116. this.slidesContainer = queryOne(this.slidesClass, this.element);
  117. this.container = queryOne(this.containerClass, this.element);
  118. this.navigation = queryOne('.ecl-carousel__navigation', this.element);
  119. this.navigationItems = queryAll(this.navigationItemsClass, this.element);
  120. this.controls = queryOne(this.controlsClass, this.element);
  121. this.currentSlide = queryOne(this.currentSlideClass, this.element);
  122. this.direction = getComputedStyle(this.element).direction;
  123. this.slides = queryAll(this.slideClass, this.element);
  124. this.total = this.slides.length;
  125. // If only one slide, don't initialize carousel and hide controls
  126. if (this.total <= 1) {
  127. if (this.controls) {
  128. this.controls.style.display = 'none';
  129. }
  130. if (this.slidesContainer) {
  131. this.slidesContainer.style.display = 'block';
  132. }
  133. return false;
  134. }
  135. // Start initializing carousel
  136. const firstSlide = this.slides[0];
  137. const lastSlide = this.slides[this.slides.length - 1];
  138. // Clone first and last slide
  139. this.cloneFirstSlide = firstSlide.cloneNode(true);
  140. this.cloneLastSlide = lastSlide.cloneNode(true);
  141. this.slidesContainer.appendChild(this.cloneFirstSlide);
  142. this.slidesContainer.insertBefore(this.cloneLastSlide, firstSlide);
  143. // Initialize the js for the two cloned slides
  144. const cloneFirstBanner = new ECL.Banner(
  145. this.cloneFirstSlide.firstElementChild,
  146. );
  147. const cloneLastBanner = new ECL.Banner(
  148. this.cloneLastSlide.firstElementChild,
  149. );
  150. cloneFirstBanner.init();
  151. cloneLastBanner.init();
  152. // Refresh the slides variable after adding new cloned slides
  153. this.slides = queryAll(this.slideClass, this.element);
  154. // Initialze pagination and navigation
  155. this.handleResize();
  156. // Bind events
  157. if (this.navigationItems) {
  158. this.navigationItems.forEach((nav, index) => {
  159. nav.addEventListener(
  160. 'click',
  161. this.shiftSlide.bind(this, index + 1, true),
  162. );
  163. });
  164. }
  165. if (this.navigation) {
  166. this.navigation.addEventListener('keydown', this.handleKeyboardOnBullets);
  167. }
  168. if (this.attachClickListener && this.btnPlay && this.btnPause) {
  169. this.btnPlay.addEventListener('click', this.handleAutoPlay);
  170. this.btnPause.addEventListener('click', this.handleAutoPlay);
  171. }
  172. if (this.btnPlay) {
  173. this.btnPlay.addEventListener('keydown', this.handleKeyboardOnPlay);
  174. }
  175. if (this.slidesContainer) {
  176. // Mouse events
  177. this.slidesContainer.addEventListener('mouseover', this.handleMouseOver);
  178. this.slidesContainer.addEventListener('mouseout', this.handleMouseOut);
  179. // Touch events
  180. this.slidesContainer.addEventListener('touchstart', this.dragStart);
  181. this.slidesContainer.addEventListener('touchend', this.dragEnd);
  182. this.slidesContainer.addEventListener('touchmove', this.dragAction);
  183. this.slidesContainer.addEventListener('transitionend', this.checkIndex);
  184. }
  185. if (this.container) {
  186. this.container.addEventListener('focus', this.handleFocus, true);
  187. }
  188. if (this.attachResizeListener) {
  189. window.addEventListener('resize', this.handleResize);
  190. }
  191. // Set ecl initialized attribute
  192. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  193. ECL.components.set(this.element, this);
  194. return this;
  195. }
  196. /**
  197. * Destroy component.
  198. */
  199. destroy() {
  200. if (this.cloneFirstSLide && this.cloneLastSLide) {
  201. this.cloneFirstSLide.remove();
  202. this.cloneLastSLide.remove();
  203. }
  204. if (this.btnPlay) {
  205. this.btnPlay.replaceWith(this.btnPlay.cloneNode(true));
  206. }
  207. if (this.btnPause) {
  208. this.btnPause.replaceWith(this.btnPause.cloneNode(true));
  209. }
  210. if (this.slidesContainer) {
  211. this.slidesContainer.removeEventListener(
  212. 'mouseover',
  213. this.handleMouseOver,
  214. );
  215. this.slidesContainer.removeEventListener('mouseout', this.handleMouseOut);
  216. this.slidesContainer.removeEventListener('touchstart', this.dragStart);
  217. this.slidesContainer.removeEventListener('touchend', this.dragEnd);
  218. this.slidesContainer.removeEventListener('touchmove', this.dragAction);
  219. this.slidesContainer.removeEventListener(
  220. 'transitionend',
  221. this.checkIndex,
  222. );
  223. }
  224. if (this.container) {
  225. this.container.removeEventListener('focus', this.handleFocus, true);
  226. }
  227. if (this.navigationItems) {
  228. this.navigationItems.forEach((nav) => {
  229. nav.replaceWith(nav.cloneNode(true));
  230. });
  231. }
  232. if (this.attachResizeListener) {
  233. window.removeEventListener('resize', this.handleResize);
  234. }
  235. if (this.autoPlayInterval) {
  236. clearInterval(this.autoPlayInterval);
  237. this.autoPlay = null;
  238. }
  239. if (this.element) {
  240. this.element.removeAttribute('data-ecl-auto-initialized');
  241. ECL.components.delete(this.element);
  242. }
  243. }
  244. /**
  245. * Set the banners height above the xl breakpoint
  246. */
  247. checkBannerHeights() {
  248. this.executionCount += 1;
  249. if (this.executionCount > this.maxExecutions) {
  250. clearInterval(this.intervalId);
  251. this.executionCount = 0;
  252. return;
  253. }
  254. const heightValues = this.slides.map((slide) => {
  255. const banner = queryOne('.ecl-banner', slide);
  256. const bannerInstance = ECL.components.get(banner);
  257. const ratio = bannerInstance.defaultRatio();
  258. bannerInstance.setHeight(ratio);
  259. const height = parseInt(banner.style.height, 10);
  260. if (banner.style.height === 'auto') {
  261. return 0;
  262. }
  263. if (Number.isNaN(height) || height === 100) {
  264. return 1;
  265. }
  266. return height;
  267. });
  268. const elementHeights = heightValues.filter(
  269. (height) => height !== undefined,
  270. );
  271. const tallestElementHeight = Math.max(...elementHeights);
  272. // We stop checking the heights of the banner if we know that all the slides
  273. // have height: auto; or if a banner with an height that is not 100% or undefined is found.
  274. if (
  275. (elementHeights.length === this.slides.length &&
  276. tallestElementHeight === 0) ||
  277. tallestElementHeight > 1
  278. ) {
  279. clearInterval(this.intervalId);
  280. if (tallestElementHeight > 0) {
  281. this.executionCount = 0;
  282. this.slides.forEach((slide) => {
  283. let bannerImage = null;
  284. const banner = queryOne('.ecl-banner', slide);
  285. if (banner) {
  286. bannerImage = queryOne('img', banner);
  287. banner.style.height = `${tallestElementHeight}px`;
  288. }
  289. if (bannerImage) {
  290. bannerImage.style.aspectRatio = 'auto';
  291. }
  292. });
  293. }
  294. }
  295. }
  296. /**
  297. * Set the banners height below the xl breakpoint
  298. */
  299. resetBannerHeights() {
  300. this.slides.forEach((slide) => {
  301. const banner = queryOne('.ecl-banner', slide);
  302. let bannerImage = null;
  303. let bannerVideo = null;
  304. let bannerFooter = null;
  305. if (banner) {
  306. banner.style.height = '';
  307. bannerImage = queryOne('img', banner);
  308. bannerVideo = queryOne('video', banner);
  309. bannerFooter = queryOne('.ecl-banner__credit', banner);
  310. if (bannerImage) {
  311. bannerImage.style.aspectRatio = '';
  312. }
  313. if (bannerVideo) {
  314. bannerVideo.style.aspectRatio = '';
  315. }
  316. if (bannerFooter) {
  317. setTimeout(() => {
  318. banner.style.setProperty(
  319. '--banner-footer-height',
  320. `${bannerFooter.offsetHeight}px`,
  321. );
  322. }, 100);
  323. }
  324. }
  325. });
  326. }
  327. /**
  328. * TouchStart handler.
  329. * @param {Event} e
  330. */
  331. dragStart(e) {
  332. e = e || window.event;
  333. this.posInitial = this.slidesContainer.offsetLeft;
  334. if (e.type === 'touchstart') {
  335. this.posX1 = e.touches[0].clientX;
  336. }
  337. }
  338. /**
  339. * TouchMove handler.
  340. * @param {Event} e
  341. */
  342. dragAction(e) {
  343. e = e || window.event;
  344. if (e.type === 'touchmove') {
  345. e.preventDefault();
  346. this.posX2 = this.posX1 - e.touches[0].clientX;
  347. this.posX1 = e.touches[0].clientX;
  348. }
  349. this.slidesContainer.style.left = `${
  350. this.slidesContainer.offsetLeft - this.posX2
  351. }px`;
  352. }
  353. /**
  354. * TouchEnd handler.
  355. */
  356. dragEnd() {
  357. this.posFinal = this.slidesContainer.offsetLeft;
  358. if (this.posFinal - this.posInitial < -this.threshold) {
  359. this.shiftSlide('next', true);
  360. } else if (this.posFinal - this.posInitial > this.threshold) {
  361. this.shiftSlide('prev', true);
  362. } else {
  363. this.slidesContainer.style.left = `${this.posInitial}px`;
  364. }
  365. }
  366. /**
  367. * Action to shift next or previous slide.
  368. * @param {int|string} dir
  369. * @param {Boolean} stopAutoPlay
  370. */
  371. shiftSlide(dir, stopAutoPlay) {
  372. if (this.allowShift) {
  373. if (typeof dir === 'number') {
  374. this.index = dir;
  375. } else {
  376. this.index = dir === 'next' ? this.index + 1 : this.index - 1;
  377. }
  378. this.moveSlides(true);
  379. }
  380. if (stopAutoPlay && this.autoPlay) {
  381. this.handleAutoPlay();
  382. }
  383. this.allowShift = false;
  384. }
  385. /**
  386. * Transition for the slides.
  387. * @param {Boolean} transition
  388. */
  389. moveSlides(transition) {
  390. const newOffset = this.slideWidth * this.index;
  391. this.slidesContainer.style.transitionDuration = transition ? '0.4s' : '0s';
  392. if (this.direction === 'rtl') {
  393. this.slidesContainer.style.right = `-${newOffset}px`;
  394. } else {
  395. this.slidesContainer.style.left = `-${newOffset}px`;
  396. }
  397. }
  398. /**
  399. * Action to update slides index and position.
  400. * @param {Event} e
  401. */
  402. checkIndex(e) {
  403. if (e) {
  404. if (e.propertyName !== 'left') {
  405. return;
  406. }
  407. }
  408. // Update index
  409. if (this.index === 0) {
  410. this.index = this.total;
  411. }
  412. if (this.index === this.total + 1) {
  413. this.index = 1;
  414. }
  415. // Move slide without transition to ensure infinity loop
  416. this.moveSlides(false);
  417. // Update pagination
  418. if (this.currentSlide) {
  419. this.currentSlide.textContent = this.index;
  420. }
  421. // Update slides
  422. if (this.slides) {
  423. this.slides.forEach((slide, index) => {
  424. const cta = queryOne('.ecl-link--cta', slide);
  425. if (this.index === index) {
  426. slide.removeAttribute('inert', 'true');
  427. if (cta) {
  428. cta.removeAttribute('tabindex', -1);
  429. }
  430. } else {
  431. slide.setAttribute('inert', 'true');
  432. if (cta) {
  433. cta.setAttribute('tabindex', -1);
  434. }
  435. }
  436. });
  437. }
  438. // Update navigation
  439. if (this.navigationItems) {
  440. this.navigationItems.forEach((nav, index) => {
  441. if (this.index === index + 1) {
  442. nav.setAttribute('aria-current', 'true');
  443. nav.removeAttribute('tabindex', -1);
  444. } else {
  445. nav.removeAttribute('aria-current', 'true');
  446. nav.setAttribute('tabindex', -1);
  447. }
  448. });
  449. }
  450. this.allowShift = true;
  451. }
  452. /**
  453. * Toggles play/pause slides.
  454. */
  455. handleAutoPlay() {
  456. if (!this.autoPlay) {
  457. this.autoPlayInterval = setInterval(() => {
  458. this.shiftSlide('next');
  459. }, 5000);
  460. this.autoPlay = true;
  461. const isFocus = document.activeElement === this.btnPlay;
  462. this.btnPlay.style.display = 'none';
  463. this.btnPause.style.display = 'flex';
  464. if (isFocus) {
  465. this.btnPause.focus();
  466. }
  467. } else {
  468. clearInterval(this.autoPlayInterval);
  469. this.autoPlay = false;
  470. const isFocus = document.activeElement === this.btnPause;
  471. this.btnPlay.style.display = 'flex';
  472. this.btnPause.style.display = 'none';
  473. if (isFocus) {
  474. this.btnPlay.focus();
  475. }
  476. }
  477. }
  478. /**
  479. * Trigger events on mouseover.
  480. */
  481. handleMouseOver() {
  482. this.hoverAutoPlay = this.autoPlay;
  483. if (this.hoverAutoPlay) {
  484. this.handleAutoPlay();
  485. }
  486. return this;
  487. }
  488. /**
  489. * Trigger events on mouseout.
  490. */
  491. handleMouseOut() {
  492. if (this.hoverAutoPlay) {
  493. this.handleAutoPlay();
  494. }
  495. return this;
  496. }
  497. /**
  498. * Trigger events on resize.
  499. */
  500. handleResize() {
  501. const vw = Math.max(
  502. document.documentElement.clientWidth || 0,
  503. window.innerWidth || 0,
  504. );
  505. clearInterval(this.intervalId);
  506. clearTimeout(this.resizeTimer);
  507. // We set 250ms delay which is higher than the 200ms delay in the banner.
  508. this.resizeTimer = setTimeout(() => {
  509. if (vw >= 998) {
  510. this.intervalId = setInterval(this.checkBannerHeights, 100);
  511. } else {
  512. this.resetBannerHeights();
  513. }
  514. this.slideWidth = this.slides[0].scrollWidth;
  515. this.checkIndex();
  516. setTimeout(() => {
  517. // Reveal the carousel
  518. this.element.style.opacity = 1;
  519. }, 250);
  520. }, 250);
  521. // Add class to set a left margin to banner content and avoid arrow overlapping
  522. if (vw >= 1140 && vw <= 1260) {
  523. this.container.classList.add('ecl-carousel-container--padded');
  524. } else {
  525. this.container.classList.remove('ecl-carousel-container--padded');
  526. }
  527. // Deactivate autoPlay for mobile or activate autoPlay onLoad for desktop
  528. if ((vw <= 768 && this.autoPlay) || (vw > 768 && this.autoPlay === null)) {
  529. this.handleAutoPlay();
  530. }
  531. }
  532. /**
  533. * @param {Event} e
  534. */
  535. handleKeyboardOnPlay(e) {
  536. if (e.key === 'Tab' && e.shiftKey) {
  537. return;
  538. }
  539. switch (e.key) {
  540. case 'Tab':
  541. case 'ArrowRight':
  542. e.preventDefault();
  543. this.activeNav = queryOne(
  544. `${this.navigationItemsClass}[aria-current="true"]`,
  545. );
  546. if (this.activeNav) {
  547. this.activeNav.focus();
  548. }
  549. if (this.autoPlay) {
  550. this.handleAutoPlay();
  551. }
  552. break;
  553. default:
  554. }
  555. }
  556. /**
  557. * @param {Event} e
  558. */
  559. handleKeyboardOnBullets(e) {
  560. const focusedEl = document.activeElement;
  561. switch (e.key) {
  562. case 'ArrowRight':
  563. if (focusedEl.nextSibling) {
  564. e.preventDefault();
  565. this.shiftSlide('next', true);
  566. setTimeout(() => focusedEl.nextSibling.focus(), 400);
  567. }
  568. break;
  569. case 'ArrowLeft':
  570. if (focusedEl.previousSibling) {
  571. this.shiftSlide('prev', true);
  572. setTimeout(() => focusedEl.previousSibling.focus(), 400);
  573. } else {
  574. this.btnPlay.focus();
  575. }
  576. break;
  577. default:
  578. // Handle other key events here
  579. }
  580. }
  581. /**
  582. * Trigger events on focus.
  583. * @param {Event} e
  584. */
  585. handleFocus(e) {
  586. const focusElement = e.target;
  587. // Disable autoplay if focus is on a slide CTA
  588. if (
  589. focusElement &&
  590. focusElement.contains(document.activeElement) &&
  591. this.autoPlay
  592. ) {
  593. this.handleAutoPlay();
  594. }
  595. return this;
  596. }
  597. }
  598. export default Carousel;