diff --git a/src/accessibility/describe.js b/src/accessibility/describe.js new file mode 100644 index 0000000000..4bcbf04d1d --- /dev/null +++ b/src/accessibility/describe.js @@ -0,0 +1,444 @@ +/** + * @module Environment + * @submodule Environment + * @for p5 + * @requires core + */ + +import p5 from '../core/main'; +const descContainer = '_Description'; //Fallback container +const fallbackDescId = '_fallbackDesc'; //Fallback description +const fallbackTableId = '_fallbackTable'; //Fallback Table +const fallbackTableElId = '_fte_'; //Fallback Table Element +const labelContainer = '_Label'; //Label container +const labelDescId = '_labelDesc'; //Label description +const labelTableId = '_labelTable'; //Label Table +const labelTableElId = '_lte_'; //Label Table Element +//dummy stores a copy of the DOM and previous descriptions +let dummy = { fallbackElements: {}, labelElements: {} }; + +/** + * Creates a screen-reader accessible description for the canvas. + * The first parameter should be a string with a description of the canvas. + * The second parameter is optional. If specified, it determines how the + * description is displayed. + * + * describe(text, LABEL) displays + * the description to all users as a + * tombstone or exhibit label/caption in a + * <div class="p5Label"></div> + * adjacent to the canvas. You can style it as you wish in your CSS. + * + * describe(text, FALLBACK) makes the + * description accessible to screen-reader users only, in + * + * a sub DOM inside the canvas element. If a second parameter is not + * specified, by default, the description will only be available to + * screen-reader users. + * + * @method describe + * @param {String} text description of the canvas + * @param {Constant} [display] either LABEL or FALLBACK (Optional) + * + * @example + *
+ * + * describe('pink square with red heart in the bottom right corner', LABEL); + * background('pink'); + * fill('red'); + * noStroke(); + * ellipse(67, 67, 20, 20); + * ellipse(83, 67, 20, 20); + * triangle(91, 73, 75, 95, 59, 73); + * + *
+ * + *
+ * + * let x = 0; + * function draw() { + * if (x > 100) { + * x = 0; + * } + * background(220); + * fill(0, 255, 0); + * ellipse(x, 50, 40, 40); + * x = x + 0.1; + * describe('green circle at x pos ' + round(x) + ' moving to the right'); + * } + * + *
+ * + */ + +p5.prototype.describe = function(text, display) { + p5._validateParameters('describe', arguments); + if (typeof text !== 'string') { + return; + } + const cnvId = this.canvas.id; + //calls function that adds punctuation for better screen reading + text = _descriptionText(text); + + //if it is the first time describe() is called + if (!dummy[cnvId + 'fallbackDesc'] || !dummy[cnvId + 'labelDesc']) { + //store copy of body dom in dummy + _populateDummyDOM(cnvId); + } + + //check if text is different + if (dummy[cnvId + 'fallbackDesc'] !== text) { + //if html structure for description is ready + if (dummy[cnvId + 'updateFallbackDesc']) { + //update description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + fallbackDescId + ).innerHTML = text; + //store updated description + dummy[cnvId + 'fallbackDesc'] = text; + } else { + //create fallback html structure + _describeFallbackHTML(cnvId, text); + } + } + //if display is LABEL and label text is different + if (display === this.LABEL && dummy[cnvId + 'labelDesc'] !== text) { + //if html structure for label is ready + if (dummy[cnvId + labelDescId]) { + //update label description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + labelDescId + ).innerHTML = text; + //store updated label description + dummy[cnvId + 'labelDesc'] = text; + } else { + //create label html structure + _describeLabelHTML(cnvId, text); + } + } +}; + +/** + * This function creates a screen-reader accessible + * description for elements —shapes or groups of shapes that create + * meaning together— in the canvas. The first paramater should + * be the name of the element. The second parameter should be a string + * with a description of the element. The third parameter is optional. + * If specified, it determines how the element description is displayed. + * + * describeElement(name, text, LABEL) + * displays the element description to all users as a + * + * tombstone or exhibit label/caption in a + * <div class="p5Label"></div> + * adjacent to the canvas. You can style it as you wish in your CSS. + * + * describeElement(name, text, FALLBACK) + * makes the element description accessible to screen-reader users + * only, in + * a sub DOM inside the canvas element. If a second parameter is not + * specified, by default, the element description will only be available + * to screen-reader users. + * + * @method describeElement + * @param {String} name name of the element + * @param {String} text description of the element + * @param {Constant} [display] either LABEL or FALLBACK (Optional) + * + * @example + *
+ * + * describe('Heart and yellow circle over pink background', LABEL); + * noStroke(); + * background('pink'); + * describeElement('Circle', 'Yellow circle in the top left corner', LABEL); + * fill('yellow'); + * ellipse(25, 25, 40, 40); + * describeElement('Heart', 'red heart in the bottom right corner', LABEL); + * fill('red'); + * ellipse(66.6, 66.6, 20, 20); + * ellipse(83.2, 66.6, 20, 20); + * triangle(91.2, 72.6, 75, 95, 58.6, 72.6); + * + *
+ */ + +p5.prototype.describeElement = function(name, text, display) { + p5._validateParameters('describeElement', arguments); + if (typeof text !== 'string' || typeof name !== 'string') { + return; + } + const cnvId = this.canvas.id; + //calls function that adds punctuation for better screen reading + text = _descriptionText(text); + //calls function that adds punctuation for better screen reading + let elementName = _elementName(name); + //remove any special characters from name to use it as html id + name = name.replace(/[^a-zA-Z0-9 ]/g, ''); + //store element description + let inner = `${elementName}${text}`; + + //if it is the first time describeElement() is called + if ( + !dummy.fallbackElements[cnvId + name] || + !dummy.labelElements[cnvId + name] + ) { + //store copy of body dom in dummy + _populateDummyDOM(cnvId); + } + + //check if element description is different from current + if (dummy.fallbackElements[cnvId + name] !== inner) { + //if html structure for element description is ready + if (dummy.fallbackElements[cnvId + name]) { + //update element description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + fallbackTableElId + name + ).innerHTML = inner; + //store updated element description + dummy.fallbackElements[cnvId + name] = inner; + } else { + //create fallback html structure + _descElementFallbackHTML(cnvId, name, inner); + } + } + //if display is LABEL and label element description is different + if (display === this.LABEL && dummy.labelElements[cnvId + name] !== inner) { + //if html structure for label element description is ready + if (dummy.labelElements[cnvId + name]) { + //update label element description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + labelTableElId + name + ).innerHTML = inner; + //store updated label element description + dummy.labelElements[cnvId + name] = inner; + } else { + //create label element html structure + _descElementLabelHTML(cnvId, name, inner); + } + } +}; + +/* + * + * Helper functions for describe() and describeElement(). + * + */ + +//clear dummy +p5.prototype._clearDummy = function() { + dummy = { fallbackElements: {}, labelElements: {} }; +}; + +//stores html body in dummy +function _populateDummyDOM(cnvId) { + dummy[cnvId + 'DOM'] = document.getElementsByTagName('body')[0]; +} + +// check that text is not LABEL or FALLBACK and ensure text ends with punctuation mark +function _descriptionText(text) { + if (text === 'label' || text === 'fallback') { + throw new Error('description should not be LABEL or FALLBACK'); + } + //if string does not end with '.' + if ( + !text.endsWith('.') && + !text.endsWith(',') && + !text.endsWith('?') && + !text.endsWith('!') + ) { + //add '.' to the end of string + text = text + '.'; + } + return text; +} + +/* + * Helper functions for describe() + */ + +//creates fallback HTML structure +function _describeFallbackHTML(cnvId, text) { + //if there is no description container + if (!dummy[cnvId + descContainer]) { + //create description container +

for fallback description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + ).innerHTML = `

`; + //set container and fallbackDescId to true + dummy[cnvId + descContainer] = true; + dummy[cnvId + fallbackDescId] = true; + //if describeElement() has already created the container and added a table of elements + } else if (dummy[cnvId + fallbackTableId]) { + //create fallback description

before the table + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + fallbackTableId) + .insertAdjacentHTML( + 'beforebegin', + `

` + ); + //set fallbackDescId to true + dummy[cnvId + fallbackDescId] = true; + } + //If the container for the description exists + if (dummy[cnvId + 'DOM'].querySelector('#' + cnvId + fallbackDescId)) { + //update description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + fallbackDescId + ).innerHTML = text; + //store updated description + dummy[cnvId + 'fallbackDesc'] = text; + //html structure is ready for any description updates + dummy[cnvId + 'updateFallbackDesc'] === true; + } + return; +} + +//If display is LABEL create a div adjacent to the canvas element with +//description text. +function _describeLabelHTML(cnvId, text) { + //if there is no label container + if (!dummy[cnvId + labelContainer]) { + //create label container +

