/* exported Quiz */
/**
* @typedef {Object} Question
* @property {String} questions.title Question title
* @property {String} questions.id Question id (must be unique)
* @property {String} questions.answer Correct answer for the question (Must correspond to answers.title)
* @property {Object[]} questions.answers Answer information holder
* @property {String} questions.answers.title Answer title
* @property {String} questions.answers.value Answer real value
* @property {Number} questions.answers.chosenPercentage Answer pick percentage
*/
/**
* Quiz class
*/
class Quiz{
/**
* Quiz constructor
* @param {Question[]} questions Question list
*/
constructor(questions){
questions = questions || [];
const selectedQuestions = [];
/**
* @type {Number}
* @private
*/
this._timeLimit = null;
/**
* @type {Element}
*/
this.wrapper = null;
/**
* @type {Element}
* @private
*/
this._timer = null;
/**
* @type {Number}
* @private
*/
this._timerInterval = null;
/**
* @type {Date}
* @private
*/
this._timerStarted = null;
/**
* @type {Object[]}
* @param {String} id Question ID
* @param {String} answer Answer value
* @param {Boolean} correct Was the answer correct?
* @param {Number} time Answer time
*/
this.answers = [];
/**
* @type {Function[]}
* @private
*/
this._onTimerStartCallbacks = [];
/**
* @type {Function[]}
* @private
*/
this._onAnswerCallbacks = [];
/**
* @type {Function[]}
* @private
*/
this._onEndCallbacks = [];
this._listenToCustomEvents(questions, selectedQuestions);
}
/**
* Listen to every custom events (used for security purposes)
* @param {Question[]} questions The current Quiz questions
* @param {Question[]} selectedQuestions The Quiz selected questions
* @private
*/
_listenToCustomEvents(questions, selectedQuestions){
document.addEventListener('quiz.js-addQuestions', ({detail}) => {
this._addQuestionsHandler(questions, detail);
});
document.addEventListener('quiz.js-removeQuestions', ({detail}) => {
this._removeQuestionsHandler(questions, detail);
});
document.addEventListener('quiz.js-start', ({detail}) => {
// .start(questions)
if(isNaN(detail)){
selectedQuestions = detail;
}else{
// .start(steps)
selectedQuestions = this._selectQuestions(questions, detail > questions.length ? questions.length : detail);
}
this._displayQuestion(selectedQuestions, 0);
});
}
/**
* Handler for the quiz.js-addQuestion event
* @param {Question[]} questions The current Quiz questions
* @param {Question[]} newQuestions Questions to add
* @private
*/
_addQuestionsHandler(questions, newQuestions){
questions.push(...newQuestions.filter(n => !questions.find(q => q.id == n.id)));
}
/**
* Handler for the quiz.js-addQuestion event
* @param {Question[]} questions The current Quiz questions
* @param {Number[]} questionsToRemove IDs of the questions to remove
* @private
*/
_removeQuestionsHandler(questions, questionsToRemove){
questions = questions.filter(q => !questionsToRemove.includes(q.id));
}
/**
* Select random questions at the start
* @param {Question[]} questions The current Quiz questions
* @param {Number} steps The amount of questions to select
* @private
*/
_selectQuestions(questions, steps){
const questionsArrayClone = questions.slice(0);
// Shuffle the array
for (let i = questionsArrayClone.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[questionsArrayClone[i], questionsArrayClone[j]] = [questionsArrayClone[j], questionsArrayClone[i]];
}
return questionsArrayClone.slice(0, steps);
}
/**
* Display a question in the DOM
* @param {Question[]} questions
* @param {Number} position
* @private
*/
_displayQuestion(questions, position){
this.wrapper.querySelector('.quiz-js-question').innerHTML = questions[position].title;
this._shuffleArray(questions[position].answers);
this.wrapper.querySelector('.quiz-js-answers').innerHTML = questions[position].answers.map(answer => `
<div>
<button data-value="${answer.value}">${answer.title}</button>
</div>
`).join('');
this._listenToUserAnswer(questions, position);
if(this._timeLimit){
this._timer.parentElement.classList.remove('quiz-js-hidden');
this._resetTimer(questions, position);
}
this.wrapper.querySelector('.quiz-js-next').classList.add('quiz-js-hidden');
}
/**
* Shuffle an array
* @param {Array} array
* @private
*/
_shuffleArray(array){
for(let i = array.length - 1; i > 0; i--){
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
/**
* Listen to user click
* @param {Question[]} questions
* @param {Number} position
* @private
*/
_listenToUserAnswer(questions, position){
this.wrapper.querySelectorAll('.quiz-js-answers button').forEach(answer => {
answer.addEventListener('click', () => {
const answerTime = new Date() - this._timerStarted;
clearInterval(this._timerInterval);
this.answers.push({
id: questions[position].id,
answer: answer.getAttribute('data-value'),
correct: questions[position].answer == answer.getAttribute('data-value'),
time: answerTime
});
this._displayQuestionInformations(questions, position, answer.getAttribute('data-value'));
this._onAnswerCallbacks.forEach(callback => {
Reflect.apply(callback, null, [
questions[position],
{
value: answer.getAttribute('data-value'),
correct: questions[position].answer == answer.getAttribute('data-value'),
time: answerTime
}
]);
});
});
});
}
/**
* Reset the timer to full time
* @param {Question[]} questions
* @param {Number} position
* @private
*/
_resetTimer(questions, position){
const timerText = this.wrapper.querySelector('.quiz-js-time-text');
this._timer.style.width = '100%';
this._timerStarted = new Date();
timerText.textContent = (this._timeLimit / 1000).toFixed(2) + 's';
this._onTimerStartCallbacks.forEach(callback => {
Reflect.apply(callback, null, [this._timer.parentElement]);
});
this._timerInterval = setInterval(() => {
const progress = (new Date() - this._timerStarted) / this._timeLimit;
if(progress < 1){
this._timer.style.width = 100 - progress * 100 + '%';
timerText.textContent = ((this._timeLimit - this._timeLimit * progress) / 1000).toFixed(2) + 's';
}else{
clearInterval(this._timerInterval);
// Hack for multi triggers ?
if(!this.answers.find(a => a.id == questions[position].id)){
this.answers.push({
id: questions[position].id,
answer: '',
time: this._timeLimit
});
this._displayQuestionInformations(questions, position);
this._onAnswerCallbacks.forEach(callback => {
Reflect.apply(callback, null, [
questions[position],
{
value: '',
time: this._timeLimit
}
]);
});
}
}
}, 10);
}
/**
* Display chosen percentage for a question
* @param {Question[]} questions
* @param {Number} position
* @param {String} [value]
* @private
*/
_displayQuestionInformations(questions, position, value = ''){
questions[position].answers.forEach(answer => {
const answerButton = this.wrapper.querySelector(`.quiz-js-answers button[data-value="${answer.value}"]`);
answerButton.disabled = true;
if(value == answer.value) answerButton.classList.add('quiz-js-answer-clicked');
if(answer.value == questions[position].answer) answerButton.classList.add('quiz-js-answer-correct');
answerButton.innerHTML = /*html*/`
<span class="quiz-js-answer-percentage">${answer.chosenPercentage}%</span>
<span>${answer.title}</span>
`;
});
const nextButton = this.wrapper.querySelector('.quiz-js-next').cloneNode(true);
this.wrapper.querySelector('.quiz-js-next').replaceWith(nextButton);
nextButton.classList.remove('quiz-js-hidden');
nextButton.addEventListener('click', () => {
if(position + 1 < questions.length){
this._displayQuestion(questions, position + 1);
}else{
this._displayEnd();
}
});
}
/**
* End panel display
* @private
*/
_displayEnd(){
this.wrapper.querySelector('.quiz-js-question').classList.add('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-answers').classList.add('quiz-js-hidden');
if(this._timeLimit) this.wrapper.querySelector('.quiz-js-time-limit').classList.add('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-next').classList.add('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-results').classList.remove('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-ok span').textContent = this.answers.filter(a => a.correct).length;
this.wrapper.querySelector('.quiz-js-error span').textContent = this.answers.filter(a => !a.correct).length;
this._onEndCallbacks.forEach(callback => {
Reflect.apply(callback, null, [this.answers]);
});
}
/**
* Add new questions to the Quiz
* @param {Question[]} questions Questions to add
* @returns {Quiz} The current Quiz
*/
addQuestions(...questions){
document.dispatchEvent(new CustomEvent('quiz.js-addQuestions', {
detail: questions
}));
return this;
}
/**
* Remove questions from the Quiz
* @param {String[]} ids Ids of the questions to remove
* @returns {Quiz} The current Quiz
*/
removeQuestions(...ids){
document.dispatchEvent(new CustomEvent('quiz.js-removeQuestions', {
detail: ids
}));
return this;
}
/**
* Set a time limit for each question
* @param {Number} time
* @returns {Quiz} The current Quiz
*/
setTimeLimit(time){
this._timeLimit = time;
return this;
}
/**
* Set the Quiz to run inside a DOM Element
* @param {String|Element} node The Element to run the Quiz in
* @returns {Quiz} The current Quiz
*/
attachTo(node){
this.wrapper = node instanceof Element ? node : document.querySelector(node);
this.wrapper.classList.add('quiz-js-wrapper');
this.wrapper.innerHTML = /*html*/`
<div class="quiz-js-question" data-id=""></div>
<div class="quiz-js-answers"></div>
${this._timeLimit ? '<div class="quiz-js-time-limit quiz-js-hidden"><div class="quiz-js-time-left"></div><div class="quiz-js-time-text"></div></div>' : ''}
<button class="quiz-js-next quiz-js-hidden">❯</button>
<div class="quiz-js-results quiz-js-hidden">
<p class="quiz-js-ok"><span></span> ✔</p>
<p class="quiz-js-error"><span></span> ✘</p>
</div>
`;
this._timer = this.wrapper.querySelector('.quiz-js-time-left');
return this;
}
/**
* Start the Quiz
* @param {Number|Question[]} steps The amount of questions to go through OR the list of questions to ask
* @returns {Quiz} The current Quiz
*/
start(steps){
if(this.wrapper && (!isNaN(steps) && steps > 0 || isNaN(steps) && steps.length)){
document.dispatchEvent(new CustomEvent('quiz.js-start', {
detail: steps
}));
}else{
if(steps) console.warn('Quiz.js: You didn\'t specify an Element to run the Quiz in. Use %c.attachTo()%c.', 'font-weight: bold; font-family: monospace', '');
else console.warn('Quiz.js: The amount of questions must be greater than 0');
}
return this;
}
/**
* Callback for every Quiz timer start
* @callback onTimerStartCallback
* @param {String} timerElement Quiz timer element
*/
/**
* Add a callback after each timer start
* @param {onTimerStartCallback} callback
* @returns {Quiz} The current Quiz
*/
onTimerStart(callback){
this._onTimerStartCallbacks.push(callback);
return this;
}
/**
* Callback for every Quiz answer
* @callback onAnswerCallback
* @param {Object} question Answered question object
* @param {String} answer Answer informations holder
* @param {String} answer.value Answer value
* @param {Boolean} answer.correct Was the answer correct?
* @param {Number} answer.time Answer time
*/
/**
* Add a callback after each user answer
* @param {onAnswerCallback} callback
* @returns {Quiz} The current Quiz
*/
onAnswer(callback){
this._onAnswerCallbacks.push(callback);
return this;
}
/**
* Callback for the Quiz end
* @callback onEndCallback
* @param {Object[]} questions Question informations holder
* @param {String} questions.id Question ID
* @param {String} questions.answer Answer value
* @param {Boolean} questions.correct Was the answer correct?
* @param {Number} questions.time Answer time
*/
/**
* Add a callback at the end of the Quiz
* @param {onEndCallback} callback
* @returns {Quiz} The current Quiz
*/
onEnd(callback){
this._onEndCallbacks.push(callback);
return this;
}
/**
* Remove every onTimerStart callback
* @returns {Quiz} The current Quiz
*/
offTimerStart(){
this._onTimerStartCallbacks = [];
return this;
}
/**
* Remove every onAnswer callback
* @returns {Quiz} The current Quiz
*/
offAnswer(){
this._onAnswerCallbacks = [];
return this;
}
/**
* Remove every onEnd callback
* @returns {Quiz} The current Quiz
*/
offEnd(){
this._onEndCallbacks = [];
return this;
}
/**
* Reset the Quiz to its base form
* @returns {Quiz} The current Quiz
*/
reset(){
clearInterval(this._timerInterval);
this._timerStarted = null;
this.answers = [];
const
question = this.wrapper.querySelector('.quiz-js-question'),
answers = this.wrapper.querySelector('.quiz-js-answers');
question.setAttribute('data-id', '');
question.classList.remove('quiz-js-hidden');
question.innerHTML = '';
answers.innerHTML = '';
answers.classList.remove('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-time-limit').classList.add('quiz-js-hidden');
this.wrapper.querySelector('.quiz-js-results').classList.add('quiz-js-hidden');
return this;
}
}