diff --git a/src/accessibility/describe.js b/src/accessibility/describe.js index 1d5511cbdf..35a0739705 100644 --- a/src/accessibility/describe.js +++ b/src/accessibility/describe.js @@ -19,12 +19,12 @@ function describe(p5, fn) { * * The first parameter, `text`, is the description of the canvas. * - * The second parameter, `display`, is optional. It determines how the - * description is displayed. If `LABEL` is passed, as in - * `describe('A description.', LABEL)`, the description will be visible in - * a div element next to the canvas. If `FALLBACK` is passed, as in - * `describe('A description.', FALLBACK)`, the description will only be - * visible to screen readers. This is the default mode. + * The second parameter, `display`, is optional and can be `LABEL` or + * `FALLBACK`. The third parameter, `lang`, is optional and sets the language + * of the description for screen readers. However, if only the optional 'lang' + * parameter is passed, it will be treated as the second optional parameter and + * the default option 'FALLBACK' will be used as the display. + * * * Read * Writing accessible canvas descriptions @@ -33,6 +33,7 @@ function describe(p5, fn) { * @method describe * @param {String} text description of the canvas. * @param {(FALLBACK|LABEL)} [display] either LABEL or FALLBACK. + * @param {String} [lang] valid lang attribute. * * @example * function setup() { @@ -110,7 +111,7 @@ function describe(p5, fn) { * describe(`A green circle at (${x}, 50) moves from left to right on a gray square.`, LABEL); * } */ - fn.describe = function (text, display) { + fn.describe = function (text, display, lang) { // p5._validateParameters('describe', arguments); if (typeof text !== 'string') { return; @@ -122,6 +123,7 @@ function describe(p5, fn) { if (!this.dummyDOM) { this.dummyDOM = document.getElementById(cnvId).parentNode; } + //check if html structure for description is ready if (!this.descriptions) { this.descriptions = {}; } @@ -150,6 +152,7 @@ function describe(p5, fn) { this._describeHTML('label', text); } } + _setDescriptionLang(this, lang); }; /** @@ -161,15 +164,18 @@ function describe(p5, fn) { * The first parameter, `name`, is the name of the element. * * The second parameter, `text`, is the description of the element. - * - * The third parameter, `display`, is optional. It determines how the - * description is displayed. If `LABEL` is passed, as in - * `describe('A description.', LABEL)`, the description will be visible in - * a div element next to the canvas. Using `LABEL` creates unhelpful - * duplicates for screen readers. Only use `LABEL` during development. If - * `FALLBACK` is passed, as in `describe('A description.', FALLBACK)`, the - * description will only be visible to screen readers. This is the default - * mode. + * + * The third parameter, `display`, is optional and can be `LABEL` or + * `FALLBACK`. The fourth parameter, `lang`, is optional and sets the language + * of the description for screen readers. However, if only the optional 'lang' + * parameter is passed, it will be treated as the third optional parameter and + * the default option 'FALLBACK' will be used as the display. + * + * If `LABEL` is passed, as in `describe('A description.', LABEL)`, + * the description will be visible in a div element next to the canvas. Using + * `LABEL` creates unhelpful duplicates for screen readers. Only use `LABEL` + * during development. If `FALLBACK` is passed, as in `describe('A description.', FALLBACK)`, + * the description will only be visible to screen readers. This is the default mode. * * Read * Writing accessible canvas descriptions @@ -179,7 +185,7 @@ function describe(p5, fn) { * @param {String} name name of the element. * @param {String} text description of the element. * @param {(FALLBACK|LABEL)} [display] either LABEL or FALLBACK. - * + * @param {String} [lang] valid lang attribute. * @example * function setup() { * background('pink'); @@ -229,7 +235,7 @@ function describe(p5, fn) { * } */ - fn.describeElement = function (name, text, display) { + fn.describeElement = function (name, text, display, lang) { // p5._validateParameters('describeElement', arguments); if (typeof text !== 'string' || typeof name !== 'string') { return; @@ -242,8 +248,11 @@ function describe(p5, fn) { //remove any special characters from name to use it as html id name = name.replace(/[^a-zA-Z0-9]/g, ''); + // Inject lang attribute + let langAttr = typeof lang === 'string' ? ` lang="${lang}"` : ''; + //store element description - let inner = `${elementName}${text}`; + let inner = `${elementName}${text}`; //if there is no dummyDOM if (!this.dummyDOM) { this.dummyDOM = document.getElementById(cnvId).parentNode; @@ -281,14 +290,96 @@ function describe(p5, fn) { this._describeElementHTML('label', name, inner); } } + _setDescriptionLang(this, lang); }; + p5.registerDecorator('p5.prototype.describe', function (target) { + return function (text, display, lang) { + // Checks for optional parameters and rearranges if needed + if (arguments.length === 2 && typeof display === 'string') { + // (text, display) + if (display === this.LABEL || display === this.FALLBACK) { + return target.call(this, text, display, undefined); + } + // (text, lang) + return target.call(this, text, undefined, display); + } + if (!_checkDescriptionOptions(this, 'describe', display, lang)) { + return; + } + // (text, display, lang) or just (text) + return target.call(this, text, display, lang); + }; + }); + + p5.registerDecorator('p5.prototype.describeElement', function (target) { + return function (name, text, display, lang) { + // Checks for optional parameters and rearranges if needed + if (arguments.length === 3 && typeof display === 'string') { + // (name, text, display) + if (display === this.LABEL || display === this.FALLBACK) { + return target.call(this, name, text, display, undefined); + } + // (name, text, lang) + return target.call(this, name, text, undefined, display); + } + if (!_checkDescriptionOptions(this, 'describeElement', display, lang)) { + return; + } + // (name, text, display, lang) or just (name, text) + return target.call(this, name, text, display, lang); + }; + }); + /* * * Helper functions for describe() and describeElement(). * */ + function _checkDescriptionOptions(pInst, methodName, display, lang) { + // Ensures display and lang are in the correct orders and are valid + const validDisplay = display === undefined || display === pInst.LABEL || display === pInst.FALLBACK; + const validLanguage = lang === undefined || typeof lang === 'string'; + + if (!validDisplay || !validLanguage) { + p5._friendlyError( + `${methodName}() expects display (LABEL or FALLBACK) before lang.`, + methodName + ); + return false; + } + return true; + } + + function _setDescriptionLang(pInst, lang) { + const canvas = pInst.canvas.elt || pInst.elt || pInst.canvas; + if (typeof lang === 'string') { + canvas.setAttribute('lang', lang); + } + else { + canvas.removeAttribute('lang'); + } + + if (pInst.descriptions.fallback) { + if (typeof lang === 'string') { + pInst.descriptions.fallback.setAttribute('lang', lang); + } + else { + pInst.descriptions.fallback.removeAttribute('lang'); + } + } + + if (pInst.descriptions.label) { + if (typeof lang === 'string') { + pInst.descriptions.label.setAttribute('lang', lang); + } + else { + pInst.descriptions.label.removeAttribute('lang'); + } + } + } + // check that text is not LABEL or FALLBACK and ensure text ends with punctuation mark function _descriptionText(text) { if (text === 'label' || text === 'fallback') { diff --git a/test/manual-test-examples/accessibility/describe/index.html b/test/manual-test-examples/accessibility/describe/index.html new file mode 100644 index 0000000000..586c215bae --- /dev/null +++ b/test/manual-test-examples/accessibility/describe/index.html @@ -0,0 +1,46 @@ + + + + + + + Accessibility: describe language + + + + + + + +
+

Accessibility: describe() language test

+
+ +
+

Read each region with a screen reader. Compare regions without a lang attribute with those that have one.

+ +
+

this is a test

+
+
+

this is a test

+
+
+

esto es una prueba

+
+
+

esto es una prueba

+
+
+

Cái này là bài thi

+
+
+

Cái này là bài thi

+
+ +

p5.js generated descriptions

+

Read the descriptions generated by the canvases below. The visible LABEL descriptions are included for inspection.

+
+ + + diff --git a/test/manual-test-examples/accessibility/describe/sketch.js b/test/manual-test-examples/accessibility/describe/sketch.js new file mode 100644 index 0000000000..e655653a74 --- /dev/null +++ b/test/manual-test-examples/accessibility/describe/sketch.js @@ -0,0 +1,40 @@ +function setup() { + // Uncomment any of the following lines to test + + createCanvas(400, 400); + + // Text only (fallback is the default) + // describe('Cái này là bài thi'); + + // Text and display + // describe('Esto es una prueba', LABEL); + + // Text and lang + //describe('Esto es una prueba', 'es'); + + // Text, display, and lang + // describe('Cái này là bài thi', LABEL, 'vi'); + + // Text and lang + // describe('यह टेस्ट है', 'hi'); + + + + // Name and text only (fallback is the default) + // describeElement('a', 'Cái này là bài thi'); + + // Name, text, and display + // describeElement('b', 'Esto es una prueba', LABEL); + + // Name, text, and lang + // describeElement('c', 'Esto es una prueba', 'es'); + + // Name, text, display, and lang + // describeElement('d', 'Cái này là bài thi', LABEL, 'vi'); + + // Name, text, and lang + // describeElement('e', 'यह टेस्ट है', 'hi'); + + fill('blue'); + circle(200, 150, 100); +} \ No newline at end of file diff --git a/test/unit/accessibility/describe.js b/test/unit/accessibility/describe.js index 427aa16ce0..776f2cd03e 100644 --- a/test/unit/accessibility/describe.js +++ b/test/unit/accessibility/describe.js @@ -5,12 +5,30 @@ suite('describe', function () { const myID = 'myCanvasID'; beforeAll(function () { + mockP5.registerDecorator = (patterns, decorator) => { + const patternList = Array.isArray(patterns) ? patterns : [patterns]; + patternList.forEach(pattern => { + const name = pattern.split('.').pop(); + mockP5Prototype[name] = decorator(mockP5Prototype[name], { name }); + }); + }; describe(mockP5, mockP5Prototype); mockP5Prototype.LABEL = 'label'; mockP5Prototype.FALLBACK = 'fallback'; }); + beforeEach(function () { + document.querySelectorAll('[id^="' + myID + '_"]').forEach(element => { + element.remove(); + }); + mockP5Prototype.elt.innerHTML = ''; + mockP5Prototype.elt.removeAttribute('lang'); + mockP5Prototype.dummyDOM = undefined; + mockP5Prototype.descriptions = undefined; + mockP5._friendlyError.mockReset(); + }); + suite('p5.prototype.describe', function () { test('should be a function', function () { assert.ok(mockP5Prototype.describe); @@ -27,12 +45,52 @@ suite('describe', function () { ); }); - test('should create description as fallback', function () { + test('should create description as FALLBACK', function () { mockP5Prototype.describe('a'); let actual = document.getElementById(myID + '_fallbackDesc'); assert.deepEqual(actual.innerHTML, 'a.'); }); + test('should support text and lang', function () { + mockP5Prototype.describe('Chào các bạn', 'vi'); + let actual = document.getElementById(myID + '_fallbackDesc'); + assert.deepEqual(actual.innerHTML, 'Chào các bạn.'); + assert.deepEqual(actual.getAttribute('lang'), 'vi'); + assert.deepEqual(mockP5Prototype.elt.getAttribute('lang'), 'vi'); + assert.isNull(document.getElementById(myID + '_Label')); + }); + + test('should support text and display', function () { + mockP5Prototype.describe('Texto visible', mockP5Prototype.LABEL); + assert.equal( + document.getElementById(myID + '_labelDesc').innerHTML, + 'Texto visible.' + ); + assert.isNull(mockP5Prototype.elt.getAttribute('lang')); + }); + + test('should support text lang and display', function () { + mockP5Prototype.describe('Chào các bạn', mockP5Prototype.FALLBACK, 'vi'); + let actual = document.getElementById(myID + '_fallbackDesc'); + assert.deepEqual(actual.innerHTML, 'Chào các bạn.'); + assert.deepEqual(actual.getAttribute('lang'), 'vi'); + assert.isNull(document.getElementById(myID + '_Label')); + }); + + test('should support text display and lang', function () { + mockP5Prototype.describe('Chào các bạn', mockP5Prototype.LABEL, 'vi'); + let actual = document.getElementById(myID + '_labelDesc'); + assert.deepEqual(actual.innerHTML, 'Chào các bạn.'); + assert.deepEqual(actual.getAttribute('lang'), 'vi'); + }); + + test('should reject language before display', function () { + mockP5Prototype.describe('Chào các bạn', 'vi', mockP5Prototype.LABEL); + assert.isNull(document.getElementById(myID + '_fallbackDesc')); + assert.lengthOf(mockP5._friendlyError.mock.calls, 1); + assert.equal(mockP5._friendlyError.mock.calls[0][1], 'describe'); + }); + test('should not add extra period if string ends in "."', function () { mockP5Prototype.describe('A.'); let actual = document.getElementById(myID + '_fallbackDesc'); @@ -104,6 +162,46 @@ suite('describe', function () { assert.deepEqual(actual, 'az:b.'); }); + test('should support element text and lang', function () { + mockP5Prototype.describeElement('aj', 'Chào các bạn', 'vi'); + let actual = document.getElementById(myID + '_fte_aj').innerHTML; + assert.deepEqual(actual, 'aj:Chào các bạn.' + ); + }); + + test('should support element text and display', function () { + mockP5Prototype.describeElement('ao', 'Texto visible', mockP5Prototype.LABEL); + assert.equal( + document.getElementById(myID + '_lte_ao').innerHTML, + 'ao:Texto visible.' + ); + }); + + test('should support element text lang and display', function () { + mockP5Prototype.describeElement('al', 'Chào các bạn', mockP5Prototype.FALLBACK, 'vi'); + let actual = document.getElementById(myID + '_fte_al').innerHTML; + assert.deepEqual(actual, 'al:Chào các bạn.' + ); + }); + + test('should support element text display and lang', function () { + mockP5Prototype.describeElement('am', 'Chào các bạn', mockP5Prototype.LABEL, 'vi'); + let actual = document.getElementById(myID + '_lte_am').innerHTML; + assert.deepEqual(actual, 'am:Chào các bạn.'); + }); + + test('should reject element language before display', function () { + mockP5Prototype.describeElement( + 'an', + 'Chào các bạn', + 'vi', + mockP5Prototype.LABEL + ); + assert.isNull(document.getElementById(myID + '_fte_an')); + assert.lengthOf(mockP5._friendlyError.mock.calls, 1); + assert.equal(mockP5._friendlyError.mock.calls[0][1], 'describeElement'); + }); + test('should not add extra ":" if element name ends in colon', function () { mockP5Prototype.describeElement('ab:', 'b.'); let actual = document.getElementById(myID + '_fte_ab').innerHTML;