nodegame-widgets
Version:
Collections of useful and reusable javascript / HTML snippets for nodeGame
1,478 lines (1,321 loc) • 69.6 kB
JavaScript
/**
* # ChoiceTable
* Copyright(c) 2021 Stefano Balietti
* MIT Licensed
*
* Creates a configurable table where each cell is a selectable choice
*
* // TODO: register time for each current choice if selectMultiple is on?
*
* www.nodegame.org
*/
(function(node) {
"use strict";
node.widgets.register('ChoiceTable', ChoiceTable);
// ## Meta-data
ChoiceTable.version = '1.8.1';
ChoiceTable.description = 'Creates a configurable table where ' +
'each cell is a selectable choice.';
ChoiceTable.title = 'Make your choice';
ChoiceTable.className = 'choicetable';
ChoiceTable.texts = {
autoHint: function(w) {
var res;
if (!w.requiredChoice && !w.selectMultiple) return false;
if (!w.selectMultiple) return '*';
res = '(';
if (!w.requiredChoice) {
if ('number' === typeof w.selectMultiple) {
res += 'select up to ' + w.selectMultiple;
}
else {
res += 'multiple selection allowed';
}
}
else {
if ('number' === typeof w.selectMultiple) {
if (w.selectMultiple === w.requiredChoice) {
res += 'select ' + w.requiredChoice;
}
else {
res += 'select between ' + w.requiredChoice +
' and ' + w.selectMultiple;
}
}
else {
res += 'select at least ' + w.requiredChoice;
}
}
res += ')';
if (w.requiredChoice) res += ' *';
return res;
},
error: function(w, value) {
if (value !== null &&
('number' === typeof w.correctChoice ||
'string' === typeof w.correctChoice)) {
return 'Not correct, try again.';
}
return 'Selection required.';
}
// correct: 'Correct.'
};
ChoiceTable.separator = '::';
// ## Dependencies
ChoiceTable.dependencies = {
JSUS: {}
};
/**
* ## ChoiceTable constructor
*
* Creates a new instance of ChoiceTable
*/
function ChoiceTable() {
var that;
that = this;
/**
* ### ChoiceTable.table
*
* The HTML element triggering the listener function when clicked
*/
this.table = null;
/**
* ### ChoiceTable.choicesSetSize
*
* How many choices can be on the same row/column
*/
this.choicesSetSize = null;
/**
* ### ChoiceTable.tr
*
* Reference to TR elements of the table
*
* Note: if the orientation is vertical there will be multiple TR
* otherwise just one.
*
* @see createTR
*/
this.trs = [];
/**
* ### ChoiceTable.listener
*
* The main function listening on clicks
*
* @see ChoiceTable.onclick
*/
this.listener = function(e) {
var name, value, td, tr;
var i, len, removed;
e = e || window.event;
td = e.target || e.srcElement;
// See if it is a clickable choice.
if ('undefined' === typeof that.choicesIds[td.id]) {
// It might be a nested element, try the parent.
td = td.parentNode;
if (!td) return;
if ('undefined' === typeof that.choicesIds[td.id]) {
td = td.parentNode;
if (!td || 'undefined' === typeof that.choicesIds[td.id]) {
return;
}
}
}
// Relative time.
if ('string' === typeof that.timeFrom) {
that.timeCurrentChoice = node.timer.getTimeSince(that.timeFrom);
}
// Absolute time.
else {
that.timeCurrentChoice = Date.now ?
Date.now() : new Date().getTime();
}
// Id of elements are in the form of name_value or name_item_value.
value = td.id.split(that.separator);
// Separator not found, not a clickable cell.
if (value.length === 1) return;
name = value[0];
value = parseInt(value[1], 10);
// value = value[1];
// Choice disabled.
// console.log('VALUE: ', value);
if (that.disabledChoices[value]) return;
// One more click.
that.numberOfClicks++;
// Click on an already selected choice.
if (that.isChoiceCurrent(value)) {
that.unsetCurrentChoice(value);
J.removeClass(td, 'selected');
if (that.selectMultiple) {
// Remove selected TD (need to keep this clean for reset).
i = -1, len = that.selected.length;
for ( ; ++i < len ; ) {
if (that.selected[i].id === td.id) {
that.selected.splice(i, 1);
break;
}
}
}
else {
that.selected = null;
}
removed = true;
}
// Click on a new choice.
else {
// Have we exhausted available choices?
if ('number' === typeof that.selectMultiple &&
that.selected.length === that.selectMultiple) return;
J.addClass(td, 'selected');
if (that.oneTimeClick) {
setTimeout(function() {
J.removeClass(td, 'selected');
}, 60);
}
else {
that.setCurrentChoice(value);
if (that.selectMultiple) {
that.selected.push(td);
}
else {
// If only 1 selection allowed, remove old selection.
if (that.selected) {
J.removeClass(that.selected, 'selected');
}
that.selected = td;
}
}
}
// Remove any warning/errors on click.
if (that.isHighlighted()) that.unhighlight();
// Call onclick, if any.
if (that.onclick) {
// TODO: Should we parseInt it anyway when we store
// the current choice?
value = parseInt(value, 10);
that.onclick.call(that, value, removed, td);
}
};
/**
* ## ChoiceTable.onclick
*
* The user-defined onclick listener
*
* Receives 3 input parameters: the value of the clicked choice,
* whether it was a remove action, and the reference to the TD object.
*
* @see ChoiceTableGroup.listener
*/
this.onclick = null;
/**
* ### ChoiceTable.mainText
*
* The main text introducing the choices
*
* @see ChoiceTable.spanMainText
*/
this.mainText = null;
/**
* ### ChoiceTable.hint
*
* An additional text with information about how to select items
*
* If not specified, it may be auto-filled, e.g. '(pick 2)'.
*
* @see Feedback.texts.autoHint
*/
this.hint = null;
/**
* ### ChoiceTable.spanMainText
*
* The span containing the main text
*/
this.spanMainText = null;
/**
* ### ChoiceTable.choices
*
* The array available choices
*/
this.choices = null;
/**
* ### ChoiceTable.choicesValues
*
* Map of choices' values to indexes in the choices array
*/
this.choicesValues = {};
/**
* ### ChoiceTable.choicesIds
*
* Map of choices' cells ids to choices
*
* Used to determine what are the clickable choices.
*/
this.choicesIds = {};
/**
* ### ChoiceTable.choicesCells
*
* The cells of the table associated with each choice
*/
this.choicesCells = null;
/**
* ### ChoiceTable.left
*
* A non-clickable first cell of the row/column
*
* It will be placed to the left of the choices if orientation
* is horizontal, or above the choices if orientation is vertical
*
* @see ChoiceTable.orientation
*/
this.left = null;
/**
* ### ChoiceTable.leftCell
*
* The rendered left cell
*
* @see ChoiceTable.renderSpecial
*/
this.leftCell = null;
/**
* ### ChoiceTable.right
*
* A non-clickable last cell of the row/column
*
* It will be placed to the right of the choices if orientation
* is horizontal, or below the choices if orientation is vertical
*
* @see ChoiceTable.orientation
*/
this.right = null;
/**
* ### ChoiceTable.rightCell
*
* The rendered right cell
*
* @see ChoiceTable.renderSpecial
*/
this.rightCell = null;
/**
* ### ChoiceTable.errorBox
*
* An HTML element displayed when a validation error occurs
*/
this.errorBox = null;
/**
* ### CustomInput.successBox
*
* An HTML element displayed when a validation error occurs
*/
this.successBox = null;
/**
* ### ChoiceTable.timeCurrentChoice
*
* Time when the last choice was made
*/
this.timeCurrentChoice = null;
/**
* ### ChoiceTable.timeFrom
*
* Time is measured from timestamp as saved by node.timer
*
* Default event is a new step is loaded (user can interact with
* the screen). Set it to FALSE, to have absolute time.
*
* @see node.timer.getTimeSince
*/
this.timeFrom = 'step';
/**
* ### ChoiceTable.order
*
* The current order of display of choices
*
* @see ChoiceTable.originalOrder
*/
this.order = null;
/**
* ### ChoiceTable.correctChoice
*
* The correct choice/s
*
* The field is an array or number|string depending
* on the value of ChoiceTable.selectMultiple
*
* @see ChoiceTable.selectMultiple
*/
this.correctChoice = null;
/**
* ### ChoiceTable.requiredChoice
*
* The number of required choices. Default 0
*/
this.requiredChoice = null;
/**
* ### ChoiceTable.attempts
*
* List of currentChoices at the moment of verifying correct answers
*/
this.attempts = [];
/**
* ### ChoiceTable.numberOfClicks
*
* Total number of clicks on different choices
*/
this.numberOfClicks = 0;
/**
* ### ChoiceTable.selected
*
* Currently selected TD elements
*
* @see ChoiceTable.currentChoice
*/
this.selected = null;
/**
* ### ChoiceTable.currentChoice
*
* Choice/s associated with currently selected cell/s
*
* The field is an array or number|string depending
* on the value of ChoiceTable.selectMultiple
*
* @see ChoiceTable.selectMultiple
*
* @see ChoiceTable.selected
*/
this.currentChoice = null;
/**
* ### ChoiceTable.selectMultiple
*
* The number of maximum simulataneous selections (>1), or false
*
* Note: this option is incompatible with `oneTimeClick`.
*/
this.selectMultiple = null;
/**
* ### ChoiceTable.oneTimeClick
*
* If TRUE, the selection is immediately removed after one click
*
* This is useful to create a buttons group which trigger some actions.
*
* Note: this option is incompatible with `selectMultiple`.
*/
this.oneTimeClick = null;
/**
* ### ChoiceTable.shuffleChoices
*
* If TRUE, choices are randomly assigned to cells
*
* @see ChoiceTable.order
*/
this.shuffleChoices = null;
/**
* ### ChoiceTable.renderer
*
* A callback that renders the content of each cell
*
* The callback must accept three parameters:
*
* - a td HTML element,
* - a choice
* - the index of the choice element within the choices array
*
* and optionally return the _value_ for the choice (otherwise
* the order in the choices array is used as value).
*/
this.renderer = null;
/**
* ### ChoiceTable.orientation
*
* Orientation of display of choices: vertical ('V') or horizontal ('H')
*
* Default orientation is horizontal.
*/
this.orientation = 'H';
/**
* ### ChoiceTable.group
*
* The name of the group where the table belongs, if any
*/
this.group = null;
/**
* ### ChoiceTable.groupOrder
*
* The order of the choice table within the group
*/
this.groupOrder = null;
/**
* ### ChoiceTable.freeText
*
* If truthy, a textarea for free-text comment will be added
*
* If 'string', the text will be added inside the textarea
*/
this.freeText = null;
/**
* ### ChoiceTable.textarea
*
* Textarea for free-text comment
*/
this.textarea = null;
/**
* ### ChoiceTable.separator
*
* Symbol used to separate tokens in the id attribute of every cell
*
* Default ChoiceTable.separator
*
* @see ChoiceTable.renderChoice
*/
this.separator = ChoiceTable.separator;
/**
* ### ChoiceTable.tabbable
*
* If TRUE, the elements of the table can be accessed with TAB
*
* Clicking is simulated upon pressing space or enter.
*
* Default TRUE
*
* @see ChoiceTable.renderChoice
*/
this.tabbable = null;
/**
* ### ChoiceTable.disabledChoices
*
* An object containing the list of disabled values
*/
this.disabledChoices = {};
/**
* ### ChoiceTable.sameWidthCells
*
* If TRUE, cells have same width regardless of content
*/
this.sameWidthCells = true;
}
// ## ChoiceTable methods
/**
* ### ChoiceTable.init
*
* Initializes the instance
*
* Available options are:
*
* - left: the content of the left (or top) cell
* - right: the content of the right (or bottom) cell
* - className: the className of the table (string, array), or false
* to have none.
* - orientation: orientation of the table: vertical (v) or horizontal (h)
* - group: the name of the group (number or string), if any
* - groupOrder: the order of the table in the group, if any
* - listener: a function executed at every click. Context is
* `this` instance
* - onclick: a function executed after the listener function. Context is
* `this` instance
* - mainText: a text to be displayed above the table
* - hint: a text with extra info to be displayed after mainText
* - choices: the array of available choices. See
* `ChoiceTable.renderChoice` for info about the format
* - correctChoice: the array|number|string of correct choices. See
* `ChoiceTable.setCorrectChoice` for info about the format
* - selectMultiple: if TRUE multiple cells can be selected
* - shuffleChoices: if TRUE, choices are shuffled before being added
* to the table
* - renderer: a function that will render the choices. See
* ChoiceTable.renderer for info about the format
* - freeText: if TRUE, a textarea will be added under the table,
* if 'string', the text will be added inside the textarea
* - timeFrom: The timestamp as recorded by `node.timer.setTimestamp`
* or FALSE, to measure absolute time for current choice
* - tabbable: if TRUE, each cell can be reached with TAB and clicked
* with SPACE or ENTER. Default: TRUE.
* - disabledChoices: array of disabled choices (values).
*
* @param {object} opts Configuration options
*/
ChoiceTable.prototype.init = function(opts) {
var tmp, that;
that = this;
if (!this.id) {
throw new TypeError('ChoiceTable.init: opts.id is missing');
}
// Option orientation, default 'H'.
if ('undefined' === typeof opts.orientation) {
tmp = 'H';
}
else if ('string' !== typeof opts.orientation) {
throw new TypeError('ChoiceTable.init: opts.orientation must ' +
'be string, or undefined. Found: ' +
opts.orientation);
}
else {
tmp = opts.orientation.toLowerCase().trim();
if (tmp === 'horizontal' || tmp === 'h') {
tmp = 'H';
}
else if (tmp === 'vertical' || tmp === 'v') {
tmp = 'V';
}
else {
throw new Error('ChoiceTable.init: opts.orientation is ' +
'invalid: ' + tmp);
}
}
this.orientation = tmp;
// Option shuffleChoices, default false.
if ('undefined' === typeof opts.shuffleChoices) tmp = false;
else tmp = !!opts.shuffleChoices;
this.shuffleChoices = tmp;
// Option selectMultiple, default false.
tmp = opts.selectMultiple;
if ('undefined' === typeof tmp) {
tmp = false;
}
else if ('boolean' !== typeof tmp) {
tmp = J.isInt(tmp, 1);
if (!tmp) {
throw new Error('ChoiceTable.init: selectMultiple must be ' +
'undefined or an integer > 1. Found: ' + tmp);
}
}
this.selectMultiple = tmp;
// Make an array for currentChoice and selected.
if (tmp) {
this.selected = [];
this.currentChoice = [];
}
// Option requiredChoice, if any.
if ('number' === typeof opts.requiredChoice) {
if (!J.isInt(opts.requiredChoice, 0)) {
throw new Error('ChoiceTable.init: if number, requiredChoice ' +
'must a positive integer. Found: ' +
opts.requiredChoice);
}
if ('number' === typeof this.selectMultiple &&
opts.requiredChoice > this.selectMultiple) {
throw new Error('ChoiceTable.init: requiredChoice cannot be ' +
'larger than selectMultiple. Found: ' +
opts.requiredChoice + ' > ' +
this.selectMultiple);
}
this.requiredChoice = opts.requiredChoice;
}
else if ('boolean' === typeof opts.requiredChoice) {
this.requiredChoice = opts.requiredChoice ? 1 : null;
}
else if ('undefined' !== typeof opts.requiredChoice) {
throw new TypeError('ChoiceTable.init: opts.requiredChoice ' +
'be number, boolean or undefined. Found: ' +
opts.requiredChoice);
}
if ('undefined' !== typeof opts.oneTimeClick) {
this.oneTimeClick = !!opts.oneTimeClick;
}
// Set the group, if any.
if ('string' === typeof opts.group ||
'number' === typeof opts.group) {
this.group = opts.group;
}
else if ('undefined' !== typeof opts.group) {
throw new TypeError('ChoiceTable.init: opts.group must ' +
'be string, number or undefined. Found: ' +
opts.group);
}
// Set the groupOrder, if any.
if ('number' === typeof opts.groupOrder) {
this.groupOrder = opts.groupOrder;
}
else if ('undefined' !== typeof opts.groupOrder) {
throw new TypeError('ChoiceTable.init: opts.groupOrder must ' +
'be number or undefined. Found: ' +
opts.groupOrder);
}
// Set the main onclick listener, if any.
if ('function' === typeof opts.listener) {
this.listener = function(e) {
opts.listener.call(this, e);
};
}
else if ('undefined' !== typeof opts.listener) {
throw new TypeError('ChoiceTable.init: opts.listener must ' +
'be function or undefined. Found: ' +
opts.listener);
}
// Set an additional onclick, if any.
if ('function' === typeof opts.onclick) {
this.onclick = opts.onclick;
}
else if ('undefined' !== typeof opts.onclick) {
throw new TypeError('ChoiceTable.init: opts.onclick must ' +
'be function or undefined. Found: ' +
opts.onclick);
}
// Set the mainText, if any.
if ('string' === typeof opts.mainText) {
this.mainText = opts.mainText;
}
else if ('undefined' !== typeof opts.mainText) {
throw new TypeError('ChoiceTable.init: opts.mainText must ' +
'be string or undefined. Found: ' +
opts.mainText);
}
// Set the hint, if any.
if ('string' === typeof opts.hint || false === opts.hint) {
this.hint = opts.hint;
if (this.requiredChoice) this.hint += ' *';
}
else if ('undefined' !== typeof opts.hint) {
throw new TypeError('ChoiceTable.init: opts.hint must ' +
'be a string, false, or undefined. Found: ' +
opts.hint);
}
else {
// Returns undefined if there are no constraints.
this.hint = this.getText('autoHint');
}
// Set the timeFrom, if any.
if (opts.timeFrom === false ||
'string' === typeof opts.timeFrom) {
this.timeFrom = opts.timeFrom;
}
else if ('undefined' !== typeof opts.timeFrom) {
throw new TypeError('ChoiceTable.init: opts.timeFrom must ' +
'be string, false, or undefined. Found: ' +
opts.timeFrom);
}
// Set the separator, if any.
if ('string' === typeof opts.separator) {
this.separator = opts.separator;
}
else if ('undefined' !== typeof opts.separator) {
throw new TypeError('ChoiceTable.init: opts.separator must ' +
'be string, or undefined. Found: ' +
opts.separator);
}
// Conflict might be generated by id or seperator.
tmp = this.id + this.separator.substring(0, (this.separator.length -1));
if (this.id.indexOf(this.separator) !== -1 ||
tmp.indexOf(this.separator) !== -1) {
throw new Error('ChoiceTable.init: separator cannot be ' +
'included in the id or in the concatenation ' +
'(id + separator). Please specify the right ' +
'separator option. Found: ' + this.separator);
}
if ('string' === typeof opts.left ||
'number' === typeof opts.left) {
this.left = '' + opts.left;
}
else if (J.isNode(opts.left) ||
J.isElement(opts.left)) {
this.left = opts.left;
}
else if ('undefined' !== typeof opts.left) {
throw new TypeError('ChoiceTable.init: opts.left must ' +
'be string, number, an HTML Element or ' +
'undefined. Found: ' + opts.left);
}
if ('string' === typeof opts.right ||
'number' === typeof opts.right) {
this.right = '' + opts.right;
}
else if (J.isNode(opts.right) ||
J.isElement(opts.right)) {
this.right = opts.right;
}
else if ('undefined' !== typeof opts.right) {
throw new TypeError('ChoiceTable.init: opts.right must ' +
'be string, number, an HTML Element or ' +
'undefined. Found: ' + opts.right);
}
// Set the className, if not use default.
if ('undefined' === typeof opts.className) {
this.className = ChoiceTable.className;
}
else if (opts.className === false) {
this.className = false;
}
else if ('string' === typeof opts.className) {
this.className = ChoiceTable.className + ' ' + opts.className;
}
else if ( J.isArray(opts.className)) {
this.className = [ChoiceTable.className].concat(opts.className);
}
else {
throw new TypeError('ChoiceTable.init: opts.' +
'className must be string, array, ' +
'or undefined. Found: ' + opts.className);
}
if (opts.tabbable !== false) this.tabbable = true;
// Set the renderer, if any.
if ('function' === typeof opts.renderer) {
this.renderer = opts.renderer;
}
else if ('undefined' !== typeof opts.renderer) {
throw new TypeError('ChoiceTable.init: opts.renderer must ' +
'be function or undefined. Found: ' +
opts.renderer);
}
// After all configuration opts are evaluated, add choices.
// Set table.
if ('object' === typeof opts.table) {
this.table = opts.table;
}
else if ('undefined' !== typeof opts.table &&
false !== opts.table) {
throw new TypeError('ChoiceTable.init: opts.table ' +
'must be object, false or undefined. ' +
'Found: ' + opts.table);
}
this.table = opts.table;
this.freeText = 'string' === typeof opts.freeText ?
opts.freeText : !!opts.freeText;
// Add the correct choices.
if ('undefined' !== typeof opts.choicesSetSize) {
if (!J.isInt(opts.choicesSetSize, 0)) {
throw new Error('ChoiceTable.init: choicesSetSize must be ' +
'undefined or an integer > 0. Found: ' +
opts.choicesSetSize);
}
if (this.left || this.right) {
throw new Error('ChoiceTable.init: choicesSetSize option ' +
'cannot be specified when either left or ' +
'right options are set.');
}
this.choicesSetSize = opts.choicesSetSize;
}
// Add the choices.
if ('undefined' !== typeof opts.choices) {
this.setChoices(opts.choices);
}
// Add the correct choices.
if ('undefined' !== typeof opts.correctChoice) {
if (this.requiredChoice) {
throw new Error('ChoiceTable.init: cannot specify both ' +
'opts requiredChoice and correctChoice');
}
this.setCorrectChoice(opts.correctChoice);
}
// Add the correct choices.
if ('undefined' !== typeof opts.disabledChoices) {
if (!J.isArray(opts.disabledChoices)) {
throw new Error('ChoiceTable.init: disabledChoices must be ' +
'undefined or array. Found: ' +
opts.disabledChoices);
}
// TODO: check if values of disabled choices are correct?
// Do we have the choices now, or can they be added later?
tmp = opts.disabledChoices.length;
if (tmp) {
(function() {
for (var i = 0; i < tmp; i++) {
that.disableChoice(opts.disabledChoices[i]);
}
})();
}
}
if ('undefined' === typeof opts.sameWidthCells) {
this.sameWidthCells = !!opts.sameWidthCells;
}
};
/**
* ### ChoiceTable.disableChoice
*
* Marks a choice as disabled (will not be clickable)
*
* @param {string|number} value The value of the choice to disable`
*/
ChoiceTable.prototype.disableChoice = function(value) {
this.disabledChoices[value] = true;
};
/**
* ### ChoiceTable.enableChoice
*
* Enables a choice (will be clickable again if previously disabled)
*
* @param {string|number} value The value of the choice to disable`
*/
ChoiceTable.prototype.enableChoice = function(value) {
this.disabledChoices[value] = null;
};
/**
* ### ChoiceTable.setChoices
*
* Sets the available choices and optionally builds the table
*
* If a table is defined, it will automatically append the choices
* as TD cells. Otherwise, the choices will be built but not appended.
*
* @param {array} choices The array of choices
*
* @see ChoiceTable.table
* @see ChoiceTable.shuffleChoices
* @see ChoiceTable.order
* @see ChoiceTable.buildChoices
* @see ChoiceTable.buildTableAndChoices
*/
ChoiceTable.prototype.setChoices = function(choices) {
var len;
if (!J.isArray(choices)) {
throw new TypeError('ChoiceTable.setChoices: choices ' +
'must be array');
}
if (!choices.length) {
throw new Error('ChoiceTable.setChoices: choices array is empty');
}
this.choices = choices;
len = choices.length;
// Save the order in which the choices will be added.
this.order = J.seq(0, len-1);
if (this.shuffleChoices) this.order = J.shuffle(this.order);
// Build the table and choices at once (faster).
if (this.table) this.buildTableAndChoices();
// Or just build choices.
else this.buildChoices();
};
/**
* ### ChoiceTable.buildChoices
*
* Render every choice and stores cell in `choicesCells` array
*
* Left and right cells are also rendered, if specified.
*
* Follows a shuffled order, if set
*
* @see ChoiceTable.order
* @see ChoiceTable.renderChoice
* @see ChoiceTable.renderSpecial
*/
ChoiceTable.prototype.buildChoices = function() {
var i, len;
i = -1, len = this.choices.length;
// Pre-allocate the choicesCells array.
this.choicesCells = new Array(len);
for ( ; ++i < len ; ) {
this.renderChoice(this.choices[this.order[i]], i);
}
if (this.left) this.renderSpecial('left', this.left);
if (this.right) this.renderSpecial('right', this.right);
};
/**
* ### ChoiceTable.buildTable
*
* Builds the table of clickable choices and enables it
*
* Must be called after choices have been set already.
*
* @see ChoiceTable.setChoices
* @see ChoiceTable.order
* @see ChoiceTable.renderChoice
* @see ChoiceTable.orientation
* @see ChoiceTable.choicesSetSize
*/
ChoiceTable.prototype.buildTable = (function() {
function makeSet(i, len, H, doSets) {
var tr, counter;
counter = 0;
// Start adding tr/s and tds based on the orientation.
if (H) {
tr = createTR(this, 'main');
// Add horizontal choices title.
if (this.leftCell) tr.appendChild(this.leftCell);
}
// Main loop.
for ( ; ++i < len ; ) {
if (!H) {
tr = createTR(this, 'left');
// Add vertical choices title.
if (i === 0 && this.leftCell) {
tr.appendChild(this.leftCell);
tr = createTR(this, i);
}
}
// Clickable cell.
tr.appendChild(this.choicesCells[i]);
// Stop if we reached set size (still need to add the right).
if (doSets && ++counter >= this.choicesSetSize) break;
}
if (this.rightCell) {
if (!H) tr = createTR(this, 'right');
tr.appendChild(this.rightCell);
}
// Start a new set, if necessary.
if (i !== len) makeSet.call(this, i, len, H, doSets);
}
return function() {
var len, H, doSets;
if (!this.choicesCells) {
throw new Error('ChoiceTable.buildTable: choices not set, ' +
'cannot build table. Id: ' + this.id);
}
H = this.orientation === 'H';
len = this.choicesCells.length;
doSets = 'number' === typeof this.choicesSetSize;
// Recursively makes sets
makeSet.call(this, -1, len, H, doSets);
// Enable onclick listener.
this.enable();
};
})();
/**
* ### ChoiceTable.buildTableAndChoices
*
* Builds the table of clickable choices
*
* @see ChoiceTable.choices
* @see ChoiceTable.order
* @see ChoiceTable.renderChoice
* @see ChoiceTable.orientation
*/
ChoiceTable.prototype.buildTableAndChoices = function() {
var i, len, tr, td, H;
len = this.choices.length;
// Pre-allocate the choicesCells array.
this.choicesCells = new Array(len);
// Start adding tr/s and tds based on the orientation.
i = -1, H = this.orientation === 'H';
if (H) {
tr = createTR(this, 'main');
// Add horizontal choices left.
if (this.left) {
td = this.renderSpecial('left', this.left);
tr.appendChild(td);
}
}
// Main loop.
for ( ; ++i < len ; ) {
if (!H) {
tr = createTR(this, 'left');
// Add vertical choices left.
if (i === 0 && this.left) {
td = this.renderSpecial('left', this.left);
tr.appendChild(td);
tr = createTR(this, i);
}
}
// Clickable cell.
td = this.renderChoice(this.choices[this.order[i]], i);
tr.appendChild(td);
}
if (this.right) {
if (!H) tr = createTR(this, 'right');
td = this.renderSpecial('right', this.right);
tr.appendChild(td);
}
// Enable onclick listener.
this.enable();
};
/**
* ### ChoiceTable.renderSpecial
*
* Renders a non-choice element into a cell of the table (e.g. left/right)
*
* @param {string} type The type of special cell ('left' or 'right').
* @param {mixed} special The special element. It must be string or number,
* or array where the first element is the 'value' (incorporated in the
* `id` field) and the second the text to display as choice.
*
* @return {HTMLElement} td The newly created cell of the table
*
* @see ChoiceTable.left
* @see ChoiceTable.right
*/
ChoiceTable.prototype.renderSpecial = function(type, special) {
var td, className;
td = document.createElement('td');
if ('string' === typeof special) td.innerHTML = special;
// HTML element (checked before).
else td.appendChild(special);
if (type === 'left') {
className = this.className ? this.className + '-left' : 'left';
this.leftCell = td;
}
else if (type === 'right') {
className = this.className ? this.className + '-right' : 'right';
this.rightCell = td;
}
else {
throw new Error('ChoiceTable.renderSpecial: unknown type: ' + type);
}
td.className = className;
td.id = this.id + this.separator + 'special-cell-' + type;
return td;
};
/* UPDATED TEXT
* @param {mixed} choice The choice element. It must be string or
* number, HTML element, or an array. If array, the first
* element is the short value (string or number), and the second
* one the the full value (string, number or HTML element) to
* display. If a renderer function is defined there are no
* restriction on the format of choice
* @param {number} idx The position of the choice within the choice array
*/
/**
* ### ChoiceTable.renderChoice
*
* Transforms a choice element into a cell of the table
*
* A reference to the cell is saved in `choicesCells`.
*
* @param {mixed} choice The choice element. It may be string, number,
* array where the first element is the 'value' and the second the
* text to display as choice, or an object with properties value and
* display. If a renderer function is defined there are no restriction
* on the format of choice.
* @param {number} idx The position of the choice within the choice array
*
* @return {HTMLElement} td The newly created cell of the table
*
* @see ChoiceTable.renderer
* @see ChoiceTable.separator
* @see ChoiceTable.choicesCells
*/
ChoiceTable.prototype.renderChoice = function(choice, idx) {
var td, shortValue, value, width;
td = document.createElement('td');
if (this.tabbable) J.makeTabbable(td);
// Forces equal width.
if (this.sameWidthCells && this.orientation === 'H') {
width = this.left ? 70 : 100;
if (this.right) width = width - 30;
width = width / (this.choicesSetSize || this.choices.length);
td.style.width = width.toFixed(2) + '%';
}
// Use custom renderer.
if (this.renderer) {
value = this.renderer(td, choice, idx);
if ('undefined' === typeof value) value = idx;
}
// Or use standard format.
else {
if (J.isArray(choice)) {
shortValue = choice[0];
choice = choice[1];
}
else if ('object' === typeof choice) {
shortValue = choice.value;
choice = choice.display;
}
value = this.shuffleChoices ? this.order[idx] : idx;
if ('string' === typeof choice || 'number' === typeof choice) {
td.innerHTML = choice;
}
else if (J.isElement(choice) || J.isNode(choice)) {
td.appendChild(choice);
}
else if (node.widgets.isWidget(choice)) {
node.widgets.append(choice, td);
}
else {
throw new Error('ChoiceTable.renderChoice: invalid choice: ' +
choice);
}
}
// Map a value to the index.
if ('undefined' !== typeof this.choicesValues[value]) {
throw new Error('ChoiceTable.renderChoice: value already ' +
'in use: ' + value);
}
// Add the id if not added already by the renderer function.
if (!td.id || td.id === '') {
td.id = this.id + this.separator + value;
}
// All fine, updates global variables.
this.choicesValues[value] = idx;
this.choicesCells[idx] = td;
this.choicesIds[td.id] = td;
return td;
};
/**
* ### ChoiceTable.setCorrectChoice
*
* Set the correct choice/s
*
* Correct choice/s are always stored as 'strings', or not number
* because then they are compared against the valued saved in
* the `id` field of the cell
*
* @param {number|string|array} If `selectMultiple` is set, param must
* be an array, otherwise a string or a number. Each correct choice
* must have been already defined as choice (value)
*
* @see ChoiceTable.setChoices
* @see checkCorrectChoiceParam
*/
ChoiceTable.prototype.setCorrectChoice = function(choice) {
var i, len;
if (!this.selectMultiple) {
choice = checkCorrectChoiceParam(this, choice);
}
else {
if (J.isArray(choice) && choice.length) {
i = -1, len = choice.length;
for ( ; ++i < len ; ) {
choice[i] = checkCorrectChoiceParam(this, choice[i]);
}
}
else {
throw new TypeError('ChoiceTable.setCorrectChoice: choice ' +
'must be non-empty array. Found: ' +
choice);
}
}
this.correctChoice = choice;
};
/**
* ### ChoiceTable.append
*
* Implements Widget.append
*
* Checks that id is unique.
*
* Appends (all optional):
*
* - mainText: a question or statement introducing the choices
* - table: the table containing the choices
* - freeText: a textarea for comments
*
* @see Widget.append
*/
ChoiceTable.prototype.append = function() {
var tmp;
// Id must be unique.
if (W.getElementById(this.id)) {
throw new Error('ChoiceTable.append: id is not ' +
'unique: ' + this.id);
}
// MainText.
if (this.mainText) {
this.spanMainText = W.append('span', this.bodyDiv, {
className: 'choicetable-maintext',
innerHTML: this.mainText
});
}
// Hint.
if (this.hint) {
W.append('span', this.spanMainText || this.bodyDiv, {
className: 'choicetable-hint',
innerHTML: this.hint
});
}
// Create/set table.
if (this.table !== false) {
// Create table, if it was not passed as object before.
if ('undefined' === typeof this.table) {
this.table = document.createElement('table');
this.buildTable();
}
// Set table id.
this.table.id = this.id;
// Class.
tmp = this.className ? [ this.className ] : [];
if (this.orientation !== 'H') tmp.push('choicetable-vertical');
if (tmp.length) J.addClass(this.table, tmp);
else this.table.className = '';
// Append table.
this.bodyDiv.appendChild(this.table);
}
this.errorBox = W.append('div', this.bodyDiv, { className: 'errbox' });
// Creates a free-text textarea, possibly with placeholder text.
if (this.freeText) {
this.textarea = document.createElement('textarea');
if (this.id) this.textarea.id = this.id + '_text';
if ('string' === typeof this.freeText) {
this.textarea.placeholder = this.freeText;
}
tmp = this.className ? this.className + '-freetext' : 'freetext';
this.textarea.className = tmp;
// Append textarea.
this.bodyDiv.appendChild(this.textarea);
}
};
/**
* ### ChoiceTable.setError
*
* Set the error msg inside the errorBox and call highlight
*
* @param {string} The error msg (can contain HTML)
*
* @see ChoiceTable.highlight
* @see ChoiceTable.errorBox
*/
ChoiceTable.prototype.setError = function(err) {
// TODO: the errorBox is added only if .append() is called.
// However, ChoiceTableGroup use the table without calling .append().
if (this.errorBox) this.errorBox.innerHTML = err || '';
if (err) this.highlight();
else this.unhighlight();
};
/**
* ### ChoiceTable.listeners
*
* Implements Widget.listeners
*
* Adds two listeners two disable/enable the widget on events:
* INPUT_DISABLE, INPUT_ENABLE
*
* @see Widget.listeners
*/
ChoiceTable.prototype.listeners = function() {
var that = this;
node.on('INPUT_DISABLE', function() {
that.disable();
});
node.on('INPUT_ENABLE', function() {
that.enable();
});
};
/**
* ### ChoiceTable.disable
*
* Disables clicking on the table and removes CSS 'clicklable' class
*/
ChoiceTable.prototype.disable = function() {
if (this.disabled === true) return;
this.disabled = true;
if (this.table) {
J.removeClass(this.table, 'clickable');
this.table.removeEventListener('click', this.listener);
// Remove listener to make cells clickable with the keyboard.
if (this.tabbable) J.makeClickable(this.table, false);
}
this.emit('disabled');
};
/**
* ### ChoiceTable.enable
*
* Enables clicking on the table and adds CSS 'clicklable' class
*
* @return {function} cb The event listener function
*/
ChoiceTable.prototype.enable = function() {
if (this.disabled === false) return;
if (!this.table) {
throw new Error('ChoiceTable.enable: table not defined');
}
this.disabled = false;
J.addClass(this.table, 'clickable');
this.table.addEventListener('click', this.listener);
// Add listener to make cells clickable with the keyboard.
if (this.tabbable) J.makeClickable(this.table);
this.emit('enabled');
};
/**
* ### ChoiceTable.verifyChoice
*
* Compares the current choice/s with the correct one/s
*
* Depending on current settings, there are two modes of verifying
* choices:
*
* - requiredChoice: there must be at least N choices selected
* - correctChoice: the choices are compared against correct ones.
*
* @param {boolean} markAttempt Optional. If TRUE, the value of
* current choice is added to the attempts array. Default: TRUE
*
* @return {boolean|null} TRUE if current choice is correct,
* FALSE if it is not correct, or NULL if no correct choice
* was set
*
* @see ChoiceTable.attempts
* @see ChoiceTable.setCorrectChoice
*/
ChoiceTable.prototype.verifyChoice =