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',