for label description + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId) + .insertAdjacentHTML( + 'afterend', + `

` + ); + //set container and labelDescId to true + dummy[cnvId + labelContainer] = true; + dummy[cnvId + labelDescId] = true; + //if describeElement() has already created the container and added a table of elements + } else if (!dummy[cnvId + labelDescId] && dummy[cnvId + labelTableId]) { + //create label description

before the table + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + labelTableId) + .insertAdjacentHTML('beforebegin', `

`); + //set fallbackDescId to true + dummy[cnvId + labelDescId] = true; + } + //update description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + labelDescId + ).innerHTML = text; + //store updated description + dummy[cnvId + 'labelDesc'] = text; + return; +} + +/* + * Helper functions for describeElement(). + */ + +//check that name is not LABEL or FALLBACK and ensure text ends with colon +function _elementName(name) { + if (name === 'label' || name === 'fallback') { + throw new Error('element name should not be LABEL or FALLBACK'); + } + //check if last character of string n is '.', ';', or ',' + if (name.endsWith('.') || name.endsWith(';') || name.endsWith(',')) { + //replace last character with ':' + name = name.replace(/.$/, ':'); + //if string n does not end with ':' + } else if (!name.endsWith(':')) { + //add ':'' at the end of string + name = name + ':'; + } + return name; +} + +//creates fallback HTML structure for element descriptions +function _descElementFallbackHTML(cnvId, name, inner) { + //if there is no description container + if (!dummy[cnvId + descContainer]) { + //create container + table for element descriptions + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + ).innerHTML = `
Canvas elements and their descriptions
`; + //set container and fallbackTableId to true + dummy[cnvId + descContainer] = true; + dummy[cnvId + fallbackTableId] = true; + //if describe() has already created the container and added a description + } else if (document.getElementById(cnvId + fallbackDescId)) { + //create fallback table for element description after fallback description + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + fallbackDescId) + .insertAdjacentHTML( + 'afterend', + `
Canvas elements and their descriptions
` + ); + //set fallbackTableId to true + dummy[cnvId + fallbackTableId] = true; + } + //if it is the first time this element is being added to the table + if (!dummy.fallbackElements[cnvId + name] && dummy[cnvId + fallbackTableId]) { + //create a table row for the element + let tableRow = document.createElement('tr'); + tableRow.id = cnvId + fallbackTableElId + name; + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + fallbackTableId) + .appendChild(tableRow); + //update element description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + fallbackTableElId + name + ).innerHTML = inner; + //store updated element description + dummy.fallbackElements[cnvId + name] = inner; + } +} +//If display is LABEL creates a div adjacent to the canvas element with +//a table, a row header cell with the name of the elements, +//and adds the description of the element in adjecent cell. +function _descElementLabelHTML(cnvId, name, inner) { + //if there is no label description container + if (!dummy[cnvId + labelContainer]) { + //create container + table for element descriptions + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId) + .insertAdjacentHTML( + 'afterend', + `
` + ); + //set container and labelTableId to true + dummy[cnvId + labelContainer] = true; + dummy[cnvId + labelTableId] = true; + //if describe() has already created the label container and added a description + } else if (dummy[cnvId + 'DOM'].querySelector('#' + cnvId + labelDescId)) { + //create label table for element description after label description + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + labelDescId) + .insertAdjacentHTML( + 'afterend', + `
` + ); + //set labelTableId to true + dummy[cnvId + labelTableId] = true; + } + //if it is the first time this element is being added to the table + if (!dummy.labelElements[cnvId + name] && dummy[cnvId + labelTableId]) { + //create a table row for the element label description + let tableRow = document.createElement('tr'); + tableRow.id = cnvId + labelTableElId + name; + dummy[cnvId + 'DOM'] + .querySelector('#' + cnvId + labelTableId) + .appendChild(tableRow); + //update element label description + dummy[cnvId + 'DOM'].querySelector( + '#' + cnvId + labelTableElId + name + ).innerHTML = inner; + //store updated element label description + dummy.labelElements[cnvId + name] = inner; + } +} + +export default p5; diff --git a/src/app.js b/src/app.js index 21188e14a9..a5022b200e 100644 --- a/src/app.js +++ b/src/app.js @@ -35,6 +35,9 @@ import './data/local_storage.js'; // DOM import './dom/dom'; +// accessibility +import './accessibility/describe'; + // events import './events/acceleration'; import './events/keyboard'; diff --git a/src/core/constants.js b/src/core/constants.js index 24d12a07fb..54cc75487a 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -696,3 +696,14 @@ export const GRID = 'grid'; * @final */ export const AXES = 'axes'; + +/** + * @property {String} LABEL + * @final + */ +export const LABEL = 'label'; +/** + * @property {String} FALLBACK + * @final + */ +export const FALLBACK = 'fallback'; diff --git a/test/unit/accessibility/describe.js b/test/unit/accessibility/describe.js new file mode 100644 index 0000000000..96ec6d0d8b --- /dev/null +++ b/test/unit/accessibility/describe.js @@ -0,0 +1,175 @@ +suite('describe', function() { + let myp5; + let myp5Container; + let myID = 'myCanvasID'; + let a = 'a'; + let b = 'b'; + let c = 'c'; + + setup(function(done) { + myp5Container = document.createElement('div'); + document.body.appendChild(myp5Container); + new p5(function(p) { + p.setup = function() { + let cnv = p.createCanvas(100, 100); + cnv.id(myID); + myp5 = p; + done(); + }; + }, myp5Container); + }); + + teardown(function() { + myp5._clearDummy(); + myp5.remove(); + if (myp5Container && myp5Container.parentNode) { + myp5Container.parentNode.removeChild(myp5Container); + } + p5Container = null; + }); + + suite('p5.prototype.describe', function() { + let expected = 'a.'; + test('should be a function', function() { + assert.ok(myp5.describe); + assert.typeOf(myp5.describe, 'function'); + }); + test('should create description as fallback', function() { + myp5.describe(a); + let actual = document.getElementById(myID + '_fallbackDesc').innerHTML; + assert.deepEqual(actual, expected); + }); + test('should not add extra period if string ends in "."', function() { + myp5.describe('a.'); + let actual = document.getElementById(myID + '_fallbackDesc').innerHTML; + assert.deepEqual(actual, expected); + }); + test.skip('should not add period if string ends in "!" or "?', function() { + myp5.describe('A!'); + let actual = document.getElementById(myID + '_fallbackDesc').innerHTML; + if (actual === 'A!') { + myp5.describe('A?'); + + actual = document.getElementById(myID + '_fallbackDesc').innerHTML; + assert.deepEqual(actual, 'A?'); + } + }); + test('should create description when called after describeElement()', function() { + myp5.describeElement(b, c); + myp5.describe(a); + + let actual = document.getElementById(myID + '_fallbackDesc').innerHTML; + assert.deepEqual(actual, expected); + }); + test('should create Label adjacent to canvas', function() { + myp5.describe(a, myp5.LABEL); + + let actual = document.getElementById(myID + '_labelDesc').innerHTML; + assert.deepEqual(actual, expected); + }); + test('should create Label adjacent to canvas when label of element already exists', function() { + myp5.describeElement(b, c, myp5.LABEL); + myp5.describe(a, myp5.LABEL); + + let actual = document.getElementById(myID + '_labelDesc').innerHTML; + assert.deepEqual(actual, expected); + }); + test('wrong param type at #0', function() { + assert.validationError(function() { + myp5.describe(1, myp5.LABEL); + }); + }); + test('no params', function() { + assert.validationError(function() { + myp5.describe(); + }); + }); + test('err when LABEL at param #0', function() { + assert.throws( + function() { + myp5.describe(myp5.LABEL); + }, + Error, + 'description should not be LABEL or FALLBACK' + ); + }); + }); + + suite('p5.prototype.describeElement', function() { + let expected = 'a:b.'; + test('should be a function', function() { + assert.ok(myp5.describeElement); + assert.typeOf(myp5.describeElement, 'function'); + }); + test('should create element description as fallback', function() { + myp5.describeElement(a, b); + let actual = document.getElementById(myID + '_fte_' + a).innerHTML; + assert.deepEqual(actual, expected); + }); + test('should not add extra ":" if element name ends in colon', function() { + myp5.describeElement('a:', 'b.'); + let actual = document.getElementById(myID + '_fte_a').innerHTML; + assert.deepEqual(actual, expected); + }); + test('should replace ";", ",", "." for ":" in element name', function() { + let actual; + myp5.describeElement('a;', 'b.'); + if (document.getElementById(myID + '_fte_a').innerHTML === expected) { + myp5.describeElement('a,', 'b.'); + if (document.getElementById(myID + '_fte_a').innerHTML === expected) { + myp5.describeElement('a.', 'b.'); + actual = document.getElementById(myID + '_fte_a').innerHTML; + assert.deepEqual(actual, expected); + } + } + }); + test('should create element description when called after describe()', function() { + myp5.describe(c); + myp5.describeElement(a, b); + + let actual = document.getElementById(myID + '_fte_' + a).innerHTML; + assert.deepEqual(actual, expected); + }); + test('should create element label adjacent to canvas', function() { + myp5.describeElement(a, b, myp5.LABEL); + + const actual = document.getElementById(myID + '_lte_' + a).innerHTML; + assert.deepEqual(actual, expected); + }); + test('should create element label adjacent to canvas when called after describe()', function() { + myp5.describe(c, myp5.LABEL); + myp5.describeElement(a, b, myp5.LABEL); + + const actual = document.getElementById(myID + '_lte_' + a).innerHTML; + assert.deepEqual(actual, expected); + }); + test('wrong param type at #0 and #1', function() { + assert.validationError(function() { + myp5.describeElement(1, 2); + }); + }); + test('no params', function() { + assert.validationError(function() { + myp5.describeElement(); + }); + }); + test('err when LABEL at param #0', function() { + assert.throws( + function() { + myp5.describeElement(myp5.LABEL, b); + }, + Error, + 'element name should not be LABEL or FALLBACK' + ); + }); + test('err when LABEL at param #1', function() { + assert.throws( + function() { + myp5.describeElement(a, myp5.LABEL); + }, + Error, + 'description should not be LABEL or FALLBACK' + ); + }); + }); +}); diff --git a/test/unit/spec.js b/test/unit/spec.js index 4bfd8f6f90..c632632d75 100644 --- a/test/unit/spec.js +++ b/test/unit/spec.js @@ -1,4 +1,5 @@ var spec = { + accessibility: ['describe'], color: ['color_conversion', 'creating_reading', 'p5.Color', 'setting'], core: [ '2d_primitives',