quiz.js

import { queryOne, queryAll } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
import EmblaCarousel from 'embla-carousel';
import Accessibility from 'embla-carousel-accessibility';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.itemSelector Element used to trigger the animation
 * @param {String} options.sliderSelector Root element of the slider
 * @param {String} options.inputSelector Data attribute for the radio inputs
 * @param {String} options.filppedClass Css class for the active dot element
 * @param {String} options.textClass Css class of the element containing the text
 * @param {String} options.frontClass Css class of the initially visible face of the card
 * @param {String} options.backClass Css class of the initially hidden face of the card
 * @param {String} options.prevClass Css class of the prev button
 * @param {String} options.nextClass Css class of the next button
 * @param {String} options.dotsClass Css class of the dots wrapper
 * @param {String} options.dotClass Css class of the single dot
 * @param {string} options.correctChosenOptionSelector Selector for correct option chosen
 * @param {string} options.incorrectChosenOptionSelector Selector for incorrect option chosen
 * @param {String} options.activeDotClass Css class to be assigned to the active dot
 * @param {Boolean} options.attachClickListener Whether or not to bind click events
 * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
 * @param {Boolean} options.attachKeyboardListener Whether or not to bind keyboard events
 */
export class Quiz {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {Quiz} An instance of Quiz.
   */
  static autoInit(root, { QUIZ: defaultOptions = {} } = {}) {
    const quiz = new Quiz(root, defaultOptions);
    quiz.init();
    root.ECLQuiz = quiz;
    return quiz;
  }

  /**
   * An array of supported events for this component.
   *
   * @type {Array<string>}
   * @event Quiz#onClick
   * @memberof Quiz
   */
  supportedEvents = ['onClick'];

  constructor(
    element,
    {
      cardSelector = '.ecl-quiz-card',
      sliderSelector = '[data-ecl-quiz-slider]',
      inputSelector = 'data-ecl-quiz-card-input',
      itemSelector = '[data-ecl-quiz-card-flip]',
      flippedClass = 'ecl-quiz-card__flipped',
      questionClass = '.ecl-quiz-card__question',
      answerClass = '.ecl-quiz-card__answer',
      optionClass = '.ecl-quiz-card__option',
      contentClass = '.ecl-quiz-card__content',
      frontClass = '.ecl-quiz-card__front',
      backClass = '.ecl-quiz-card__back',
      prevClass = '.ecl-quiz__prev',
      nextClass = '.ecl-quiz__next',
      pagerClass = '.ecl-quiz__pager',
      dotsClass = '.ecl-quiz__dots',
      dotClass = '.ecl-quiz__dot',
      activeDotClass = 'ecl-quiz__dot--active',
      correctChosenOptionSelector = 'data-ecl-quiz-chosen-option-correct',
      incorrectChosenOptionSelector = 'data-ecl-quiz-chosen-option-incorrect',
      attachClickListener = true,
      attachResizeListener = true,
      attachKeyboardListener = true,
    } = {},
  ) {
    // Check element
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;
    this.eventManager = new EventManager();
    this.direction = getComputedStyle(this.element).direction;

    // Options
    this.cardSelector = cardSelector;
    this.itemSelector = itemSelector;
    this.inputSelector = inputSelector;
    this.flippedClass = flippedClass;
    this.contentClass = contentClass;
    this.questionClass = questionClass;
    this.answerClass = answerClass;
    this.prevClass = prevClass;
    this.nextClass = nextClass;
    this.pagerClass = pagerClass;
    this.dotsClass = dotsClass;
    this.dotClass = dotClass;
    this.frontClass = frontClass;
    this.backClass = backClass;
    this.optionClass = optionClass;
    this.activeDotClass = activeDotClass;
    this.attachClickListener = attachClickListener;
    this.attachResizeListener = attachResizeListener;
    this.attachKeyboardListener = attachKeyboardListener;
    this.sliderSelector = sliderSelector;
    this.correctChosenOptionSelector = correctChosenOptionSelector;
    this.incorrectChosenOptionSelector = incorrectChosenOptionSelector;

    this.prevButtonNode = null;
    this.nextButtonNode = null;
    this.resizeTimer = null;
    this.slider = null;
    this.dotsNode = null;
    this.pagerNode = null;
    this.toggleButtonsDisabled = null;
    this.ariaObserver = null;
    this.accessibility = null;

    // Bind `this` for use in callbacks
    this.handleClickOnItem = this.handleClickOnItem.bind(this);
    this.handleKeyboard = this.handleKeyboard.bind(this);
    this.checkHeight = this.checkHeight.bind(this);
    this.handleResize = this.handleResize.bind(this);
    this.initSlider = this.initSlider.bind(this);
    this.setCounter = this.setCounter.bind(this);
    this.escapeSlider = this.escapeSlider.bind(this);
  }

