', {
'class': 'h5p-sc-set-wrapper navigatable' + (!this.options.behaviour.autoContinue ? ' next-button-mode' : '')
});
this.$slides = [];
// An array containing the SingleChoice instances
this.choices = [];
/**
* Keeps track of buttons that will be hidden
* @type {Array}
*/
self.buttonsToBeHidden = [];
/**
* The solution dialog
* @type {SolutionView}
*/
this.solutionView = new SolutionView(contentId, this.options.choices, this.l10n);
this.$choices = $('
', {
'class': 'h5p-sc-set h5p-sc-animate'
});
// sometimes an empty object is in the choices
this.options.choices = this.options.choices.filter(function (choice) {
return choice !== undefined && !!choice.answers;
});
var numQuestions = this.options.choices.length;
// Create progressbar
self.progressbar = UI.createProgressbar(numQuestions + 1, {
progressText: this.l10n.slideOfTotal
});
self.progressbar.setProgress(this.currentIndex);
for (var i = 0; i < this.options.choices.length; i++) {
var choice = new SingleChoice(this.options.choices[i], i, this.contentId, this.options.behaviour.autoContinue);
choice.on('finished', this.handleQuestionFinished, this);
choice.on('alternative-selected', this.handleAlternativeSelected, this);
choice.appendTo(this.$choices, (i === this.currentIndex));
this.choices.push(choice);
this.$slides.push(choice.$choice);
}
this.resultSlide = new ResultSlide(this.options.choices.length);
this.resultSlide.appendTo(this.$choices);
this.resultSlide.on('retry', function() {
self.resetTask(true);
}, this);
this.resultSlide.on('view-solution', this.handleViewSolution, this);
this.$slides.push(this.resultSlide.$resultSlide);
this.on('resize', this.resize, this);
// Use the correct starting slide
this.recklessJump(this.currentIndex);
if (this.options.choices.length === this.currentIndex) {
// Make sure results slide is displayed
this.resultSlide.$resultSlide.addClass('h5p-sc-current-slide');
this.setScore(this.results.corrects, true);
}
if (!this.muted) {
setTimeout(function () {
SoundEffects.setup(self.getLibraryFilePath(''));
}, 1);
}
/**
* Override Question's hideButton function
* to be able to hide buttons after delay
*
* @override
* @param {string} id
*/
this.superHideButton = self.hideButton;
this.hideButton = (function () {
return function (id) {
if (!self.scoreTimeout) {
return self.superHideButton(id);
}
self.buttonsToBeHidden.push(id);
return this;
};
})();
}
SingleChoiceSet.prototype = Object.create(Question.prototype);
SingleChoiceSet.prototype.constructor = SingleChoiceSet;
/**
* Set if a element is tabbable or not
*
* @param {jQuery} $element The element
* @param {boolean} tabbable If element should be tabbable
* @returns {jQuery} The element
*/
SingleChoiceSet.prototype.setTabbable = function ($element, tabbable) {
if ($element) {
$element.attr('tabindex', tabbable ? 0 : -1);
}
};
/**
* Handle alternative selected, i.e play sound if sound effects are enabled
*
* @method handleAlternativeSelected
* @param {Object} event Event that was fired
*/
SingleChoiceSet.prototype.handleAlternativeSelected = function (event) {
var self = this;
this.lastAnswerIsCorrect = event.data.correct;
self.toggleNextButton(true);
// Keep track of num correct/wrong answers
this.results[this.lastAnswerIsCorrect ? 'corrects' : 'wrongs']++;
self.triggerXAPI('interacted');
// Read and set a11y friendly texts
self.readA11yFriendlyText(event.data.index, event.data.currentIndex)
if (!this.muted) {
// Can't play it after the transition end is received, since this is not
// accepted on iPad. Therefore we are playing it here with a delay instead
SoundEffects.play(this.lastAnswerIsCorrect ? 'positive-short' : 'negative-short', 700);
}
};
/**
* Handler invoked when question is done
*
* @param {object} event An object containing a single boolean property: "correct".
*/
SingleChoiceSet.prototype.handleQuestionFinished = function (event) {
var self = this;
var index = event.data.index;
// saves user response
var userResponse = self.userResponses[index] = event.data.answerIndex;
// trigger answered event
var duration = this.stopStopWatch(index);
var xapiEvent = self.createXApiAnsweredEvent(self.options.choices[index], userResponse, duration);
self.trigger(xapiEvent);
self.continue(index);
};
/**
* Setup auto continue
*/
SingleChoiceSet.prototype.continue = function (index) {
var self = this;
self.choices[index].setA11yTextReadable();
if (!self.options.behaviour.autoContinue) {
// Set focus to next button
self.$nextButton.focus();
return;
}
var timeout;
var letsMove = function () {
// Handle impatient users
self.$container.off('click.impatient keydown.impatient');
clearTimeout(timeout);
self.next();
};
timeout = setTimeout(function () {
letsMove();
}, self.lastAnswerIsCorrect ? self.options.behaviour.timeoutCorrect : self.options.behaviour.timeoutWrong);
self.onImpatientUser(letsMove);
};
/**
* Listen to impatience
* @param {Function} action Callback
*/
SingleChoiceSet.prototype.onImpatientUser = function (action) {
this.$container.off('click.impatient keydown.impatient');
this.$container.one('click.impatient', action);
this.$container.one('keydown.impatient', function (event) {
// If return, space or right arrow
if ([13,32,39].indexOf(event.which)) {
action();
}
});
};
/**
* Go to next slide
*/
SingleChoiceSet.prototype.next = function () {
this.move(this.currentIndex + 1);
};
/**
* Creates an xAPI answered event
*
* @param {object} question
* @param {number} userAnswer
* @param {number} duration
*
* @return {H5P.XAPIEvent}
*/
SingleChoiceSet.prototype.createXApiAnsweredEvent = function (question, userAnswer, duration) {
var self = this;
var types = XApiEventBuilder.interactionTypes;
// creates the definition object
var definition = XApiEventBuilder.createDefinition()
.interactionType(types.CHOICE)
.description(question.question)
.correctResponsesPattern(self.getXApiCorrectResponsePattern())
.optional( self.getXApiChoices(question.answers))
.build();
// create the result object
var result = XApiEventBuilder.createResult()
.response(userAnswer.toString())
.duration(duration)
.score((userAnswer === 0) ? 1 : 0, 1)
.completion(true)
.success(userAnswer === 0)
.build();
return XApiEventBuilder.create()
.verb(XApiEventBuilder.verbs.ANSWERED)
.objectDefinition(definition)
.context(self.contentId, self.subContentId)
.contentId(self.contentId, question.subContentId)
.result(result)
.build();
};
/**
* Returns the 'correct response pattern' for xApi
*
* @return {string[]}
*/
SingleChoiceSet.prototype.getXApiCorrectResponsePattern = function () {
return [XApiEventBuilder.createCorrectResponsePattern([(0).toString()])]; // is always '0' for SCS
};
/**
* Returns the choices array for xApi statements
*
* @param {String[]} answers
*
* @return {{ choices: []}}
*/
SingleChoiceSet.prototype.getXApiChoices = function (answers) {
var choices = answers.map(function (answer, index) {
return XApiEventBuilder.createChoice(index.toString(), answer);
});
return {
choices: choices
};
};
/**
* Handles buttons that are queued for hiding
*/
SingleChoiceSet.prototype.handleQueuedButtonChanges = function () {
var self = this;
if (self.buttonsToBeHidden.length) {
self.buttonsToBeHidden.forEach(function (id) {
self.superHideButton(id);
});
}
self.buttonsToBeHidden = [];
};
/**
* Set score and feedback
*
* @params {Number} score Number of correct answers
*/
SingleChoiceSet.prototype.setScore = function (score, noXAPI) {
var self = this;
if (!self.choices.length) {
return;
}
var feedbackText = determineOverallFeedback(self.options.overallFeedback , score / self.options.choices.length)
.replace(':numcorrect', score)
.replace(':maxscore', self.options.choices.length.toString());
self.setFeedback(feedbackText, score, self.options.choices.length, self.l10n.scoreBarLabel);
if (score === self.options.choices.length) {
self.hideButton('try-again');
self.hideButton('show-solution');
}
else {
self.showButton('try-again');
self.showButton('show-solution');
}
self.handleQueuedButtonChanges();
self.scoreTimeout = undefined;
if (!noXAPI) {
self.triggerXAPIScored(score, self.options.choices.length, 'completed', true, (100 * score / self.options.choices.length) >= self.options.behaviour.passPercentage);
}
self.trigger('resize');
};
/**
* Handler invoked when view solution is selected
*/
SingleChoiceSet.prototype.handleViewSolution = function () {
var self = this;
var $tryAgainButton = $('.h5p-question-try-again', self.$container);
var $showSolutionButton = $('.h5p-question-show-solution', self.$container);
var buttons = [self.$muteButton, $tryAgainButton, $showSolutionButton];
// remove tabbable for buttons in result view
buttons.forEach(function (button) {
self.setTabbable(button, false);
});
self.solutionView.on('hide', function () {
// re-add tabbable for buttons in result view
buttons.forEach(function (button) {
self.setTabbable(button, true);
});
self.toggleAriaVisibility(true);
// Focus on first button when closing solution view
self.focusButton();
});
self.solutionView.show();
self.toggleAriaVisibility(false);
};
/**
* Toggle elements visibility to Assistive Technologies
*
* @param {boolean} enable Make elements visible
*/
SingleChoiceSet.prototype.toggleAriaVisibility = function (enable) {
var self = this;
var ariaHidden = enable ? '' : 'true';
if (self.$muteButton) {
self.$muteButton.attr('aria-hidden', ariaHidden);
}
self.progressbar.$progressbar.attr('aria-hidden', ariaHidden);
self.$choices.attr('aria-hidden', ariaHidden);
};
/**
* Register DOM elements before they are attached.
* Called from H5P.Question.
*/
SingleChoiceSet.prototype.registerDomElements = function () {
// Register task content area.
this.setContent(this.createQuestion());
// Register buttons with question.
this.addButtons();
// Insert feedback and buttons section on the result slide
this.insertSectionAtElement('feedback', this.resultSlide.$feedbackContainer);
this.insertSectionAtElement('scorebar', this.resultSlide.$feedbackContainer);
this.insertSectionAtElement('buttons', this.resultSlide.$buttonContainer);
// Question is finished
if (this.options.choices.length === this.currentIndex) {
this.trigger('question-finished');
}
this.trigger('resize');
};
/**
* Add Buttons to question.
*/
SingleChoiceSet.prototype.addButtons = function () {
var self = this;
if (this.options.behaviour.enableRetry) {
this.addButton('try-again', this.l10n.retryButtonLabel, function () {
self.resetTask(true);
}, self.results.corrects !== self.options.choices.length, {
'aria-label': this.l10n.a11yRetry,
});
}
if (this.options.behaviour.enableSolutionsButton) {
this.addButton('show-solution', this.l10n.showSolutionButtonLabel, function () {
self.showSolutions();
}, self.results.corrects !== self.options.choices.length, {
'aria-label': this.l10n.a11yShowSolution,
});
}
};
/**
* Create main content
*/
SingleChoiceSet.prototype.createQuestion = function () {
var self = this;
self.progressbar.appendTo(self.$container);
self.$container.append(self.$choices);
function toggleMute(event) {
var $button = $(event.target);
event.preventDefault();
self.muted = !self.muted;
$button.attr('aria-pressed', self.muted);
}
// Keep this out of H5P.Question, since we are moving the button & feedback
// region to the last slide
if (!this.options.behaviour.autoContinue) {
var handleNextClick = function () {
if (self.$nextButton.attr('aria-disabled') !== 'true') {
self.next();
}
};
self.$nextButton = UI.createButton({
'class': 'h5p-ssc-next-button',
'aria-label': self.l10n.nextButtonLabel,
click: handleNextClick,
keydown: function (event) {
switch (event.which) {
case 13: // Enter
case 32: // Space
handleNextClick();
event.preventDefault();
}
},
appendTo: self.$container
});
self.toggleNextButton(false);
}
if (self.options.behaviour.soundEffectsEnabled) {
self.$muteButton = $('
', {
'class': 'h5p-sc-sound-control',
'tabindex': 0,
'role': 'button',
'aria-label': self.l10n.muteButtonLabel,
'aria-pressed': false,
'on': {
'keydown': function (event) {
switch (event.which) {
case 13: // Enter
case 32: // Space
toggleMute(event);
break;
}
}
},
'click': toggleMute,
prependTo: self.$container
});
}
// Append solution view - hidden by default:
self.solutionView.appendTo(self.$container);
self.resize();
// Hide all other slides than the current one:
self.$container.addClass('initialized');
return self.$container;
};
/**
* Resize if something outside resizes
*/
SingleChoiceSet.prototype.resize = function () {
var self = this;
var maxHeight = 0;
self.choices.forEach(function (choice) {
var choiceHeight = choice.$choice.outerHeight();
maxHeight = choiceHeight > maxHeight ? choiceHeight : maxHeight;
});
// Set minimum height for choices
self.$choices.css({minHeight: maxHeight + 'px'});
};
/**
* Disable/enable the next button
* @param {boolean} enable
*/
SingleChoiceSet.prototype.toggleNextButton = function (enable) {
if (this.$nextButton) {
this.$nextButton.attr('aria-disabled', !enable);
}
};
/**
* Will jump to the given slide without any though to animations,
* current slide etc.
*
* @public
*/
SingleChoiceSet.prototype.recklessJump = function (index) {
var tX = 'translateX(' + (-index * 100) + '%)';
this.$choices.css({
'-webkit-transform': tX,
'-moz-transform': tX,
'-ms-transform': tX,
'transform': tX
});
this.progressbar.setProgress(index + 1);
};
/**
* Move to slide n
* @param {number} index The slide number to move to
* @param {boolean} moveFocus True to set focus on first alternative
*/
SingleChoiceSet.prototype.move = function (index, moveFocus = true) {
var self = this;
if (index === this.currentIndex || index > self.$slides.length-1) {
return;
}
var $previousSlide = self.$slides[self.currentIndex];
var $currentChoice = self.choices[index];
var $currentSlide = self.$slides[index];
var isResultSlide = (index >= self.choices.length);
self.toggleNextButton(false);
H5P.Transition.onTransitionEnd(self.$choices, function () {
$previousSlide.removeClass('h5p-sc-current-slide');
// on slides with answers focus on first alternative
// if content is root and not on result slide - always move focus
if (!isResultSlide && (moveFocus || self.isRoot())) {
$currentChoice.focusOnAlternative(0);
}
// on last slide, focus on try again button
else {
self.resultSlide.focusScore();
}
}, 600);
// if should show result slide
if (isResultSlide) {
self.setScore(self.results.corrects);
}
self.$container.toggleClass('navigatable', !isResultSlide);
// start timing of new slide
this.startStopWatch(index);
// move to slide
$currentSlide.addClass('h5p-sc-current-slide');
self.recklessJump(index);
self.currentIndex = index;
};
/**
* Starts a stopwatch for indexed slide
*
* @param {number} index
*/
SingleChoiceSet.prototype.startStopWatch = function (index) {
this.stopWatches[index] = this.stopWatches[index] || new StopWatch();
this.stopWatches[index].start();
};
/**
* Stops a stopwatch for indexed slide
*
* @param {number} index
*/
SingleChoiceSet.prototype.stopStopWatch = function (index) {
if (this.stopWatches[index]) {
this.stopWatches[index].stop();
}
};
/**
* Returns the passed time in seconds of a stopwatch on an indexed slide,
* or 0 if not existing
*
* @param {number} index
* @return {number}
*/
SingleChoiceSet.prototype.timePassedInStopWatch = function (index) {
if (this.stopWatches[index] !== undefined) {
return this.stopWatches[index].passedTime();
}
else {
// if not created, return no passed time,
return 0;
}
};
/**
* Returns the time the user has spent on all questions so far
*
* @return {number}
*/
SingleChoiceSet.prototype.getTotalPassedTime = function () {
return this.stopWatches
.filter(function (watch) {
return watch != undefined;
})
.reduce(function (sum, watch) {
return sum + watch.passedTime();
}, 0);
};
/**
* The following functions implements the CP and IV - Contracts v 1.0 documented here:
* http://h5p.org/node/1009
*/
SingleChoiceSet.prototype.getScore = function () {
return this.results.corrects;
};
SingleChoiceSet.prototype.getMaxScore = function () {
return this.options.choices.length;
};
SingleChoiceSet.prototype.getAnswerGiven = function () {
return (this.results.corrects + this.results.wrongs) > 0;
};
SingleChoiceSet.prototype.getTitle = function () {
return H5P.createTitle((this.contentData && this.contentData.metadata && this.contentData.metadata.title) ? this.contentData.metadata.title : 'Single Choice Set');
};
/**
* Retrieves the xAPI data necessary for generating result reports.
*
* @return {object}
*/
SingleChoiceSet.prototype.getXAPIData = function () {
var self = this;
// create array with userAnswer
var children = self.options.choices.map(function (question, index) {
var userResponse = self.userResponses[index] >= 0 ? self.userResponses[index] : '';
var duration = self.timePassedInStopWatch(index);
var event = self.createXApiAnsweredEvent(question, userResponse, duration);
return {
statement: event.data.statement
};
});
var result = XApiEventBuilder.createResult()
.score(self.getScore(), self.getMaxScore())
.duration(self.getTotalPassedTime())
.build();
// creates the definition object
var definition = XApiEventBuilder.createDefinition()
.interactionType(XApiEventBuilder.interactionTypes.COMPOUND)
.build();
var xAPIEvent = XApiEventBuilder.create()
.verb(XApiEventBuilder.verbs.ANSWERED)
.contentId(self.contentId, self.subContentId)
.context(self.getParentAttribute('contentId'), self.getParentAttribute('subContentId'))
.objectDefinition(definition)
.result(result)
.build();
return {
statement: xAPIEvent.data.statement,
children: children
};
};
/**
* Returns an attribute from this.parent if it exists
*
* @param {string} attributeName
* @return {*|undefined}
*/
SingleChoiceSet.prototype.getParentAttribute = function (attributeName) {
var self = this;
if (self.parent !== undefined) {
return self.parent[attributeName];
}
};
SingleChoiceSet.prototype.showSolutions = function () {
this.handleViewSolution();
};
/**
* Reset all answers. This is equal to refreshing the quiz
* @param {boolean} moveFocus True to move the focus
* This prevents loss of focus if reset from within content
*/
SingleChoiceSet.prototype.resetTask = function (moveFocus = false) {
var self = this;
// Close solution view if visible:
this.solutionView.hide();
// Reset the user's answers
var classes = ['h5p-sc-reveal-wrong', 'h5p-sc-reveal-correct', 'h5p-sc-selected', 'h5p-sc-drummed', 'h5p-sc-correct-answer'];
for (var i = 0; i < classes.length; i++) {
this.$choices.find('.' + classes[i]).removeClass(classes[i]);
}
this.results = {
corrects: 0,
wrongs: 0
};
this.choices.forEach(function (choice) {
choice.setAnswered(false);
choice.resetA11yText();
choice.resetAriaAttributes();
});
this.stopWatches.forEach(function (stopWatch) {
if (stopWatch) {
stopWatch.reset();
}
});
this.move(0, moveFocus);
// Reset userResponses as well
this.userResponses = [];
// Wait for transition, then remove feedback.
H5P.Transition.onTransitionEnd(this.$choices, function () {
self.removeFeedback();
}, 600);
};
/**
* Clever comment.
*
* @public
* @returns {object}
*/
SingleChoiceSet.prototype.getCurrentState = function () {
return this.userResponses.length > 0
? {
progress: this.currentIndex,
answers: this.results,
userResponses: this.userResponses
}
: undefined;
};
/**
* Generate A11y friendly text
*
* @param {number} index
* @param {number} currentIndex
*/
SingleChoiceSet.prototype.readA11yFriendlyText = function (index, currentIndex) {
var self = this;
var correctAnswer = self.$choices.find('.h5p-sc-is-correct')[index].textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
let selectedOptionText = this.lastAnswerIsCorrect ? self.l10n.correctText : self.l10n.incorrectText;
// Announce by ARIA label
if (!self.options.behaviour.autoContinue) {
// Set text for a11y
selectedOptionText = this.lastAnswerIsCorrect ? self.l10n.correctText + self.l10n.shouldSelect : self.l10n.incorrectText + self.l10n.shouldNotSelect;
self.$choices.find('.h5p-sc-current-slide .h5p-sc-is-correct .h5p-sc-a11y').text(self.l10n.shouldSelect);
self.$choices.find('.h5p-sc-current-slide .h5p-sc-is-wrong .h5p-sc-a11y').text(self.l10n.shouldNotSelect);
self.$choices.find('.h5p-sc-current-slide .h5p-sc-alternative').eq(currentIndex).find('.h5p-sc-a11y').text(selectedOptionText);
// Utilize same variable for the read text
selectedOptionText = this.lastAnswerIsCorrect ? self.l10n.correctText : self.l10n.incorrectText + correctAnswer + self.l10n.shouldSelect;
}
self.read(selectedOptionText);
};
/**
* Determine the overall feedback to display for the question.
* Returns empty string if no matching range is found.
*
* @param {Object[]} feedbacks
* @param {number} scoreRatio
* @return {string}
*/
var determineOverallFeedback = function (feedbacks, scoreRatio) {
scoreRatio = Math.floor(scoreRatio * 100);
for (var i = 0; i < feedbacks.length; i++) {
var feedback = feedbacks[i];
var hasFeedback = (feedback.feedback !== undefined && feedback.feedback.trim().length !== 0);
if (feedback.from <= scoreRatio && feedback.to >= scoreRatio && hasFeedback) {
return feedback.feedback;
}
}
return '';
};
return SingleChoiceSet;
})(H5P.jQuery, H5P.JoubelUI, H5P.Question, H5P.SingleChoiceSet.SingleChoice, H5P.SingleChoiceSet.SolutionView, H5P.SingleChoiceSet.ResultSlide, H5P.SingleChoiceSet.SoundEffects, H5P.SingleChoiceSet.XApiEventBuilder, H5P.SingleChoiceSet.StopWatch);
;