@oat-sa/tao-test-runner-qti
Version:
TAO Test Runner QTI implementation
226 lines (204 loc) • 9.81 kB
JavaScript
define(['jquery', 'ui/scroller', 'ui/keyNavigation/navigator', 'ui/keyNavigation/navigableDomElement', 'taoQtiTest/runner/plugins/content/accessibility/keyNavigation/helpers'], function ($, scrollHelper, keyNavigator, navigableDomElement, helpers) { 'use strict';
$ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $;
scrollHelper = scrollHelper && Object.prototype.hasOwnProperty.call(scrollHelper, 'default') ? scrollHelper['default'] : scrollHelper;
keyNavigator = keyNavigator && Object.prototype.hasOwnProperty.call(keyNavigator, 'default') ? keyNavigator['default'] : keyNavigator;
navigableDomElement = navigableDomElement && Object.prototype.hasOwnProperty.call(navigableDomElement, 'default') ? navigableDomElement['default'] : navigableDomElement;
/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2020 Open Assessment Technologies SA ;
*/
/**
* Add aria-labelledby attribute to choice interaction
*
* @param {Navigator} cursor
*/
const addLabelledByAttribute = cursor => {
const $element = cursor.navigable.getElement();
const value = $element.attr('value');
const name = $element.attr('name');
if (name) {
$element.attr('aria-labelledby', `${name.replace('response-', 'choice-')}-${value}`);
}
};
/**
* Add aria-labelledby attribute from choice interaction
*
* @param {Navigator} cursor
*/
const removeLabelledByAttribute = cursor => {
const $element = cursor.navigable.getElement();
$element.removeAttr('aria-labelledby', '');
};
/**
* Adds attributes on navigation focus and blur
*
* @param {Navigator} navigator
*/
const manageLabelledByAttribute = navigator => {
if (navigator) {
navigator.on('focus', addLabelledByAttribute);
navigator.on('blur', removeLabelledByAttribute); // applies WCAG behavior for the radio buttons
}
};
/**
* Key navigator strategy applying inside the item.
* Navigable item content are interaction choices and body element with the special class "key-navigation-focusable".
* @type {Object} keyNavigationStrategy
*/
var itemNavigation = {
name: 'item',
/**
* Builds the item navigation strategy.
*
* @returns {keyNavigationStrategy}
*/
init() {
var _this = this;
this.keyNavigators = [];
const config = this.getConfig();
const $content = this.getTestRunner().getAreaBroker().getContentArea();
/**
* Gets the QTI choice element from the current position in the keyNavigation
* @param {Object} cursor - The cursor definition supplied by the keyNavigator
* @returns {jQuery} - The selected choice element
*/
const getQtiChoice = function (cursor) {
return cursor && cursor.navigable.getElement().closest('.qti-choice');
};
/**
* Creates and registers a keyNavigator for the supplied list of elements
* @param {jQuery} $elements - The list of navigable elements
* @param {jQuery} group - The group container
* @param {Boolean} [loop=false] - Allow cycling the list when a boundary is reached
* @param {Number|Function} [defaultPosition=0] - The default position the group should set the focus on
* @returns {keyNavigator} - the created navigator, if the list of element is not empty
*/
const addNavigator = function ($elements, group) {
let loop = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
let defaultPosition = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0;
const elements = navigableDomElement.createFromDoms($elements);
if (elements.length) {
const navigator = keyNavigator({
elements,
group,
loop,
defaultPosition,
propagateTab: false
});
_this.keyNavigators.push(navigator);
return navigator;
}
};
/**
* Creates and setups a keyNavigator for the interaction inputs.
* @param {jQuery} $elements - The list of navigable elements
* @param {jQuery} group - The group container
* @param {Boolean} [loop=false] - Allow cycling the list when a boundary is reached
* @param {Number|Function} [defaultPosition=0] - The default position the group should set the focus on
* @returns {keyNavigator} - The supplied keyNavigator
*/
const addInputsNavigator = function ($elements, group, loop) {
let defaultPosition = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0;
const navigator = addNavigator($elements, group, loop, defaultPosition);
if (navigator) {
helpers.setupItemsNavigator(navigator, config);
helpers.setupClickableNavigator(navigator);
// each choice is represented by more than the input, the style must be spread to the actual element
navigator.on('focus', cursor => scrollHelper.scrollTo(getQtiChoice(cursor).addClass('key-navigation-highlight'), $content.closest('.content-wrapper'))).on('blur', cursor => getQtiChoice(cursor).removeClass('key-navigation-highlight'));
}
return navigator;
};
// list the navigable areas inside the item. This could be either the interactions choices or the prompts
const $qtiInteractions = $content.find('.key-navigation-focusable,.qti-interaction')
//filter out interaction as it will be managed separately
.filter((i, node) => !$(node).parents('.qti-interaction').length);
// the item focusable body elements are considered scrollable
$content.find('.key-navigation-focusable').addClass('key-navigation-scrollable');
// each navigable area will get its own keyNavigator
$qtiInteractions.each((itemPos, itemElement) => {
const $itemElement = $(itemElement);
// detect the type of choices: checkbox or radio
const $choiceInput = $itemElement.find('.qti-choice input');
const choiceType = $choiceInput.attr('type');
if ($itemElement.hasClass('qti-interaction')) {
//interaction block may be scrollable (if writing-mode for interaction is different from writing-mode for item)
if ($itemElement.hasClass('key-navigation-focusable')) {
addNavigator($itemElement, $itemElement);
}
//add navigable elements from prompt
$itemElement.find('.key-navigation-focusable').each((navPos, nav) => {
const $nav = $(nav);
if (!$nav.closest('.qti-choice').length) {
addNavigator($nav, $nav);
}
});
//reset interaction custom key navigation to override the behaviour with the new one
$itemElement.off('.keyNavigation');
//search for inputs that represent the interaction focusable choices
const $inputs = $itemElement.is(':input') ? $itemElement : $itemElement.find(':input');
if (config.flatNavigation && (config.flatRadioNavigation || choiceType !== 'radio')) {
$inputs.each((i, input) => {
const navigator = addInputsNavigator($(input), $itemElement);
manageLabelledByAttribute(navigator);
});
} else {
const navigator = addInputsNavigator($inputs, $itemElement, true, () => {
// keep default positioning for now
let position = -1;
// autofocus the selected radio button if any
$inputs.each((index, input) => {
if (input.checked) {
position = index;
}
});
return position;
});
manageLabelledByAttribute(navigator);
// applies WCAG behavior for the radio buttons
if (navigator && config.wcagBehavior) {
navigator.on('focus', cursor => {
const $element = cursor.navigable.getElement();
if (!$element.is(':checked')) {
$element.click();
}
});
}
}
} else {
addNavigator($itemElement, $itemElement);
}
});
return this;
},
/**
* Gets the list of applied navigators
* @returns {keyNavigator[]}
*/
getNavigators() {
return this.keyNavigators;
},
/**
* Tears down the keyNavigator strategy
* @returns {keyNavigationStrategy}
*/
destroy() {
this.keyNavigators.forEach(navigator => navigator.destroy());
this.keyNavigators = [];
return this;
}
};
return itemNavigation;
});