diff --git a/js/accessibility/A11yBehaviorFunctionDef.js b/js/accessibility/A11yBehaviorFunctionDef.js index c0d9e418b..1c41b5ac5 100644 --- a/js/accessibility/A11yBehaviorFunctionDef.js +++ b/js/accessibility/A11yBehaviorFunctionDef.js @@ -59,6 +59,7 @@ * REVIEW: inputType (setter doesn't call onAccessibleContentChange, can introduce buggy behavior) * REVIEW: ariaLabelledbyAssociations (ok, probably won't override with behaviors?) * REVIEW: ariaDescribedbyAssociations (ok, probably won't override with behaviors?) + * REVIEW: activeDescendantAssociations (ok, probably won't override with behaviors?) * REVIEW: accessibleAttributes (we WANT to override these with behavior, but can't right now) from onAttributeChange * REVIEW: inputValue (doesn't look like behaviors can override) * REVIEW: node.focusHighlight (looks like updates to this are completely broken) diff --git a/js/accessibility/Accessibility.js b/js/accessibility/Accessibility.js index c674dfb3f..62dfabcce 100644 --- a/js/accessibility/Accessibility.js +++ b/js/accessibility/Accessibility.js @@ -227,7 +227,8 @@ define( function( require ) { 'focusable', // Sets whether or not the node can receive keyboard focus, see setFocusable() 'accessibleOrder', // Modifies the order of accessible navigation, see setAccessibleOrder() 'ariaLabelledbyAssociations', // sets the list of aria-labelledby associations between from this node to others (including itself), see setAriaLabelledbyAssociations - 'ariaDescribedbyAssociations' // sets the list of aria-describedby associations between from this node to others (including itself), see setAriaDescribedbyAssociations + 'ariaDescribedbyAssociations', // sets the list of aria-describedby associations between from this node to others (including itself), see setAriaDescribedbyAssociations + 'activeDescendantAssociations' // sets the list of aria-activedescendant associations between from this node to others (including itself), see setActiveDescendantAssociations ]; var Accessibility = { @@ -353,7 +354,7 @@ define( function( require ) { // {Array.} this._nodesThatAreAriaLabelledbyThisNode = []; - // @private {Array.} - Keep track of what this Node is aria-descripbedby via "associationObjects" + // @private {Array.} - Keep track of what this Node is aria-describedby via "associationObjects" // see addAriaDescribedbyAssociation for why we support more than one association. this._ariaDescribedbyAssociations = []; @@ -364,6 +365,17 @@ define( function( require ) { // {Array.} this._nodesThatAreAriaDescribedbyThisNode = []; + // @private {Array.} - Keep track of what this Node is aria-activedescendant via "associationObjects" + // see addActiveDescendantAssociation for why we support more than one association. + this._activeDescendantAssociations = []; + + // Keep a reference to all nodes that are aria-activedescendant this node, i.e. that have store one of this Node's + // peer HTMLElement's id in their peer HTMLElement's aria-activedescendant attribute. This way we can tell other + // nodes to update their aria-activedescendant associations when this Node rebuilds its accessible content. + // @private + // {Array.} + this._nodesThatAreActiveDescendantToThisNode = []; + // @private {boolean|null} - 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 @@ -567,6 +579,7 @@ define( function( require ) { // Clear out aria association attributes, which hold references to other nodes. this.setAriaLabelledbyAssociations( [] ); this.setAriaDescribedbyAssociations( [] ); + this.setActiveDescendantAssociations( [] ); this.removeAllAccessibleInputListeners(); }, @@ -1871,6 +1884,154 @@ define( function( require ) { }, get nodesThatAreAriaDescribedbyThisNode() { return this.getNodesThatAreAriaDescribedbyThisNode(); }, + + + /** + * @public + * @param {Array.} activeDescendantAssociations - list of associationObjects, see this._activeDescendantAssociations. + */ + setActiveDescendantAssociations: function( activeDescendantAssociations ) { + var associationObject; + if ( assert ) { + assert( Array.isArray( activeDescendantAssociations ) ); + for ( var j = 0; j < activeDescendantAssociations.length; j++ ) { + associationObject = activeDescendantAssociations[ j ]; + assert && AccessibilityUtil.validateAssociationObject( associationObject ); + } + } + + // if the list isn't the same, TODO: make order in the list not matter + if ( !_.isEqual( activeDescendantAssociations, this._activeDescendantAssociations ) ) { + + var beforeOnly = []; // Will hold all nodes that will be removed. + var afterOnly = []; // Will hold all nodes that will be "new" children (added) + var inBoth = []; // Child nodes that "stay". Will be ordered for the "after" case. + var i; + + // get a difference of the desired new list, and the old + arrayDifference( activeDescendantAssociations, this._activeDescendantAssociations, afterOnly, beforeOnly, inBoth ); + + // remove each current associationObject that isn't in the new list + for ( i = 0; i < beforeOnly.length; i++ ) { + associationObject = beforeOnly[ i ]; + this.removeActiveDescendantAssociation( associationObject ); + } + + assert && assert( this._activeDescendantAssociations.length === inBoth.length, + 'Removing associations should not have triggered other association changes' ); + + // add each association from the new list that hasn't been added yet + for ( i = 0; i < afterOnly.length; i++ ) { + var activeDescendantAssociation = activeDescendantAssociations[ i ]; + this.addActiveDescendantAssociation( activeDescendantAssociation ); + } + + // TODO maybe reorder them, but right now order doesn't seem to matter + } + }, + set activeDescendantAssociations( activeDescendantAssociations ) { return this.setActiveDescendantAssociations( activeDescendantAssociations ); }, + + /** + * @public + * @returns {Array.} - the list of current association objects + */ + getActiveDescendantAssociations: function() { + return this._activeDescendantAssociations; + }, + get activeDescendantAssociations() { return this.getActiveDescendantAssociations(); }, + + /** + * Add an aria-activeDescendant 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-activeDescendant attribute with a value that includes the `otherNode`'s peer HTMLElement's id (specified with + * `otherElementName`)." + * + * @param {Object} associationObject - with key value pairs like + * { otherNode: {Node}, otherElementName: {string}, thisElementName: {string } } + * see AccessiblePeer for valid element names. + */ + addActiveDescendantAssociation: function( associationObject ) { + assert && AccessibilityUtil.validateAssociationObject( associationObject ); + + // TODO: assert if this associationObject is already in the association objects list! https://github.com/phetsims/scenery/issues/832 + this._activeDescendantAssociations.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. + associationObject.otherNode._nodesThatAreActiveDescendantToThisNode.push( this ); + + // update the accessiblePeers with this aria-activeDescendant association + this.updateActiveDescendantAssociationsInPeers(); + }, + + /** + * Remove an aria-activeDescendant association object, see addActiveDescendantAssociation for more details + * @public + */ + removeActiveDescendantAssociation: function( associationObject ) { + assert && assert( _.includes( this._activeDescendantAssociations, associationObject ) ); + + // remove the + var removedObject = this._activeDescendantAssociations.splice( _.indexOf( this._activeDescendantAssociations, associationObject ), 1 ); + + // remove the reference from the other node back to this node because we don't need it anymore + removedObject[ 0 ].otherNode.removeNodeThatIsActiveDescendantThisNode( this ); + + this.updateActiveDescendantAssociationsInPeers(); + }, + + /** + * Remove the reference to the node that is using this Node's ID as an aria-activeDescendant value + * @param {Node} node + * @public (scenery-internal) + */ + removeNodeThatIsActiveDescendantThisNode: function( node ) { + assert && assert( node instanceof scenery.Node ); + var indexOfNode = _.indexOf( this._nodesThatAreActiveDescendantToThisNode, node ); + assert && assert( indexOfNode >= 0 ); + this._nodesThatAreActiveDescendantToThisNode.splice( indexOfNode, 1 ); + + }, + + /** + * Trigger the view update for each AccessiblePeer + * @public + */ + updateActiveDescendantAssociationsInPeers: function() { + for ( var i = 0; i < this.accessibleInstances.length; i++ ) { + var peer = this.accessibleInstances[ i ].peer; + peer.onActiveDescendantAssociationChange(); + } + }, + + /** + * Update the associations for aria-activeDescendant + * @public (scenery-internal) + */ + updateOtherNodesActiveDescendant: function() { + + // if any other nodes are aria-activeDescendant this Node, update those associations too. Since this node's + // accessible content needs to be recreated, they need to update their aria-activeDescendant associations accordingly. + // TODO: only use unique elements of the array (_.unique) + for ( var i = 0; i < this._nodesThatAreActiveDescendantToThisNode.length; i++ ) { + var otherNode = this._nodesThatAreActiveDescendantToThisNode[ i ]; + otherNode.updateActiveDescendantAssociationsInPeers(); + } + }, + + /** + * The list of Nodes that are aria-activeDescendant this node (other node's peer element will have this Node's Peer element's + * id in the aria-activeDescendant attribute + * @public + * @returns {Array.} + */ + getNodesThatAreActiveDescendantToThisNode: function() { + return this._nodesThatAreActiveDescendantToThisNode; + }, + get nodesThatAreActiveDescendantToThisNode() { return this.getNodesThatAreActiveDescendantToThisNode(); }, + + + /** * Sets the accessible focus order for this node. This includes not only focused items, but elements that can be * placed in the parallel DOM. If provided, it will override the focus order between children (and @@ -2508,6 +2669,7 @@ define( function( require ) { // to this Node (they are pointing to this Node's IDs). https://github.com/phetsims/scenery/issues/816 node.updateOtherNodesAriaLabelledby(); node.updateOtherNodesAriaDescribedby(); + node.updateOtherNodesActiveDescendant(); sceneryLog && sceneryLog.Accessibility && sceneryLog.pop(); }, diff --git a/js/accessibility/AccessibilityTests.js b/js/accessibility/AccessibilityTests.js index 5c6217134..0678f3c88 100644 --- a/js/accessibility/AccessibilityTests.js +++ b/js/accessibility/AccessibilityTests.js @@ -455,14 +455,16 @@ define( function( require ) { } ); // tests for aria-labelledby and aria-describedby should be the same, since both support the same feature set - function testAriaLabelledOrDescribedBy( assert, attribute ) { // eslint-disable-line + function testAssociationAttribute( assert, attribute ) { // eslint-disable-line // use a different setter depending on if testing labelledby or describedby var addAssociationFunction = attribute === 'aria-labelledby' ? 'addAriaLabelledbyAssociation' : - attribute === 'aria-describedby' ? 'addAriaDescribedbyAssociation' : null; + attribute === 'aria-describedby' ? 'addAriaDescribedbyAssociation' : + attribute === 'aria-activedescendant' ? 'addActiveDescendantAssociation' : + null; if ( !addAssociationFunction ) { - throw new Error( 'incorrect attribute name while in testAriaLabelledOrDescribedBy' ); + throw new Error( 'incorrect attribute name while in testAssociationAttribute' ); } @@ -631,7 +633,7 @@ define( function( require ) { otherElementName: AccessiblePeer.LABEL_SIBLING } ); - var checkOnYourOwnAriaLabelledByAssociations = function( node ) { + var checkOnYourOwnAssociations = function( node ) { var instance = node._accessibleInstances[ 0 ]; var nodePrimaryElement = instance.peer.primarySibling; @@ -662,44 +664,44 @@ define( function( require ) { // Check basic associations within single node - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); // Moving this node around the scene graph should not change it's aria labelled by associations. rootNode.addChild( new Node( { children: [ j ] } ) ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); // check remove child rootNode.removeChild( j ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); // check dispose var jParent = new Node( { children: [ j ] } ); rootNode.children = []; rootNode.addChild( jParent ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); rootNode.addChild( j ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); rootNode.addChild( k ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); jParent.dispose(); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); // check removeChild with dag var jParent2 = new Node( { children: [ j ] } ); rootNode.insertChild( 0, jParent2 ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); rootNode.removeChild( jParent2 ); - checkOnYourOwnAriaLabelledByAssociations( j ); + checkOnYourOwnAssociations( j ); testK(); } - function testAriaLabelledOrDescribedBySetters( assert, attribute ) { // eslint-disable-line + function testAssociationAttributeBySetters( assert, attribute ) { // eslint-disable-line var rootNode = new Node(); @@ -709,11 +711,15 @@ define( function( require ) { // use a different setter depending on if testing labelledby or describedby var associationsArrayName = attribute === 'aria-labelledby' ? 'ariaLabelledbyAssociations' : - attribute === 'aria-describedby' ? 'ariaDescribedbyAssociations' : null; + attribute === 'aria-describedby' ? 'ariaDescribedbyAssociations' : + attribute === 'aria-activedescendant' ? 'activeDescendantAssociations' : + null; // use a different setter depending on if testing labelledby or describedby var associationRemovalFunction = attribute === 'aria-labelledby' ? 'removeAriaLabelledbyAssociation' : - attribute === 'aria-describedby' ? 'removeAriaDescribedbyAssociation' : null; + attribute === 'aria-describedby' ? 'removeAriaDescribedbyAssociation' : + attribute === 'aria-activedescendant' ? 'removeActiveDescendantAssociation' : + null; var options = { @@ -775,17 +781,23 @@ define( function( require ) { assert.ok( m[ associationsArrayName ].length === 0, 'cleared when disposed' ); } - // TODO: comment these back in once aria-labelledby is working again QUnit.test( 'aria-labelledby', function( assert ) { - testAriaLabelledOrDescribedBy( assert, 'aria-labelledby' ); - testAriaLabelledOrDescribedBySetters( assert, 'aria-labelledby' ); + testAssociationAttribute( assert, 'aria-labelledby' ); + testAssociationAttributeBySetters( assert, 'aria-labelledby' ); } ); QUnit.test( 'aria-describedby', function( assert ) { - testAriaLabelledOrDescribedBy( assert, 'aria-describedby' ); - testAriaLabelledOrDescribedBySetters( assert, 'aria-describedby' ); + testAssociationAttribute( assert, 'aria-describedby' ); + testAssociationAttributeBySetters( assert, 'aria-describedby' ); + + } ); + + QUnit.test( 'aria-activedescendant', function( assert ) { + + testAssociationAttribute( assert, 'aria-activedescendant' ); + testAssociationAttributeBySetters( assert, 'aria-activedescendant' ); } ); @@ -879,7 +891,7 @@ define( function( require ) { a1Element = getPrimarySiblingElementByNode( a1 ); assert.ok( a1Element.hidden, true, 'hidden set as Property' ); assert.ok( a1Element.getAttribute( 'hidden' ) === '', 'hidden should not be set as attribute' ); - + } ); QUnit.test( 'Accessibility input listeners', function( assert ) { diff --git a/js/accessibility/AccessibilityUtil.js b/js/accessibility/AccessibilityUtil.js index 9e8e6800e..36924c86c 100644 --- a/js/accessibility/AccessibilityUtil.js +++ b/js/accessibility/AccessibilityUtil.js @@ -67,9 +67,10 @@ define( function( require ) { var ARIA_LABELLEDBY = 'aria-labelledby'; var ARIA_DESCRIBEDBY = 'aria-describedby'; + var ARIA_ACTIVE_DESCENDANT = 'aria-activedescendant'; // {Array.} attributes that put an ID of another attribute as the value, see https://github.com/phetsims/scenery/issues/819 - var ASSOCIATION_ATTRIBUTES = [ ARIA_LABELLEDBY, ARIA_DESCRIBEDBY ]; + var ASSOCIATION_ATTRIBUTES = [ ARIA_LABELLEDBY, ARIA_DESCRIBEDBY, ARIA_ACTIVE_DESCENDANT ]; /** * Get all 'element' nodes off the parent element, placing them in an array for easy traversal. Note that this diff --git a/js/accessibility/AccessiblePeer.js b/js/accessibility/AccessiblePeer.js index ad02c40b4..54d1a1c5a 100644 --- a/js/accessibility/AccessiblePeer.js +++ b/js/accessibility/AccessiblePeer.js @@ -233,6 +233,7 @@ define( function( require ) { // recompute and assign the association attributes that link two elements (like aria-labelledby) this.onAriaLabelledbyAssociationChange(); this.onAriaDescribedbyAssociationChange(); + this.onActiveDescendantAssociationChange(); // add all listeners to the dom element @@ -256,6 +257,7 @@ define( function( require ) { this.node.updateOtherNodesAriaLabelledby(); this.node.updateOtherNodesAriaDescribedby(); + this.node.updateOtherNodesActiveDescendant(); }, /** @@ -363,6 +365,25 @@ define( function( require ) { } }, + /** + * Recompute the aria-activedescendant attributes for all of the peer's elements + * @public + */ + onActiveDescendantAssociationChange: function() { + this.removeAttributeFromAllElements( 'aria-activedescendant' ); + + for ( var i = 0; i < this.node.activeDescendantAssociations.length; i++ ) { + var associationObject = this.node.activeDescendantAssociations[ i ]; + + // Assert out if the model list is different than the data held in the associationObject + assert && assert( associationObject.otherNode.nodesThatAreActiveDescendantToThisNode.indexOf( this.node ) >= 0, + 'unexpected otherNode' ); + + + this.setAssociationAttribute( 'aria-activedescendant', associationObject ); + } + }, + /** * Set all accessible attributes onto the peer elements from the model's stored data objects * @private @@ -552,7 +573,7 @@ define( function( require ) { * @param {Object} associationObject - see addAriaLabelledbyAssociation() for schema */ setAssociationAttribute: function( attribute, associationObject ) { - assert && assert( attribute === 'aria-labelledby' || attribute === 'aria-describedby', + assert && assert( AccessibilityUtil.ASSOCIATION_ATTRIBUTES.indexOf( attribute ) >= 0, 'unsupported attribute for setting with association object: ' + attribute ); assert && AccessibilityUtil.validateAssociationObject( associationObject );