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