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