diff --git a/js/accessibility/Accessibility.js b/js/accessibility/Accessibility.js index e89733d9a..b56c15151 100644 --- a/js/accessibility/Accessibility.js +++ b/js/accessibility/Accessibility.js @@ -360,6 +360,17 @@ define( function( require ) { // {Array.} this._nodesThatAreAriaLabelledByThisNode = []; + // @private {Array.} - Keep track of what this Node is aria-descripbedby via "associationObjects" + // see addAriaDescribedbyAssociation for why we support more than one association. + this._ariaDescribedbyAssociations = []; + + // Keep a reference to all nodes that are aria-describedby this node, i.e. that have store one of this Node's + // peer HTMLElement's id in their peer HTMLElement's aria-describedby attribute. This way we can tell other + // nodes to update their aria-describedby associations when this Node rebuilds its accessible content. + // @public (scenery-internal) - only used by Accessibility.js and invalidateAccessibleContent.js + // {Array.} + this._nodesThatAreAriaDescribedByThisNode = []; + // @private {?boolean} - whether or not this node's DOM element has been explicitly set to receive focus from // tab navigation. Sets the tabIndex attribute on the node's DOM element. Setting to false will not remove the // node's DOM from the document, but will ensure that it cannot receive focus by pressing 'tab'. Several @@ -1353,25 +1364,92 @@ define( function( require ) { // this node to restore the association appropriately, see invalidateAccessibleContent for implementation. associationObject.otherNode._nodesThatAreAriaLabelledByThisNode.push( this ); - // update the accessiblePeers with this aria-labelledby association - this.addAriaLabelledbyAssociationImplementation( associationObject ); + this.addAssociationImplementationForAttribute( 'aria-labelledby', associationObject ); + }, + + + /** + * Add an aria-describedby association to this node. The data in the associationObject will be implemented like + * "a peer's HTMLElement of this Node (specified with the string constant stored in `thisElementName`) will have an + * aria-describedby attribute with a value that includes the `otherNode`'s peer HTMLElement's id (specified with + * `otherElementName`)." + * + * There can be more than one association because an aria-describedby attribute's value can be a space separated + * list of HTML ids, and not just a single id, see https://www.w3.org/WAI/GL/wiki/Using_aria-labelledby_to_concatenate_a_label_from_several_text_nodes + * + * @param {Object} associationObject - with key value pairs like + * { otherNode: {Node}, otherElementName: {string}, thisElementName: {string } } + * see AccessiblePeer for valid element names. + */ + addAriaDescribedbyAssociation: function( associationObject ) { + + this._ariaDescribedbyAssociations.push( associationObject ); // Keep track of this association. + + // Flag that this node is is being described by the other node, so that if the other node changes it can tell + // this node to restore the association appropriately, see invalidateAccessibleContent for implementation. + associationObject.otherNode._nodesThatAreAriaDescribedByThisNode.push( this ); + + + // update the accessiblePeers with this aria-describedby association + this.addAssociationImplementationForAttribute( 'aria-describedby', associationObject ); }, /** - * Implementation for addAriaLabelledbyAssociation. Called in addAriaLabelledByAssociation, as well as + * Update all of the aria-*edby associations for this Node. Depending on the parameter, either aria-labelledby + * or aria-describedby. This involves clearing out the current values of the attribute in the AccessiblePeer, + * to be restored to the value of the state stored by this Node + * + * @param {string} attribute - "aria-labelledby"|"aria-describedby" + * @public (scenery-internal) only used by invalidateAccessibleContent.js + */ + updateAssociationsForAttribute: function( attribute ) { + assert && assert( attribute === 'aria-describedby' || attribute === 'aria-labelledby', 'unsupported attribute name: ' + attribute ); + + // get the proper list of associations depending on what attribute we are updating + var associationList = attribute === 'aria-labelledby' ? this._ariaLabelledbyAssociations : + attribute === 'aria-describedby' ? this._ariaDescribedbyAssociations : null; + + assert && assert( associationList ); // extra safe + + // no-op if there are no associations + if ( associationList.length === 0 ) { + return; + } + + // clear the current aria-labelledby attribute and recreate it from stored associations + // TODO: make this more efficient + this.updateAccessiblePeers( function( peer ) { + peer.primarySibling && peer.primarySibling.removeAttribute( attribute ); + peer.labelSibling && peer.labelSibling.removeAttribute( attribute ); + peer.descriptionSibling && peer.descriptionSibling.removeAttribute( attribute ); + peer.containerParent && peer.containerParent.removeAttribute( attribute ); + } ); + + for ( var i = 0; i < associationList.length; i++ ) { + var associationObject = associationList[ i ]; + + this.addAssociationImplementationForAttribute( attribute, associationObject ); + } + }, + + /** + * Implementation for aria-labelledby and aria-describedby associations. Called in a11y api setters , as well as * invalidateAccessibleContent when we recreate the accessible content for a Node. * - * Update accessible peers with this aria-labelledby association. + * Update accessible peers with this specific attribute association object reference. * - * @param {Object} associationObject - see addAriaLabelledbyAssociation doc + * @param {string} attribute - "aria-labelledby"|"aria-describedby" + * @param {Object} associationObject - see addAriaLabelledbyAssociation or describedby doc + * @private */ - addAriaLabelledbyAssociationImplementation: function( associationObject ) { + addAssociationImplementationForAttribute: function( attribute, associationObject ) { + assert && assert( attribute === 'aria-describedby' || attribute === 'aria-labelledby', 'unsupported attribute name: ' + attribute ); this.updateAccessiblePeers( function( peer ) { // We are just using the first AccessibleInstance for simplicity, but it is OK because the accessible // content for all AccessibleInstances will be the same, so the Accessible Names (in the browser's - // accessibility tree) of elements that are referenced by aria-labelledby will all have the same content + // accessibility tree) of elements that are referenced by the attribute value id will all have the same content var firstAccessibleInstance = associationObject.otherNode.getAccessibleInstances()[ 0 ]; var otherPeerElement = firstAccessibleInstance.peer.getElementByName( associationObject.otherElementName ); @@ -1381,37 +1459,14 @@ define( function( require ) { // NOTE: in the future, we would like to verify that the association exists but can't do that yet because // we have to support cases where we set label association prior to setting the sibling/parent tagName if ( thisPeerElement ) { - var previousAriaLabelledbyValue = thisPeerElement.getAttribute( 'aria-labelledby' ) || ''; - assert && assert( typeof previousAriaLabelledbyValue === 'string' ); + var previousAttributeValue = thisPeerElement.getAttribute( attribute ) || ''; + assert && assert( typeof previousAttributeValue === 'string' ); // add the id from the new association to the value of the HTMLElement's attribute. - thisPeerElement.setAttribute( 'aria-labelledby', [ previousAriaLabelledbyValue, otherPeerElement.id ].join( ' ' ) ); + thisPeerElement.setAttribute( attribute, [ previousAttributeValue.trim(), otherPeerElement.id ].join( ' ' ) ); } } ); }, - /** - * Update all of the aria-labelledby associations for this Node. This involves clearing out the current aria-labelledby - * values in the AccessiblePeer, to be restored to the value of the state stored by this Node - * - * @public (scenery-internal) only used by invalidateAccessibleContent.js - */ - updateAriaLabelledbyAssociations: function() { - - // clear the current aria-labelledby attribute and recreate it from stored associations - // TODO: make this more efficient - this.updateAccessiblePeers( function( peer ) { - peer.primarySibling && peer.primarySibling.removeAttribute( 'aria-labelledby' ); - peer.labelSibling && peer.labelSibling.removeAttribute( 'aria-labelledby' ); - peer.descriptionSibling && peer.descriptionSibling.removeAttribute( 'aria-labelledby' ); - peer.containerParent && peer.containerParent.removeAttribute( 'aria-labelledby' ); - } ); - - for ( var i = 0; i < this._ariaLabelledbyAssociations.length; i++ ) { - var associationObject = this._ariaLabelledbyAssociations[ i ]; - - this.addAriaLabelledbyAssociationImplementation( associationObject ); - } - }, /** * Sets the node that labels this node through the ARIA attribute aria-labelledby. The value of the diff --git a/js/accessibility/AccessibilityTests.js b/js/accessibility/AccessibilityTests.js index 6a40b3a4e..ee3d98227 100644 --- a/js/accessibility/AccessibilityTests.js +++ b/js/accessibility/AccessibilityTests.js @@ -365,8 +365,17 @@ define( function( require ) { } ); - // new aria-labelledby api - QUnit.test( 'aria-labelledby', function( assert ) { + // tests for aria-labelledby and aria-describedby should be the same, since both support the same feature set + function testAriaLabelledOrDescribedBy( assert, attribute ) { + + // use a different setter depending on if testing labelledby or describedby + var addAssociationFunction = attribute === 'aria-labelledby' ? 'addAriaLabelledbyAssociation' : + attribute === 'aria-describedby' ? 'addAriaDescribedbyAssociation' : null; + + if ( !addAssociationFunction ) { + throw new Error( 'incorrect attribute name while in testAriaLabelledOrDescribedBy' ); + } + var rootNode = new Node(); var display = new Display( rootNode ); // eslint-disable-line @@ -377,7 +386,7 @@ define( function( require ) { var b = new Node( { tagName: 'p', innerContent: TEST_LABEL_2 } ); rootNode.children = [ a, b ]; - a.addAriaLabelledbyAssociation( { + a[ addAssociationFunction ]( { otherNode: b, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.PRIMARY_SIBLING @@ -385,12 +394,12 @@ define( function( require ) { var aElement = getPrimarySiblingElementByNode( a ); var bElement = getPrimarySiblingElementByNode( b ); - assert.ok( aElement.getAttribute( 'aria-labelledby' ).indexOf( bElement.id ) >= 0, 'aria-labelledby for one node.' ); + assert.ok( aElement.getAttribute( attribute ).indexOf( bElement.id ) >= 0, attribute + ' for one node.' ); var c = new Node( { tagName: 'div', innerContent: TEST_LABEL } ); rootNode.addChild( c ); - a.addAriaLabelledbyAssociation( { + a[ addAssociationFunction ]( { otherNode: c, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.PRIMARY_SIBLING @@ -400,8 +409,7 @@ define( function( require ) { bElement = getPrimarySiblingElementByNode( b ); var cElement = getPrimarySiblingElementByNode( c ); var expectedValue = [ bElement.id, cElement.id ].join( ' ' ); - assert.ok( aElement.getAttribute( 'aria-labelledby' ).trim() === expectedValue, - 'aria-labelledby two nodes' ); + assert.ok( aElement.getAttribute( attribute ).trim() === expectedValue, attribute + ' two nodes' ); // Make c invalidate rootNode.removeChild( c ); @@ -411,14 +419,15 @@ define( function( require ) { aElement = getPrimarySiblingElementByNode( a ); cElement = getPrimarySiblingElementByNode( c ); - assert.ok( aElement.getAttribute( 'aria-labelledby' ).trim() !== oldValue, 'should have invalidated on tree change' ); - assert.ok( aElement.getAttribute( 'aria-labelledby' ).trim() === [ bElement.id, cElement.id ].join( ' ' ), + + assert.ok( aElement.getAttribute( attribute ).trim() !== oldValue, 'should have invalidated on tree change' ); + assert.ok( aElement.getAttribute( attribute ).trim() === [ bElement.id, cElement.id ].join( ' ' ), 'should have invalidated on tree change' ); var d = new Node( { tagName: 'div', descriptionTagName: 'p', innerContent: TEST_LABEL } ); rootNode.addChild( d ); - b.addAriaLabelledbyAssociation( { + b[ addAssociationFunction ]( { otherNode: d, thisElementName: AccessiblePeer.CONTAINER_PARENT, otherElementName: AccessiblePeer.DESCRIPTION_SIBLING @@ -427,9 +436,9 @@ define( function( require ) { var bParentContainer = getPrimarySiblingElementByNode( b ).parentElement; var dDescriptionElement = getPrimarySiblingElementByNode( d ).parentElement.childNodes[ 0 ]; - assert.ok( bParentContainer.getAttribute( 'aria-labelledby' ).trim() !== oldValue, 'should have invalidated on tree change' ); - assert.ok( bParentContainer.getAttribute( 'aria-labelledby' ).trim() === dDescriptionElement.id, - 'b parent container element is aria-labelledby d description sibling' ); + assert.ok( bParentContainer.getAttribute( attribute ).trim() !== oldValue, 'should have invalidated on tree change' ); + assert.ok( bParentContainer.getAttribute( attribute ).trim() === dDescriptionElement.id, + 'b parent container element is ' + attribute + ' d description sibling' ); // say we have a scene graph that looks like: @@ -450,19 +459,19 @@ define( function( require ) { g.addChild( h ); rootNode.addChild( e ); - e.addAriaLabelledbyAssociation( { + e[ addAssociationFunction ]( { otherNode: f, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.PRIMARY_SIBLING } ); - f.addAriaLabelledbyAssociation( { + f[ addAssociationFunction ]( { otherNode: g, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.PRIMARY_SIBLING } ); - g.addAriaLabelledbyAssociation( { + g[ addAssociationFunction ]( { otherNode: h, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.PRIMARY_SIBLING @@ -472,11 +481,11 @@ define( function( require ) { var fElement = getPrimarySiblingElementByNode( f ); var gElement = getPrimarySiblingElementByNode( g ); var hElement = getPrimarySiblingElementByNode( h ); - assert.ok( eElement.getAttribute( 'aria-labelledby' ).trim() === fElement.id, 'eElement should be aria-labelledby fElement' ); - assert.ok( fElement.getAttribute( 'aria-labelledby' ).trim() === gElement.id, 'fElement should be aria-labelledby gElement' ); - assert.ok( gElement.getAttribute( 'aria-labelledby' ).trim() === hElement.id, 'gElement should be aria-labelledby hElement' ); + assert.ok( eElement.getAttribute( attribute ).trim() === fElement.id, 'eElement should be ' + attribute + ' fElement' ); + assert.ok( fElement.getAttribute( attribute ).trim() === gElement.id, 'fElement should be ' + attribute + ' gElement' ); + assert.ok( gElement.getAttribute( attribute ).trim() === hElement.id, 'gElement should be ' + attribute + ' hElement' ); - // re-arrange the scene graph and make sure that the aria-labelledby ids remain up to date + // re-arrange the scene graph and make sure that the attribute ids remain up to date // e // \ // h @@ -495,11 +504,11 @@ define( function( require ) { fElement = getPrimarySiblingElementByNode( f ); gElement = getPrimarySiblingElementByNode( g ); hElement = getPrimarySiblingElementByNode( h ); - assert.ok( eElement.getAttribute( 'aria-labelledby' ).trim() === fElement.id, 'eElement should still be aria-labelledby fElement' ); - assert.ok( fElement.getAttribute( 'aria-labelledby' ).trim() === gElement.id, 'fElement should still be aria-labelledby gElement' ); - assert.ok( gElement.getAttribute( 'aria-labelledby' ).trim() === hElement.id, 'gElement should still be aria-labelledby hElement' ); + assert.ok( eElement.getAttribute( attribute ).trim() === fElement.id, 'eElement should still be ' + attribute + ' fElement' ); + assert.ok( fElement.getAttribute( attribute ).trim() === gElement.id, 'fElement should still be ' + attribute + ' gElement' ); + assert.ok( gElement.getAttribute( attribute ).trim() === hElement.id, 'gElement should still be ' + attribute + ' hElement' ); - // test aria labelled by your self, but a different peer Element, multiple aria-labelledby ids included in the test. + // test aria labelled by your self, but a different peer Element, multiple attribute ids included in the test. var containerTagName = 'div'; var j = new Node( { tagName: 'button', @@ -509,19 +518,19 @@ define( function( require ) { } ); rootNode.children = [ j ]; - j.addAriaLabelledbyAssociation( { + j[ addAssociationFunction ]( { otherNode: j, thisElementName: AccessiblePeer.PRIMARY_SIBLING, otherElementName: AccessiblePeer.LABEL_SIBLING } ); - j.addAriaLabelledbyAssociation( { + j[ addAssociationFunction ]( { otherNode: j, thisElementName: AccessiblePeer.CONTAINER_PARENT, otherElementName: AccessiblePeer.DESCRIPTION_SIBLING } ); - j.addAriaLabelledbyAssociation( { + j[ addAssociationFunction ]( { otherNode: j, thisElementName: AccessiblePeer.CONTAINER_PARENT, otherElementName: AccessiblePeer.LABEL_SIBLING @@ -529,16 +538,16 @@ define( function( require ) { var checkOnYourOwnAriaLabelledByAssociations = function( node ) { - var instance = node._accessibleInstances[0]; + var instance = node._accessibleInstances[ 0 ]; var nodePrimaryElement = instance.peer.primarySibling; var nodeParent = nodePrimaryElement.parentElement; var nodeLabelElement = nodeParent.childNodes[ DEFAULT_LABEL_SIBLING_INDEX ]; var nodeDescriptionElement = nodeParent.childNodes[ DEFAULT_DESCRIPTION_SIBLING_INDEX ]; - assert.ok( nodePrimaryElement.getAttribute( 'aria-labelledby' ).indexOf( nodeLabelElement.id ) >= 0, 'aria-labelledby your own label element.' ); - assert.ok( nodeParent.getAttribute( 'aria-labelledby' ).indexOf( nodeDescriptionElement.id ) >= 0, 'parent aria-labelledby your own description.' ); + assert.ok( nodePrimaryElement.getAttribute( attribute ).indexOf( nodeLabelElement.id ) >= 0, attribute + ' your own label element.' ); + assert.ok( nodeParent.getAttribute( attribute ).indexOf( nodeDescriptionElement.id ) >= 0, 'parent ' + attribute + ' your own description.' ); - assert.ok( nodeParent.getAttribute( 'aria-labelledby' ).indexOf( nodeLabelElement.id ) >= 0, 'parent aria-labelledby your own description.' ); + assert.ok( nodeParent.getAttribute( attribute ).indexOf( nodeLabelElement.id ) >= 0, 'parent ' + attribute + ' your own description.' ); }; @@ -552,6 +561,16 @@ define( function( require ) { j.invalidateAccessibleContent(); checkOnYourOwnAriaLabelledByAssociations( j ); + } + + QUnit.test( 'aria-labelledby', function( assert ) { + + testAriaLabelledOrDescribedBy( assert, 'aria-labelledby' ); + + } ); + QUnit.test( 'aria-describedby', function( assert ) { + + testAriaLabelledOrDescribedBy( assert, 'aria-describedby' ); } ); diff --git a/js/accessibility/invalidateAccessibleContent.js b/js/accessibility/invalidateAccessibleContent.js index ae626b4ac..f4397abd3 100644 --- a/js/accessibility/invalidateAccessibleContent.js +++ b/js/accessibility/invalidateAccessibleContent.js @@ -169,12 +169,23 @@ define( function( require ) { } // restore this nodes aria-labelledby associations - self.updateAriaLabelledbyAssociations(); + var ariaLabelledbyAtrributeName = 'aria-labelledby'; + self.updateAssociationsForAttribute( ariaLabelledbyAtrributeName ); // if any other nodes are aria-labelledby this Node, update those associations too. Since this node's // accessible content needs to be recreated, they need to update their aria-labelledby associations accordingly. for ( i = 0; i < self._nodesThatAreAriaLabelledByThisNode.length; i++ ) { - self._nodesThatAreAriaLabelledByThisNode[ i ].updateAriaLabelledbyAssociations(); + self._nodesThatAreAriaLabelledByThisNode[ i ].updateAssociationsForAttribute( ariaLabelledbyAtrributeName ); + } + + // restore this nodes aria-describedby associations + var ariaDescribedbyAttributeName = 'aria-describedby'; + self.updateAssociationsForAttribute( ariaDescribedbyAttributeName ); + + // if any other nodes are aria-describedby this Node, update those associations too. Since this node's + // accessible content needs to be recreated, they need to update their aria-describedby associations accordingly. + for ( i = 0; i < self._nodesThatAreAriaDescribedByThisNode.length; i++ ) { + self._nodesThatAreAriaDescribedByThisNode[ i ].updateAssociationsForAttribute( ariaDescribedbyAttributeName ); } // restore aria-labelledby associations