  /**
   * Initialise component.
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();

    this.items = queryAll(this.itemSelector, this.element);
    this.cards = queryAll(this.cardSelector, this.element);
    this.sliderEl = queryOne(this.sliderSelector, this.element);

    // Bind click event on toggle
    if (this.attachClickListener && this.items.length > 0) {
      this.items.forEach((item) => {
        item.addEventListener('click', this.handleClickOnItem);
      });
    }

    if (this.attachKeyboardListener && this.sliderEl) {
      this.sliderEl.addEventListener('keydown', this.handleKeyboard);
    }

    if (this.attachResizeListener) {
      window.addEventListener('resize', this.handleResize);
    }

    if (this.cards.length) {
      this.checkHeight();
    }

    if (this.sliderEl) {
      this.initSlider(this.sliderEl);
    }

    // Set ecl initialized attribute
    this.element.setAttribute('data-ecl-auto-initialized', 'true');
    ECL.components.set(this.element, this);
  }

  /**
   * Register a callback function for a specific event.
   *
   * @param {string} eventName - The name of the event to listen for.
   * @param {Function} callback - The callback function to be invoked when the event occurs.
   * @returns {void}
   * @memberof Quiz
   * @instance
   *
   * @example
   * // Registering a callback for the 'toggle' event
   * quiz.on('onClick', (event) => {
   *   console.log('Click event occurred!', event);
   * });
   */
  on(eventName, callback) {
    this.eventManager.on(eventName, callback);
  }

  /**
   * Trigger a component event.
   *
   * @param {string} eventName - The name of the event to trigger.
   * @param {any} eventData - Data associated with the event.
   *
   * @memberof Quiz
   */
  trigger(eventName, eventData) {
    this.eventManager.trigger(eventName, eventData);
  }

  /**
   * Destroy component.
   */
  destroy() {
    if (this.attachClickListener && this.items) {
      this.items.forEach((item) => {
        item.removeEventListener('click', this.handleClickOnItem);
      });
    }

    if (this.attachKeyboardListener && this.items) {
      this.items.forEach((item) => {
        item.removeEventListener('keydown', this.handleClickOnItem);
      });
    }

    if (this.attachResizeListener) {
      window.removeEventListener('resize', this.handleResize);
    }

    if (this.element) {
      this.element.removeAttribute('data-ecl-auto-initialized');
      ECL.components.delete(this.element);
    }

    if (this.slider) {
      this.slider.destroy();
    }

    if (this.ariaObserver) {
      this.ariaObserver.disconnect();
      this.ariaObserver = null;
    }
  }

