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