  /**
   * Init the slider.
   */
  initSlider(sliderEl) {
    this.slider = EmblaCarousel(
      sliderEl,
      {
        loop: false,
        align: 'start',
        direction: this.direction,
      },
      [
        Accessibility({
          carouselAriaLabel: 'Quiz slider',
          carouselAriaRoleDescription: '',
          slideAriaRoleDescription: '',
          slideRole: '',
          previousButtonAriaLabel: 'Show previous Slide',
          nextButtonAriaLabel: 'Show next Slide',
          dotButtonAriaLabel: (
            hasAnyGroupedSlides,
            firstSlideIndex,
            lastSlideIndex,
            totalSlides,
            selectedSnapIndex,
            totalSnaps,
          ) => {
            const visibleSlides = totalSlides - (totalSnaps - 1);
            if (hasAnyGroupedSlides) {
              const start = firstSlideIndex + 1;
              const end = Math.min(
                firstSlideIndex + visibleSlides,
                totalSlides,
              );
              return `Show slides ${start} to ${end} of ${totalSlides}`;
            }

            return `Show slide ${firstSlideIndex + 1} of ${totalSlides}`;
          },
          slideAriaLabel: () => '',
        }),
      ],
    );

    // The accessibility plugin sets aria-hidden on inactive slides, which prevents
    // screen readers from navigating all quiz questions. Remove it as it's set.
    this.ariaObserver = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.attributeName === 'aria-hidden') {
          mutation.target.removeAttribute('aria-hidden');
        }
      });
    });
    this.cards.forEach((card) => {
      this.ariaObserver.observe(card, {
        attributes: true,
        attributeFilter: ['aria-hidden'],
      });
      card.removeAttribute('aria-hidden');
    });

    this.accessibility = this.slider.plugins().accessibility;
    this.prevButtonNode = queryOne(this.prevClass, this.element);
    this.nextButtonNode = queryOne(this.nextClass, this.element);
    this.pagerNode = queryOne(this.pagerClass, this.element);

    if (this.prevButtonNode) {
      this.prevButtonNode.addEventListener(
        'click',
        () => this.slider.goToPrev(),
        false,
      );
    }
    if (this.nextButtonNode) {
      this.nextButtonNode.addEventListener(
        'click',
        () => this.slider.goToNext(),
        false,
      );
    }

    if (this.prevButtonNode && this.nextButtonNode) {
      this.toggleButtonsDisabled = (emblaApi) => {
        const setButtonState = (button, enabled) => {
          button.toggleAttribute('disabled', !enabled);
        };
        setButtonState(this.prevButtonNode, emblaApi.canGoToPrev());
        setButtonState(this.nextButtonNode, emblaApi.canGoToNext());
      };

      this.toggleButtonsDisabled(this.slider);
      this.slider.on('select', this.toggleButtonsDisabled);
      this.slider.on('reInit', this.toggleButtonsDisabled);

      this.accessibility.setupPrevAndNextButtons(
        this.prevButtonNode,
        this.nextButtonNode,
      );
    }

    let dotNodes = [];
    this.dotsNode = queryOne(this.dotsClass, this.element);

    if (this.dotsNode) {
      const createDotButtonHtml = (emblaApi) => {
        const dotTemplate = document.getElementById('ecl-quiz__dot-template');
        const snapList = emblaApi.snapList();
        this.dotsNode.innerHTML = snapList.reduce(
          (acc) => acc + dotTemplate.innerHTML,
          '',
        );
        return Array.from(queryAll(this.dotClass, this.dotsNode));
      };

      const addDotButtonClickHandlers = (emblaApi, dotNodes) => {
        dotNodes.forEach((dotNode, index) => {
          dotNode.addEventListener('click', () => emblaApi.goTo(index), false);
        });
      };

      this.createAndSetupDotButtons = (emblaApi) => {
        const canScroll =
          this.slider.canGoToNext() || this.slider.canGoToPrev();

        if (this.pagerNode) this.pagerNode.style.display = '';

        if (!canScroll) {
          if (this.pagerNode) this.pagerNode.style.display = 'none';

          this.dotsNode.innerHTML = '';
          dotNodes = [];
          return;
        }

        dotNodes = createDotButtonHtml(emblaApi);
        addDotButtonClickHandlers(emblaApi, dotNodes);
      };

      this.createAndSetupDotButtons(this.slider, this.dotsNode);
      this.slider.on('reInit', () => {
        this.createAndSetupDotButtons(this.slider, this.dotsNode);
      });

      this.updateDots = () => {
        if (!dotNodes.length) return;

        const index = this.slider.selectedSnap();

        dotNodes.forEach((dot, i) => {
          dot.classList.toggle(this.activeDotClass, i === index);
          dot.classList.toggle('is-prev', i === index - 1);
        });

        this.setCounter();
      };

      this.accessibility.setupDotButtons(this.dotsNode);

      this.updateDots();
      this.slider.on('select', this.updateDots);
    }

    // Add aria-labelledby
    this.cards.forEach((card) => {
      const question = queryOne(`#${card.id}-question`, card);
      card.setAttribute('aria-labelledby', question.id);
    });
  }

  /**
   * Sets the counter (in mobile).
   */
  setCounter() {
    const currentIndex = this.slider.selectedSnap();
    const total = this.slider.snapList().length;
    const counter = queryOne('.ecl-quiz__counter', this.element);

    if (counter) {
      counter.textContent = `${currentIndex + 1} / ${total}`;
    }
  }

  /**
   * Check the height of the elements.
   *
   * @memberof Quiz
   */
  checkHeight() {
    let maxHeight = 0;
    let maxTextHeight = 0;

    const front = queryOne(this.frontClass, this.element);
    const styles = getComputedStyle(front);
    const minHeight = parseFloat(styles.getPropertyValue('min-height')) || 345;

    this.cards.forEach((card) => {
      const front = queryOne(this.frontClass, card);
      const back = queryOne(this.backClass, card);
      const question = queryOne(this.questionClass, card);
      const answer = queryOne(this.answerClass, card);

      front.style.position = 'static';
      back.style.position = 'static';

      // Reset heights previously set.
      question.style.minHeight = '';
      answer.style.minHeight = '';

      const heightQuestion = question.scrollHeight;
      const heightAnswer = answer.scrollHeight;
      const heightText = Math.max(heightQuestion, heightAnswer);

      if (heightText > maxTextHeight) {
        maxTextHeight = heightText;
      }

      front.style.position = '';
      back.style.position = '';
    });

    this.cards.forEach((card) => {
      const question = queryOne(this.questionClass, card);
      const answer = queryOne(this.answerClass, card);
      const front = queryOne(this.frontClass, card);
      const back = queryOne(this.backClass, card);
      const content = queryOne(this.contentClass, card);

      front.style.position = 'static';
      back.style.position = 'static';
      content.style.height = '';

      question.style.minHeight = maxTextHeight + 'px';
      answer.style.minHeight = maxTextHeight + 'px';

      const heightFront = front.scrollHeight;
      const heightBack = back.scrollHeight;
      const height = Math.max(heightFront, heightBack);

      if (height > minHeight && height > maxHeight) {
        maxHeight = height;
      }
      front.style.position = '';
      back.style.position = '';
    });

    if (maxHeight > 0) {
      this.cards.forEach((card) => {
        const content = queryOne(this.contentClass, card);
        content.style.height = maxHeight + 'px';
      });
    }
  }

  /**
   * Handle keyboard events for accessibility.
   *
   * @memberof Quiz
   */
  handleKeyboard(e) {
    if (e.key === 'Escape') {
      this.escapeSlider();
      return;
    }

    const item = e.target;
    const card = item.closest(this.cardSelector);
    if (!card) return;

    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      this.handleClickOnItem(e);
    }

    if (
      e.key === 'Tab' &&
      e.target.classList.contains(this.cardSelector.slice(1))
    ) {
      if (e.shiftKey) {
        e.preventDefault();
        if (e.target.previousElementSibling) {
          e.target.previousElementSibling.focus();
        }

        if (this.slider.canGoToPrev()) {
          this.slider.goToPrev();
        }
        return;
      }

      if (this.slider.canGoToNext()) {
        e.preventDefault();
        e.target.nextElementSibling.focus();
        this.slider.goToNext();
      }
    }

    if (
      e.key === 'Tab' &&
      !e.shiftKey &&
      e.target.classList.contains('ecl-quiz-card__category')
    ) {
      let nextSlide = card.nextElementSibling;
      while (nextSlide && nextSlide.classList.contains(this.flippedClass)) {
        nextSlide = nextSlide.nextElementSibling;
      }
      if (nextSlide) {
        e.preventDefault();
        const idx = Array.from(this.cards).indexOf(nextSlide);
        if (idx !== -1) this.slider.goTo(idx);
        const firstInput = queryOne(`[${this.inputSelector}]`, nextSlide);
        if (firstInput) firstInput.focus();
      }
      // No unflipped card ahead: let native Tab exit the quiz naturally
    }

    if (
      e.key === 'Tab' &&
      e.shiftKey &&
      card.classList.contains(this.flippedClass) &&
      e.target.closest(this.backClass)
    ) {
      let prevSlide = card.previousElementSibling;
      while (prevSlide && prevSlide.classList.contains(this.flippedClass)) {
        prevSlide = prevSlide.previousElementSibling;
      }
      if (prevSlide) {
        e.preventDefault();
        const idx = Array.from(this.cards).indexOf(prevSlide);
        if (idx !== -1) this.slider.goTo(idx);
        const lastInput = Array.from(
          queryAll(`[${this.inputSelector}]`, prevSlide),
        ).pop();
        if (lastInput) lastInput.focus();
      }
      // No unflipped card before: let native Shift+Tab exit the quiz naturally
      return;
    }

    // Tab from a radio input: jump to the nearest unflipped card, skipping flipped ones
    if (e.key === 'Tab' && e.target.hasAttribute(this.inputSelector)) {
      if (!e.shiftKey) {
        let nextSlide = card.nextElementSibling;
        while (nextSlide && nextSlide.classList.contains(this.flippedClass)) {
          nextSlide = nextSlide.nextElementSibling;
        }
        if (nextSlide) {
          e.preventDefault();
          const idx = Array.from(this.cards).indexOf(nextSlide);
          if (idx !== -1) this.slider.goTo(idx);
          const firstInput = queryOne(`[${this.inputSelector}]`, nextSlide);
          if (firstInput) firstInput.focus();
        }
        // No unflipped card ahead: let native Tab exit the quiz naturally
      } else {
        let prevSlide = card.previousElementSibling;
        while (prevSlide && prevSlide.classList.contains(this.flippedClass)) {
          prevSlide = prevSlide.previousElementSibling;
        }
        if (prevSlide) {
          e.preventDefault();
          const idx = Array.from(this.cards).indexOf(prevSlide);
          if (idx !== -1) this.slider.goTo(idx);
          const lastInput = Array.from(
            queryAll(`[${this.inputSelector}]`, prevSlide),
          ).pop();
          if (lastInput) lastInput.focus();
        }
        // No unflipped card before: let native Shift+Tab exit the quiz naturally
      }
    }

    // Arrow keys: move focus within the card's options without selecting/flipping
    if (
      (e.key === 'ArrowDown' ||
        e.key === 'ArrowUp' ||
        e.key === 'ArrowRight' ||
        e.key === 'ArrowLeft') &&
      e.target.hasAttribute(this.inputSelector)
    ) {
      e.preventDefault();
      const focusables = queryAll(`[${this.inputSelector}]`, card);
      const focusableArray = Array.from(focusables);
      const currentIndex = focusableArray.indexOf(item);

      if ((e.key === 'ArrowUp' || e.key === 'ArrowLeft') && currentIndex > 0) {
        item.setAttribute('tabindex', '-1');
        focusableArray[currentIndex - 1].setAttribute('tabindex', '0');
        focusableArray[currentIndex - 1].focus();
      } else if (
        (e.key === 'ArrowDown' || e.key === 'ArrowRight') &&
        currentIndex < focusableArray.length - 1
      ) {
        item.setAttribute('tabindex', '-1');
        focusableArray[currentIndex + 1].setAttribute('tabindex', '0');
        focusableArray[currentIndex + 1].focus();
      }
    }
  }

  /**
   * Trigger events on resize
   * Uses a debounce, for performance
   */
  handleResize() {
    clearTimeout(this.resizeTimer);
    this.resizeTimer = setTimeout(() => {
      if (this.slider) {
        this.createAndSetupDotButtons(this.slider, this.dotsNode);
        if (this.accessibility && this.dotsNode) {
          this.accessibility.setupDotButtons(this.dotsNode);
        }
        this.updateDots();
        if (this.toggleButtonsDisabled) {
          this.toggleButtonsDisabled(this.slider);
        }
      }

      this.checkHeight();
    }, 100);
  }

  /**
   * Flips the card.
   *
   * @param {Event} e
   *
   * @fires Quiz#onClick
   */
  handleClickOnItem(e) {
    const card = e.target.closest(this.cardSelector);

    if (card) {
      card.classList.toggle(this.flippedClass);
      if (card.hasAttribute('aria-pressed')) {
        card.setAttribute(
          'aria-pressed',
          card.classList.contains(this.flippedClass),
        );
      }
      const isFlipped = card.classList.contains(this.flippedClass);
      const front = queryOne(this.frontClass, card);
      const back = queryOne(this.backClass, card);
      let category = queryOne('.ecl-quiz-card__category--error', back);

      if (e.target.hasAttribute('data-match')) {
        const li = e.target.closest(this.optionClass);
        const parent = li.parentNode;
        const items = Array.from(parent.children);
        const index = items.indexOf(li);
        const match = e.target.getAttribute('data-match') === 'true';
        let successText = '';
        let errorText = '';
        const successEl = queryOne('.ecl-quiz-card__category--success', back);

        if (successEl) {
          successText = successEl.textContent;
        }
        const errorEl = queryOne('.ecl-quiz-card__category--error', back) || '';
        if (errorEl) {
          errorText = errorEl.textContent;
        }
        const message = match ? successText : errorText;
        const statusEl = queryOne('.ecl-quiz-card__sr-status', back);
        statusEl.textContent = '';
        requestAnimationFrame(() => {
          statusEl.textContent = message;
        });

        if (match) {
          back.classList.add('ecl-quiz-card--correct');
          category = queryOne('.ecl-quiz-card__category--success', back);
        }

        const options = queryOne('.ecl-quiz-card__options', back);
        Array.from(options.children).forEach((el, i) => {
          const isSelected = i === index;
          el.classList.toggle('ecl-quiz-card__option--selected', isSelected);
          // Replace the assistive text for the chosen option
          if (isSelected) {
            const assistiveTextEl = queryOne(
              '.ecl-quiz-card__option-assistive-label',
              el,
            );
            // If the chosen option is correct
            if (match) {
              const correctChosenText = card.getAttribute(
                this.correctChosenOptionSelector,
              );

              if (correctChosenText && assistiveTextEl) {
                assistiveTextEl.textContent = correctChosenText;
              } // If the chosen option is incorrect
            } else {
              const incorrectChosenText = card.getAttribute(
                this.incorrectChosenOptionSelector,
              );

              if (incorrectChosenText && assistiveTextEl) {
                assistiveTextEl.textContent = incorrectChosenText;
              }
            }
          }
        });
      }

      front.hidden = isFlipped;
      front.inert = isFlipped;
      back.hidden = !isFlipped;
      back.inert = !isFlipped;

      // FRONT-5298 Focus the status category after answering (poll variant only)
      if (isFlipped && e.target.hasAttribute('data-match') && category) {
        category.focus();
      }

      // Update aria-labelledby
      const question = queryOne(`#${card.id}-question`, card);
      const answer = queryOne(`#${card.id}-answer`, card);
      card.setAttribute('aria-labelledby', isFlipped ? answer.id : question.id);

      const eventData = { flipped: !isFlipped, e };
      this.trigger('onClick', eventData);
    }
  }

  /**
   * Moves the focus out of the slider when Escape is pressed.
   *
   */
  escapeSlider() {
    let dots = [];

    if (this.slider.canGoToNext()) {
      this.nextButtonNode.focus();
      return;
    }

    if (this.dotsNode) {
      dots = queryAll(this.dotClass, this.dotsNode);
    }

    // Move focus on the disabled next button in case there are no dots.
    if (
      dots.length === 0 &&
      !this.slider.canGoToNext() &&
      this.nextButtonNode
    ) {
      this.nextButtonNode.disabled = false;
      this.nextButtonNode.style.display = 'flex';
      this.nextButtonNode.style.visibility = 'visible';
      this.nextButtonNode.classList.add('.ecl-quiz__next--escape');
      this.nextButtonNode.focus();
      return;
    }

    if (dots.length > 0) {
      const lastDot = dots[dots.length - 1];
      lastDot.focus();
    }
  }
}

export default Quiz;