diff --git a/js/accessibility/pdom/PDOMFuzzer.js b/js/accessibility/pdom/PDOMFuzzer.js index 3a934c6fa..e7219e5d9 100644 --- a/js/accessibility/pdom/PDOMFuzzer.js +++ b/js/accessibility/pdom/PDOMFuzzer.js @@ -183,7 +183,7 @@ class PDOMFuzzer { // Can't include nodes that are included in other accessible orders for ( let i = 0; i < order.length; i++ ) { - if ( order[ i ]._pdomParent && order[ i ]._pdomParent !== node ) { + if ( order[ i ].pdomParent && order[ i ].pdomParent !== node ) { return false; } } diff --git a/js/accessibility/pdom/PDOMPeer.js b/js/accessibility/pdom/PDOMPeer.js index 571b6f91f..e503888c7 100644 --- a/js/accessibility/pdom/PDOMPeer.js +++ b/js/accessibility/pdom/PDOMPeer.js @@ -804,7 +804,7 @@ class PDOMPeer { assert && assert( content === null || typeof content === 'string', 'incorrect inner content type' ); assert && assert( this.pdomInstance.children.length === 0, 'descendants exist with accessible content, innerContent cannot be used' ); assert && assert( PDOMUtils.tagNameSupportsContent( this._primarySibling.tagName ), - `tagName: ${this._tagName} does not support inner content` ); + `tagName: ${this.tagName} does not support inner content` ); // no-op to support any option order if ( !this._primarySibling ) { diff --git a/js/accessibility/pdom/PDOMTree.js b/js/accessibility/pdom/PDOMTree.js index e59154c64..a10237fba 100644 --- a/js/accessibility/pdom/PDOMTree.js +++ b/js/accessibility/pdom/PDOMTree.js @@ -34,7 +34,7 @@ const PDOMTree = { const blockedDisplays = PDOMTree.beforeOp( child ); - if ( !child._pdomParent ) { + if ( !child.pdomParent ) { PDOMTree.addTree( parent, child ); } @@ -60,7 +60,7 @@ const PDOMTree = { const blockedDisplays = PDOMTree.beforeOp( child ); - if ( !child._pdomParent ) { + if ( !child.pdomParent ) { PDOMTree.removeTree( parent, child ); } @@ -121,11 +121,11 @@ const PDOMTree = { // Check some initial conditions if ( assert ) { for ( i = 0; i < removedItems; i++ ) { - assert( removedItems[ i ] === null || removedItems[ i ]._pdomParent === node, + assert( removedItems[ i ] === null || removedItems[ i ].pdomParent === node, 'Node should have had an pdomOrder' ); } for ( i = 0; i < addedItems; i++ ) { - assert( addedItems[ i ] === null || addedItems[ i ]._pdomParent === null, + assert( addedItems[ i ] === null || addedItems[ i ].pdomParent === null, 'Node is already specified in an pdomOrder' ); } } @@ -142,7 +142,7 @@ const PDOMTree = { const removedItemToRemove = removedItems[ i ]; if ( removedItemToRemove ) { PDOMTree.removeTree( node, removedItemToRemove, pdomTrails ); - removedItemToRemove._pdomParent = null; + removedItemToRemove.pdomParent = null; } } @@ -154,7 +154,7 @@ const PDOMTree = { for ( j = 0; j < removedParents.length; j++ ) { PDOMTree.removeTree( removedParents[ j ], addedItemToRemove ); } - addedItemToRemove._pdomParent = node; + addedItemToRemove.pdomParent = node; } } @@ -197,7 +197,7 @@ const PDOMTree = { const blockedDisplays = PDOMTree.beforeOp( node ); let i; - const parents = node._pdomParent ? [ node._pdomParent ] : node._parents; + const parents = node.pdomParent ? [ node.pdomParent ] : node._parents; const pdomTrailsList = []; // pdomTrailsList[ i ] := PDOMTree.findPDOMTrails( parents[ i ] ) // For now, just regenerate the full tree. Could optimize in the future, if we can swap the content for an @@ -475,7 +475,7 @@ const PDOMTree = { } } - const parents = root._pdomParent ? [ root._pdomParent ] : root._parents; + const parents = root.pdomParent ? [ root.pdomParent ] : root._parents; const parentCount = parents.length; for ( i = 0; i < parentCount; i++ ) { const parent = parents[ i ]; @@ -564,8 +564,8 @@ const PDOMTree = { trail.removeAncestor(); } // Only visit the pdomParent if we didn't already visit it as a parent. - if ( root._pdomParent && !root._pdomParent.hasChild( root ) ) { - trail.addAncestor( root._pdomParent ); + if ( root.pdomParent && !root.pdomParent.hasChild( root ) ) { + trail.addAncestor( root.pdomParent ); recursiveSearch(); trail.removeAncestor(); } diff --git a/js/accessibility/pdom/ParallelDOM.js b/js/accessibility/pdom/ParallelDOM.js index f30657895..1a0434636 100644 --- a/js/accessibility/pdom/ParallelDOM.js +++ b/js/accessibility/pdom/ParallelDOM.js @@ -226,6 +226,7 @@ const ParallelDOM = { * Given the constructor for Node, add accessibility functions into the prototype. * * @param {function} type - the constructor for Node + * @returns {Object} - The recordConfig for Node's Record */ compose( type ) { // Can't avoid circular dependency, so no assertion here. Ensure that 'type' is the constructor for Node. @@ -254,164 +255,164 @@ const ParallelDOM = { initializeParallelDOM: function() { // @private {string|null} - the HTML tag name of the element representing this node in the DOM - this._tagName = null; + // this.tagName = null; // @private {string|null} - the HTML tag name for a container parent element for this node in the DOM. This // container parent will contain the node's DOM element, as well as peer elements for any label or description // content. See setContainerTagName() for more documentation. If this option is needed (like to // contain multiple siblings with the primary sibling), it will default to the value of DEFAULT_CONTAINER_TAG_NAME. - this._containerTagName = null; + // this._containerTagName = null; // @private {string|null} - the HTML tag name for the label element that will contain the label content for // this dom element. There are ways in which you can have a label without specifying a label tag name, // see setLabelContent() for the list of ways. - this._labelTagName = null; + // this._labelTagName = null; // @private {string|null} - the HTML tag name for the description element that will contain descsription content // for this dom element. If a description is set before a tag name is defined, a paragraph element // will be created for the description. - this._descriptionTagName = null; + // this._descriptionTagName = null; // @private {string|null} - the type for an element with tag name of INPUT. This should only be used // if the element has a tag name INPUT. - this._inputType = null; + // this._inputType = null; // @private {string|number|null} - the value of the input, only relevant if the tag name is of type "INPUT". Is a // string because the `value` attribute is a DOMString. null value indicates no value. - this._inputValue = null; + // this._inputValue = null; // @private {boolean} - whether or not the pdom input is considered 'checked', only useful for inputs of // type 'radio' and 'checkbox' - this._pdomChecked = false; + // this._pdomChecked = false; // @private {boolean} - By default the label will be prepended before the primary sibling in the PDOM. This // option allows you to instead have the label added after the primary sibling. Note: The label will always // be in front of the description sibling. If this flag is set with `appendDescription: true`, the order will be // (1) primary sibling, (2) label sibling, (3) description sibling. All siblings will be placed within the // containerParent. - this._appendLabel = false; + // this._appendLabel = false; // @private {boolean} - By default the description will be prepended before the primary sibling in the PDOM. This // option allows you to instead have the description added after the primary sibling. Note: The description // will always be after the label sibling. If this flag is set with `appendLabel: true`, the order will be // (1) primary sibling, (2) label sibling, (3) description sibling. All siblings will be placed within the // containerParent. - this._appendDescription = false; + // this._appendDescription = false; // @private {Array. - array of attributes that are on the node's DOM element. Objects will have the // form { attribute:{string}, value:{*}, namespace:{string|null} } - this._pdomAttributes = []; + // this._pdomAttributes = []; // @private {string|null} - the label content for this node's DOM element. There are multiple ways that a label // can be associated with a node's dom element, see setLabelContent() for more documentation - this._labelContent = null; + // this._labelContent = null; // @private {string|null} - the inner label content for this node's primary sibling. Set as inner HTML // or text content of the actual DOM element. If this is used, the node should not have children. - this._innerContent = null; + // this._innerContent = null; // @private {string|null} - the description content for this node's DOM element. - this._descriptionContent = null; + // this._descriptionContent = null; // @private {string|null} - If provided, it will create the primary DOM element with the specified namespace. // This may be needed, for example, with MathML/SVG/etc. - this._pdomNamespace = null; + // this._pdomNamespace = null; // @private {string|null} - if provided, "aria-label" will be added as an inline attribute on the node's DOM // element and set to this value. This will determine how the Accessible Name is provided for the DOM element. - this._ariaLabel = null; + // this._ariaLabel = null; // @private {string|null} - the ARIA role for this Node's primary sibling, added as an HTML attribute. For a complete // list of ARIA roles, see https://www.w3.org/TR/wai-aria/roles. Beware that many roles are not supported // by browsers or assistive technologies, so use vanilla HTML for accessibility semantics where possible. - this._ariaRole = null; + // this._ariaRole = null; // @private {string|null} - the ARIA role for the container parent element, added as an HTML attribute. For a // complete list of ARIA roles, see https://www.w3.org/TR/wai-aria/roles. Beware that many roles are not // supported by browsers or assistive technologies, so use vanilla HTML for accessibility semantics where // possible. - this._containerAriaRole = null; + // this._containerAriaRole = null; // @private {string|null} - if provided, "aria-valuetext" will be added as an inline attribute on the Node's // primary sibling and set to this value. Setting back to null will clear this attribute in the view. - this._ariaValueText = null; + // this._ariaValueText = null; // @private {Array.} - Keep track of what this Node is aria-labelledby via "associationObjects" // see addAriaLabelledbyAssociation for why we support more than one association. - this._ariaLabelledbyAssociations = []; + // this._ariaLabelledbyAssociations = []; // Keep a reference to all nodes that are aria-labelledby this node, i.e. that have store one of this Node's // peer HTMLElement's id in their peer HTMLElement's aria-labelledby attribute. This way we can tell other // nodes to update their aria-labelledby associations when this Node rebuilds its pdom content. // @private // {Array.} - this._nodesThatAreAriaLabelledbyThisNode = []; + // this._nodesThatAreAriaLabelledbyThisNode = []; // @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 = []; + // 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 pdom content. // @private // {Array.} - this._nodesThatAreAriaDescribedbyThisNode = []; + // 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 = []; + // 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 pdom content. // @private // {Array.} - this._nodesThatAreActiveDescendantToThisNode = []; + // this._nodesThatAreActiveDescendantToThisNode = []; // @private {boolean|null} - whether or not this Node's primary sibling has been explicitly set to receive focus from // tab navigation. Sets the tabIndex attribute on the Node's primary sibling. 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 // HTMLElements (such as HTML form elements) can be focusable by default, without setting this property. The // native HTML function from these form elements can be overridden with this property. - this._focusableOverride = null; + // this._focusableOverride = null; // @private {Shape|Node|string.<'invisible'>|null} - the focus highlight that will surround this node when it // is focused. By default, the focus highlight will be a pink rectangle that surrounds the Node's local // bounds. - this._focusHighlight = null; + // this._focusHighlight = null; // @private {boolean} - A flag that allows prevents focus highlight from being displayed in the HighlightOverlay. // If true, the focus highlight for this node will be layerable in the scene graph. Client is responsible // for placement of the focus highlight in the scene graph. - this._focusHighlightLayerable = false; + // this._focusHighlightLayerable = false; // @private {boolean|Node} - Adds a group focus highlight that surrounds this node when a descendant has // focus. Typically useful to indicate focus if focus enters a group of elements. If 'true', group // highlight will go around local bounds of this node. Otherwise the custom node will be used as the highlight/ - this._groupFocusHighlight = false; + // this._groupFocusHighlight = false; // @private {boolean} - Whether or not the pdom content will be visible from the browser and assistive // technologies. When pdomVisible is false, the Node's primary sibling will not be focusable, and it cannot // be found by the assistive technology virtual cursor. For more information on how assistive technologies // read with the virtual cursor see // http://www.ssbbartgroup.com/blog/how-windows-screen-readers-work-on-the-web/ - this._pdomVisible = true; + // this._pdomVisible = true; // @private {Array.|null} - If provided, it will override the focus order between children // (and optionally arbitrary subtrees). If not provided, the focus order will default to the rendering order // (first children first, last children last) determined by the children array. // See setPDOMOrder() for more documentation. - this._pdomOrder = null; + // this._pdomOrder = null; // @public (scenery-internal) {Node|null} - If this node is specified in another node's // pdomOrder, then this will have the value of that other (PDOM parent) Node. Otherwise it's null. - this._pdomParent = null; + // this._pdomParent = null; // @public (scenery-internal) {Node|null} - If this is specified, the primary sibling will be positioned // to align with this source node and observe the transforms along this node's trail. At this time the // pdomTransformSourceNode cannot use DAG. - this._pdomTransformSourceNode = null; + // this._pdomTransformSourceNode = null; // @public (scenery-internal) {PDOMDisplaysInfo} - Contains information about what pdom displays // this node is "visible" for, see PDOMDisplaysInfo.js for more information. @@ -423,35 +424,35 @@ const ParallelDOM = { // @private {boolean} - Determines if DOM siblings are positioned in the viewport. This // is required for Nodes that require unique input gestures with iOS VoiceOver like "Drag and Drop". // See setPositionInPDOM for more information. - this._positionInPDOM = false; + // this._positionInPDOM = false; // @public (read-only, scenery-internal) {boolean} - If true, any DOM events received on the label sibling // will not dispatch SceneryEvents through the scene graph, see setExcludeLabelSiblingFromInput() - this.excludeLabelSiblingFromInput = false; + // this.excludeLabelSiblingFromInput = false; // HIGHER LEVEL API INITIALIZATION // {string|null} - sets the "Accessible Name" of the Node, as defined by the Browser's ParallelDOM Tree - this._accessibleName = null; + // this._accessibleName = null; // {PDOMBehaviorFunctionDef} - function that returns the options needed to set the appropriate accessible name for the Node - this._accessibleNameBehavior = ParallelDOM.BASIC_ACCESSIBLE_NAME_BEHAVIOR; + // this._accessibleNameBehavior = ParallelDOM.BASIC_ACCESSIBLE_NAME_BEHAVIOR; // {string|null} - sets the help text of the Node, this most often corresponds to description text. - this._helpText = null; + // this._helpText = null; // {PDOMBehaviorFunctionDef} - sets the help text of the Node, this most often corresponds to description text. - this._helpTextBehavior = ParallelDOM.HELP_TEXT_AFTER_CONTENT; + // this._helpTextBehavior = ParallelDOM.HELP_TEXT_AFTER_CONTENT; // {string|null} - sets the help text of the Node, this most often corresponds to label sibling text. - this._pdomHeading = null; + // this._pdomHeading = null; + + // {PDOMBehaviorFunctionDef} - sets the help text of the Node, this most often corresponds to description text. + // this._pdomHeadingBehavior = DEFAULT_PDOM_HEADING_BEHAVIOR; // TODO: implement headingLevel override, see https://github.com/phetsims/scenery/issues/855 // {number|null} - the number that corresponds to the heading tag the node will get if using the pdomHeading api,. - this._headingLevel = null; - - // {PDOMBehaviorFunctionDef} - sets the help text of the Node, this most often corresponds to description text. - this._pdomHeadingBehavior = DEFAULT_PDOM_HEADING_BEHAVIOR; + // this._headingLevel = null; // @public {TinyEmitter} - Emits an event when the focus highlight is changed. this.focusHighlightChangedEmitter = new TinyEmitter(); @@ -542,7 +543,7 @@ const ParallelDOM = { // when accessibility is widely used, this assertion can be added back in // assert && assert( this._pdomInstances.length > 0, 'there must be pdom content for the node to receive focus' ); assert && assert( this.focusable, 'trying to set focus on a node that is not focusable' ); - assert && assert( this._pdomVisible, 'trying to set focus on a node with invisible pdom content' ); + assert && assert( this.pdomVisible, 'trying to set focus on a node with invisible pdom content' ); assert && assert( this._pdomInstances.length === 1, 'focus() unsupported for Nodes using DAG, pdom content is not unique' ); const peer = this._pdomInstances[ 0 ].peer; @@ -575,17 +576,17 @@ const ParallelDOM = { if ( this.hasPDOMContent && assert ) { - this._inputType && assert( this._tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support inputType' ); - this._pdomChecked && assert( this._tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support pdomChecked.' ); - this._inputValue && assert( this._tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support inputValue' ); - this._pdomChecked && assert( INPUT_TYPES_THAT_SUPPORT_CHECKED.indexOf( this._inputType.toUpperCase() ) >= 0, `inputType does not support checked attribute: ${this._inputType}` ); - this._focusHighlightLayerable && assert( this.focusHighlight instanceof Node, 'focusHighlight must be Node if highlight is layerable' ); - this._tagName.toUpperCase() === INPUT_TAG && assert( typeof this._inputType === 'string', ' inputType expected for input' ); + this.inputType && assert( this.tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support inputType' ); + this.pdomChecked && assert( this.tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support pdomChecked.' ); + this.inputValue && assert( this.tagName.toUpperCase() === INPUT_TAG, 'tagName must be INPUT to support inputValue' ); + this.pdomChecked && assert( INPUT_TYPES_THAT_SUPPORT_CHECKED.indexOf( this.inputType.toUpperCase() ) >= 0, `inputType does not support checked attribute: ${this.inputType}` ); + this.focusHighlightLayerable && assert( this.focusHighlight instanceof Node, 'focusHighlight must be Node if highlight is layerable' ); + this.tagName.toUpperCase() === INPUT_TAG && assert( typeof this.inputType === 'string', ' inputType expected for input' ); // note that most things that are not focusable by default need innerContent to be focusable on VoiceOver, // but this will catch most cases since often things that get added to the focus order have the application // role for custom input - this.ariaRole === 'application' && assert( this._innerContent, 'must have some innerContent or element will never be focusable in VoiceOver' ); + this.ariaRole === 'application' && assert( this.innerContent, 'must have some innerContent or element will never be focusable in VoiceOver' ); } for ( let i = 0; i < this.children.length; i++ ) { @@ -600,6 +601,15 @@ const ParallelDOM = { // pdom content to the PDOM. See https://github.com/phetsims/scenery/issues/795 /***********************************************************************************************************/ + /** + * @protected + * + * @param {string|null} accessibleName + */ + validateAccessibleName( accessibleName ) { + assert && assert( accessibleName === null || typeof accessibleName === 'string' ); + }, + /** * Set the Node's pdom content in a way that will define the Accessible Name for the browser. Different * HTML components and code situations require different methods of setting the Accessible Name. See @@ -612,13 +622,7 @@ const ParallelDOM = { * @param {string|null} accessibleName */ setAccessibleName: function( accessibleName ) { - assert && assert( accessibleName === null || typeof accessibleName === 'string' ); - - if ( this._accessibleName !== accessibleName ) { - this._accessibleName = accessibleName; - - this.onPDOMContentChange(); - } + this._record._set_( 'accessibleName', accessibleName ); }, set accessibleName( accessibleName ) { this.setAccessibleName( accessibleName ); }, @@ -631,7 +635,7 @@ const ParallelDOM = { * @returns {string|null} */ getAccessibleName: function() { - return this._accessibleName; + return this._record._get_( 'accessibleName' ); }, get accessibleName() { return this.getAccessibleName(); }, @@ -641,10 +645,18 @@ const ParallelDOM = { * @public */ removeFromPDOM: function() { - assert && assert( this._tagName !== null, 'There is no pdom content to clear from the PDOM' ); + assert && assert( this.tagName !== null, 'There is no pdom content to clear from the PDOM' ); this.tagName = null; }, + /** + * @protected + * + * @param {PDOMBehaviorFunctionDef} accessibleNameBehavior + */ + validateAccessibleNameBehavior( accessibleNameBehavior ) { + assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( accessibleNameBehavior ); + }, /** * accessibleNameBehavior is a function that will set the appropriate options on this node to get the desired @@ -670,14 +682,7 @@ const ParallelDOM = { * @param {PDOMBehaviorFunctionDef} accessibleNameBehavior */ setAccessibleNameBehavior: function( accessibleNameBehavior ) { - assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( accessibleNameBehavior ); - - if ( this._accessibleNameBehavior !== accessibleNameBehavior ) { - - this._accessibleNameBehavior = accessibleNameBehavior; - - this.onPDOMContentChange(); - } + this._record._set_( 'accessibleNameBehavior', accessibleNameBehavior ); }, set accessibleNameBehavior( accessibleNameBehavior ) { this.setAccessibleNameBehavior( accessibleNameBehavior ); }, @@ -689,10 +694,18 @@ const ParallelDOM = { * @returns {function} */ getAccessibleNameBehavior: function() { - return this._accessibleNameBehavior; + return this._record._get_( 'accessibleNameBehavior' ); }, get accessibleNameBehavior() { return this.getAccessibleNameBehavior(); }, + /** + * @protected + * + * @param {string|null} pdomHeading + */ + validatePDOMHeading( pdomHeading ) { + assert && assert( pdomHeading === null || typeof pdomHeading === 'string' ); + }, /** * Set the Node heading content. This by default will be a heading tag whose level is dependent on how many parents @@ -703,13 +716,7 @@ const ParallelDOM = { * @param {string|null} pdomHeading */ setPDOMHeading: function( pdomHeading ) { - assert && assert( pdomHeading === null || typeof pdomHeading === 'string' ); - - if ( this._pdomHeading !== pdomHeading ) { - this._pdomHeading = pdomHeading; - - this.onPDOMContentChange(); - } + this._record._set_( 'pdomHeading', pdomHeading ); }, set pdomHeading( pdomHeading ) { this.setPDOMHeading( pdomHeading ); }, @@ -721,10 +728,18 @@ const ParallelDOM = { * @returns {string|null} */ getPDOMHeading: function() { - return this._pdomHeading; + return this._record._get_( 'pdomHeading' ); }, get pdomHeading() { return this.getPDOMHeading(); }, + /** + * @protected + * + * @param {PDOMBehaviorFunctionDef} pdomHeadingBehavior + */ + validatePDOMHeadingBehavior( pdomHeadingBehavior ) { + assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( pdomHeadingBehavior ); + }, /** * Set the behavior of how `this.pdomHeading` is set in the PDOM. See default behavior function for more @@ -735,14 +750,7 @@ const ParallelDOM = { * @param {PDOMBehaviorFunctionDef} pdomHeadingBehavior */ setPDOMHeadingBehavior: function( pdomHeadingBehavior ) { - assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( pdomHeadingBehavior ); - - if ( this._pdomHeadingBehavior !== pdomHeadingBehavior ) { - - this._pdomHeadingBehavior = pdomHeadingBehavior; - - this.onPDOMContentChange(); - } + this._record._set_( 'pdomHeadingBehavior', pdomHeadingBehavior ); }, set pdomHeadingBehavior( pdomHeadingBehavior ) { this.setPDOMHeadingBehavior( pdomHeadingBehavior ); }, @@ -754,10 +762,19 @@ const ParallelDOM = { * @returns {function} */ getPDOMHeadingBehavior: function() { - return this._pdomHeadingBehavior; + return this._record._get_( 'pdomHeadingBehavior' ); }, get pdomHeadingBehavior() { return this.getPDOMHeadingBehavior(); }, + /** + * @private + * + * @param {number|null} headingLevel + */ + setHeadingLevel( headingLevel ) { + this._record._set_( 'headingLevel', headingLevel ); + }, + set headingLevel( value ) { this.setHeadingLevel( value ); }, /** * Get the tag name of the DOM element representing this node for accessibility. @@ -767,7 +784,7 @@ const ParallelDOM = { * @returns {number|null} */ getHeadingLevel: function() { - return this._headingLevel; + return this._record._get_( 'headingLevel' ); }, get headingLevel() { return this.getHeadingLevel(); }, @@ -782,28 +799,39 @@ const ParallelDOM = { * @returns {number} */ computeHeadingLevel: function() { + const pdomParent = this.pdomParent; + const pdomHeading = this.pdomHeading; - // TODO: assert??? assert( this.headingLevel || this._pdomParent); see https://github.com/phetsims/scenery/issues/855 + // TODO: assert??? assert( this.headingLevel || this.pdomParent); see https://github.com/phetsims/scenery/issues/855 // Either ^ which may break during construction, or V (below) // base case to heading level 1 - if ( !this._pdomParent ) { - if ( this._pdomHeading ) { - this._headingLevel = 1; + if ( !pdomParent ) { + if ( pdomHeading ) { + this.headingLevel = 1; return 1; } return 0; // so that the first node with a heading is headingLevel 1 } - if ( this._pdomHeading ) { - const level = this._pdomParent.computeHeadingLevel() + 1; - this._headingLevel = level; + if ( pdomHeading ) { + const level = pdomParent.computeHeadingLevel() + 1; + this.headingLevel = level; return level; } else { - return this._pdomParent.computeHeadingLevel(); + return pdomParent.computeHeadingLevel(); } }, + /** + * @protected + * + * @param {string|null} helpText + */ + validateHelpText( helpText ) { + assert && assert( helpText === null || typeof helpText === 'string' ); + }, + /** * Set the help text for a Node. See setAccessibleNameBehavior for details on how this string is * rendered in the PDOM. Null will clear the help text for this Node. @@ -813,14 +841,7 @@ const ParallelDOM = { * @param {string|null} helpText */ setHelpText: function( helpText ) { - assert && assert( helpText === null || typeof helpText === 'string' ); - - if ( this._helpText !== helpText ) { - - this._helpText = helpText; - - this.onPDOMContentChange(); - } + this._record._set_( 'helpText', helpText ); }, set helpText( helpText ) { this.setHelpText( helpText ); }, @@ -832,10 +853,19 @@ const ParallelDOM = { * @returns {string|null} */ getHelpText: function() { - return this._helpText; + return this._record._get_( 'helpText' ); }, get helpText() { return this.getHelpText(); }, + /** + * @protected + * + * @param {PDOMBehaviorFunctionDef} helpTextBehavior + */ + validateHelpTextBehavior( helpTextBehavior ) { + assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( helpTextBehavior ); + }, + /** * helpTextBehavior is a function that will set the appropriate options on this node to get the desired * "Help Text". @@ -845,14 +875,7 @@ const ParallelDOM = { * @param {PDOMBehaviorFunctionDef} helpTextBehavior */ setHelpTextBehavior: function( helpTextBehavior ) { - assert && PDOMBehaviorFunctionDef.validatePDOMBehaviorFunctionDef( helpTextBehavior ); - - if ( this._helpTextBehavior !== helpTextBehavior ) { - - this._helpTextBehavior = helpTextBehavior; - - this.onPDOMContentChange(); - } + this._record._set_( 'helpTextBehavior', helpTextBehavior ); }, set helpTextBehavior( helpTextBehavior ) { this.setHelpTextBehavior( helpTextBehavior ); }, @@ -864,7 +887,7 @@ const ParallelDOM = { * @returns {function} */ getHelpTextBehavior: function() { - return this._helpTextBehavior; + return this._record._get_( 'helpTextBehavior' ); }, get helpTextBehavior() { return this.getHelpTextBehavior(); }, @@ -873,6 +896,15 @@ const ParallelDOM = { // LOWER LEVEL GETTERS AND SETTERS FOR PDOM API OPTIONS /***********************************************************************************************************/ + /** + * @protected + * + * @param {string|null} tagName + */ + validateTagName( tagName ) { + assert && assert( tagName === null || typeof tagName === 'string' ); + }, + /** * Set the tag name for the primary sibling in the PDOM. DOM element tag names are read-only, so this * function will create a new DOM element each time it is called for the Node's PDOMPeer and @@ -882,14 +914,7 @@ const ParallelDOM = { * @param {string|null} tagName */ setTagName: function( tagName ) { - assert && assert( tagName === null || typeof tagName === 'string' ); - - if ( tagName !== this._tagName ) { - this._tagName = tagName; - - // TODO: this could be setting PDOM content twice - this.onPDOMContentChange(); - } + this._record._set_( 'tagName', tagName ); }, set tagName( tagName ) { this.setTagName( tagName ); }, @@ -900,10 +925,19 @@ const ParallelDOM = { * @returns {string|null} */ getTagName: function() { - return this._tagName; + return this._record._get_( 'tagName' ); }, get tagName() { return this.getTagName(); }, + /** + * @protected + * + * @param {string|null} tagName + */ + validateLabelTagName( tagName ) { + assert && assert( tagName === null || typeof tagName === 'string' ); + }, + /** * Set the tag name for the accessible label sibling for this Node. DOM element tag names are read-only, * so this will require creating a new PDOMPeer for this Node (reconstructing all DOM Elements). If @@ -914,13 +948,7 @@ const ParallelDOM = { * @param {string|null} tagName */ setLabelTagName: function( tagName ) { - assert && assert( tagName === null || typeof tagName === 'string' ); - - if ( tagName !== this._labelTagName ) { - this._labelTagName = tagName; - - this.onPDOMContentChange(); - } + this._record._set_( 'labelTagName', tagName ); }, set labelTagName( tagName ) { this.setLabelTagName( tagName ); }, @@ -931,10 +959,19 @@ const ParallelDOM = { * @returns {string|null} */ getLabelTagName: function() { - return this._labelTagName; + return this._record._get_( 'labelTagName' ); }, get labelTagName() { return this.getLabelTagName(); }, + /** + * @protected + * + * @param {string|null} tagName + */ + validateDescriptionTagName( tagName ) { + assert && assert( tagName === null || typeof tagName === 'string' ); + }, + /** * Set the tag name for the description sibling. HTML element tag names are read-only, so this will require creating * a new HTML element, and inserting it into the DOM. The tag name provided must support @@ -947,14 +984,7 @@ const ParallelDOM = { * @param {string|null} tagName */ setDescriptionTagName: function( tagName ) { - assert && assert( tagName === null || typeof tagName === 'string' ); - - if ( tagName !== this._descriptionTagName ) { - - this._descriptionTagName = tagName; - - this.onPDOMContentChange(); - } + this._record._set_( 'descriptionTagName', tagName ); }, set descriptionTagName( tagName ) { this.setDescriptionTagName( tagName ); }, @@ -965,37 +995,49 @@ const ParallelDOM = { * @returns {string|null} */ getDescriptionTagName: function() { - return this._descriptionTagName; + return this._record._get_( 'descriptionTagName' ); }, get descriptionTagName() { return this.getDescriptionTagName(); }, /** - * Sets the type for an input element. Element must have the INPUT tag name. The input attribute is not - * specified as readonly, so invalidating pdom content is not necessary. - * @public + * @protected * * @param {string|null} inputType */ - setInputType: function( inputType ) { + validateInputType( inputType ) { assert && assert( inputType === null || typeof inputType === 'string' ); - assert && this.tagName && assert( this._tagName.toUpperCase() === INPUT_TAG, 'tag name must be INPUT to support inputType' ); - - if ( inputType !== this._inputType ) { + assert && this.tagName && assert( this.tagName.toUpperCase() === INPUT_TAG, 'tag name must be INPUT to support inputType' ); + }, - this._inputType = inputType; - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - const peer = this._pdomInstances[ i ].peer; + /** + * @protected + * + * @param {string|null} inputType + */ + onAfterInputType( inputType ) { + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + const peer = this._pdomInstances[ i ].peer; - // remove the attribute if cleared by setting to 'null' - if ( inputType === null ) { - peer.removeAttributeFromElement( 'type' ); - } - else { - peer.setAttributeToElement( 'type', inputType ); - } + // remove the attribute if cleared by setting to 'null' + if ( inputType === null ) { + peer.removeAttributeFromElement( 'type' ); + } + else { + peer.setAttributeToElement( 'type', inputType ); } } }, + + /** + * Sets the type for an input element. Element must have the INPUT tag name. The input attribute is not + * specified as readonly, so invalidating pdom content is not necessary. + * @public + * + * @param {string|null} inputType + */ + setInputType: function( inputType ) { + this._record._set_( 'inputType', inputType ); + }, set inputType( inputType ) { this.setInputType( inputType ); }, /** @@ -1005,10 +1047,19 @@ const ParallelDOM = { * @returns {string|null} */ getInputType: function() { - return this._inputType; + return this._record._get_( 'inputType' ); }, get inputType() { return this.getInputType(); }, + /** + * @protected + * + * @param {boolean} appendLabel + */ + validateAppendLabel( appendLabel ) { + assert && assert( typeof appendLabel === 'boolean' ); + }, + /** * By default the label will be prepended before the primary sibling in the PDOM. This * option allows you to instead have the label added after the primary sibling. Note: The label will always @@ -1025,13 +1076,7 @@ const ParallelDOM = { * @param {boolean} appendLabel */ setAppendLabel: function( appendLabel ) { - assert && assert( typeof appendLabel === 'boolean' ); - - if ( this._appendLabel !== appendLabel ) { - this._appendLabel = appendLabel; - - this.onPDOMContentChange(); - } + this._record._set_( 'appendLabel', appendLabel ); }, set appendLabel( appendLabel ) { this.setAppendLabel( appendLabel ); }, @@ -1042,10 +1087,19 @@ const ParallelDOM = { * @returns {boolean} */ getAppendLabel: function() { - return this._appendLabel; + return this._record._get_( 'appendLabel' ); }, get appendLabel() { return this.getAppendLabel(); }, + /** + * @protected + * + * @param {boolean} appendDescription + */ + validateAppendDescription( appendDescription ) { + assert && assert( typeof appendDescription === 'boolean' ); + }, + /** * By default the label will be prepended before the primary sibling in the PDOM. This * option allows you to instead have the label added after the primary sibling. Note: The label will always @@ -1062,13 +1116,7 @@ const ParallelDOM = { * @param {boolean} appendDescription */ setAppendDescription: function( appendDescription ) { - assert && assert( typeof appendDescription === 'boolean' ); - - if ( this._appendDescription !== appendDescription ) { - this._appendDescription = appendDescription; - - this.onPDOMContentChange(); - } + this._record._set_( 'appendDescription', appendDescription ); }, set appendDescription( appendDescription ) { this.setAppendDescription( appendDescription ); }, @@ -1079,11 +1127,20 @@ const ParallelDOM = { * @returns {boolean} */ getAppendDescription: function() { - return this._appendDescription; + return this._record._get_( 'appendDescription' ); }, get appendDescription() { return this.getAppendDescription(); }, + /** + * @protected + * + * @param {string|null} tagName + */ + validateContainerTagName( tagName ) { + assert && assert( tagName === null || typeof tagName === 'string', `invalid tagName argument: ${tagName}` ); + }, + /** * Set the container parent tag name. By specifying this container parent, an element will be created that * acts as a container for this Node's primary sibling DOM Element and its label and description siblings. @@ -1103,12 +1160,7 @@ const ParallelDOM = { * @param {string|null} tagName */ setContainerTagName: function( tagName ) { - assert && assert( tagName === null || typeof tagName === 'string', `invalid tagName argument: ${tagName}` ); - - if ( this._containerTagName !== tagName ) { - this._containerTagName = tagName; - this.onPDOMContentChange(); - } + this._record._set_( 'containerTagName', tagName ); }, set containerTagName( tagName ) { this.setContainerTagName( tagName ); }, @@ -1119,10 +1171,36 @@ const ParallelDOM = { * @returns {string|null} */ getContainerTagName: function() { - return this._containerTagName; + return this._record._get_( 'containerTagName' ); }, get containerTagName() { return this.getContainerTagName(); }, + /** + * @protected + * + * @param {string|null} label + */ + validateLabelContent( label ) { + assert && assert( label === null || typeof label === 'string', 'label must be null or string' ); + }, + + /** + * @protected + * + * @param {string|null} label + */ + onAfterLabelContent( label ) { + // if trying to set labelContent, make sure that there is a labelTagName default + if ( !this.labelTagName ) { + this.setLabelTagName( DEFAULT_LABEL_TAG_NAME ); + } + + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + const peer = this._pdomInstances[ i ].peer; + peer.setLabelSiblingContent( this.labelContent ); + } + }, + /** * Set the content of the label sibling for the this node. The label sibling will default to the value of * DEFAULT_LABEL_TAG_NAME if no `labelTagName` is provided. If the label sibling is a `LABEL` html element, @@ -1136,21 +1214,7 @@ const ParallelDOM = { * @param {string|null} label */ setLabelContent: function( label ) { - assert && assert( label === null || typeof label === 'string', 'label must be null or string' ); - - if ( this._labelContent !== label ) { - this._labelContent = label; - - // if trying to set labelContent, make sure that there is a labelTagName default - if ( !this._labelTagName ) { - this.setLabelTagName( DEFAULT_LABEL_TAG_NAME ); - } - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - const peer = this._pdomInstances[ i ].peer; - peer.setLabelSiblingContent( this._labelContent ); - } - } + this._record._set_( 'labelContent', label ); }, set labelContent( label ) { this.setLabelContent( label ); }, @@ -1161,10 +1225,31 @@ const ParallelDOM = { * @returns {string|null} */ getLabelContent: function() { - return this._labelContent; + return this._record._get_( 'labelContent' ); }, get labelContent() { return this.getLabelContent(); }, + /** + * @protected + * + * @param {string|null} content + */ + validateInnerContent( content ) { + assert && assert( content === null || typeof content === 'string' ); + }, + + /** + * @protected + * + * @param {string|null} content + */ + onAfterInnerContent( content ) { + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + const peer = this._pdomInstances[ i ].peer; + peer.setPrimarySiblingContent( this.innerContent ); + } + }, + /** * Set the inner content for the primary sibling of the PDOMPeers of this Node. Will be set as textContent * unless content is html which uses exclusively formatting tags. A node with inner content cannot @@ -1174,16 +1259,7 @@ const ParallelDOM = { * @public */ setInnerContent: function( content ) { - assert && assert( content === null || typeof content === 'string' ); - - if ( this._innerContent !== content ) { - this._innerContent = content; - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - const peer = this._pdomInstances[ i ].peer; - peer.setPrimarySiblingContent( this._innerContent ); - } - } + this._record._set_( 'innerContent', content ); }, set innerContent( content ) { this.setInnerContent( content ); }, @@ -1194,10 +1270,36 @@ const ParallelDOM = { * @public */ getInnerContent: function() { - return this._innerContent; + return this._record._get_( 'innerContent' ); }, get innerContent() { return this.getInnerContent(); }, + /** + * @protected + * + * @param {string|null} descriptionContent + */ + validateDescriptionContent( descriptionContent ) { + assert && assert( descriptionContent === null || typeof descriptionContent === 'string', 'description must be null or string' ); + }, + + /** + * @protected + * + * @param {string|null} descriptionContent + */ + onAfterDescriptionContent( descriptionContent ) { + // if there is no description element, assume that a paragraph element should be used + if ( !this.descriptionTagName ) { + this.setDescriptionTagName( DEFAULT_DESCRIPTION_TAG_NAME ); + } + + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + const peer = this._pdomInstances[ i ].peer; + peer.setDescriptionSiblingContent( this.descriptionContent ); + } + }, + /** * Set the description content for this Node's primary sibling. The description sibling tag name must support * innerHTML and textContent. If a description element does not exist yet, a default @@ -1207,21 +1309,7 @@ const ParallelDOM = { * @param {string|null} descriptionContent */ setDescriptionContent: function( descriptionContent ) { - assert && assert( descriptionContent === null || typeof descriptionContent === 'string', 'description must be null or string' ); - - if ( this._descriptionContent !== descriptionContent ) { - this._descriptionContent = descriptionContent; - - // if there is no description element, assume that a paragraph element should be used - if ( !this._descriptionTagName ) { - this.setDescriptionTagName( DEFAULT_DESCRIPTION_TAG_NAME ); - } - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - const peer = this._pdomInstances[ i ].peer; - peer.setDescriptionSiblingContent( this._descriptionContent ); - } - } + this._record._set_( 'descriptionContent', descriptionContent ); }, set descriptionContent( textContent ) { this.setDescriptionContent( textContent ); }, @@ -1232,10 +1320,33 @@ const ParallelDOM = { * @returns {string|null} */ getDescriptionContent: function() { - return this._descriptionContent; + return this._record._get_( 'descriptionContent' ); }, get descriptionContent() { return this.getDescriptionContent(); }, + /** + * @protected + * + * @param {string|null} ariaRole + */ + validateAriaRole( ariaRole ) { + assert && assert( ariaRole === null || typeof ariaRole === 'string' ); + }, + + /** + * @protected + * + * @param {string|null} ariaRole + */ + onAfterAriaRole( ariaRole ) { + if ( ariaRole !== null ) { + this.setPDOMAttribute( 'role', ariaRole ); + } + else { + this.removePDOMAttribute( 'role' ); + } + }, + /** * Set the ARIA role for this Node's primary sibling. According to the W3C, the ARIA role is read-only for a DOM * element. So this will create a new DOM element for this Node with the desired role, and replace the old @@ -1250,19 +1361,7 @@ const ParallelDOM = { * for a list of roles, states, and properties. */ setAriaRole: function( ariaRole ) { - assert && assert( ariaRole === null || typeof ariaRole === 'string' ); - - if ( this._ariaRole !== ariaRole ) { - - this._ariaRole = ariaRole; - - if ( ariaRole !== null ) { - this.setPDOMAttribute( 'role', ariaRole ); - } - else { - this.removePDOMAttribute( 'role' ); - } - } + this._record._set_( 'ariaRole', ariaRole ); }, set ariaRole( ariaRole ) { this.setAriaRole( ariaRole ); }, @@ -1273,10 +1372,40 @@ const ParallelDOM = { * @returns {string|null} */ getAriaRole: function() { - return this._ariaRole; + return this._record._get_( 'ariaRole' ); }, get ariaRole() { return this.getAriaRole(); }, + /** + * @protected + * + * @param {string|null} ariaRole + */ + validateContainerAriaRole( ariaRole ) { + assert && assert( ariaRole === null || typeof ariaRole === 'string' ); + }, + + /** + * @protected + * + * @param {string|null} ariaRole + */ + onAfterContainerAriaRole( ariaRole ) { + // clear out the attribute + if ( ariaRole === null ) { + this.removePDOMAttribute( 'role', { + elementName: PDOMPeer.CONTAINER_PARENT + } ); + } + + // add the attribute + else { + this.setPDOMAttribute( 'role', ariaRole, { + elementName: PDOMPeer.CONTAINER_PARENT + } ); + } + }, + /** * Set the ARIA role for this node's container parent element. According to the W3C, the ARIA role is read-only * for a DOM element. This will create a new DOM element for the container parent with the desired role, and @@ -1288,26 +1417,7 @@ const ParallelDOM = { * for a list of roles, states, and properties. */ setContainerAriaRole: function( ariaRole ) { - assert && assert( ariaRole === null || typeof ariaRole === 'string' ); - - if ( this._containerAriaRole !== ariaRole ) { - - this._containerAriaRole = ariaRole; - - // clear out the attribute - if ( this._containerAriaRole === null ) { - this.removePDOMAttribute( 'role', { - elementName: PDOMPeer.CONTAINER_PARENT - } ); - } - - // add the attribute - else { - this.setPDOMAttribute( 'role', ariaRole, { - elementName: PDOMPeer.CONTAINER_PARENT - } ); - } - } + this._record._set_( 'containerAriaRole', ariaRole ); }, set containerAriaRole( ariaRole ) { this.setContainerAriaRole( ariaRole ); }, @@ -1317,10 +1427,33 @@ const ParallelDOM = { * @returns {string|null} */ getContainerAriaRole: function() { - return this._containerAriaRole; + return this._record._get_( 'containerAriaRole' ); }, get containerAriaRole() { return this.getContainerAriaRole(); }, + /** + * @protected + * + * @param {string|null} ariaValueText + */ + validateAriaValueText( ariaValueText ) { + assert && assert( ariaValueText === null || typeof ariaValueText === 'string' ); + }, + + /** + * @protected + * + * @param {string|null} ariaValueText + */ + onAfterAriaValueText( ariaValueText ) { + if ( ariaValueText === null ) { + this.removePDOMAttribute( 'aria-valuetext' ); + } + else { + this.setPDOMAttribute( 'aria-valuetext', ariaValueText ); + } + }, + /** * Set the aria-valuetext of this Node independently from the changing value, if necessary. Setting to null will * clear this attribute. @@ -1329,18 +1462,7 @@ const ParallelDOM = { * @param {string|null} ariaValueText */ setAriaValueText: function( ariaValueText ) { - assert && assert( ariaValueText === null || typeof ariaValueText === 'string' ); - - if ( this._ariaValueText !== ariaValueText ) { - this._ariaValueText = ariaValueText; - - if ( this._ariaValueText === null ) { - this.removePDOMAttribute( 'aria-valuetext' ); - } - else { - this.setPDOMAttribute( 'aria-valuetext', ariaValueText ); - } - } + this._record._set_( 'ariaValueText', ariaValueText ); }, set ariaValueText( ariaValueText ) { this.setAriaValueText( ariaValueText ); }, @@ -1352,10 +1474,19 @@ const ParallelDOM = { * @returns {string|null} */ getAriaValueText: function() { - return this._ariaValueText; + return this._record._get_( 'ariaValueText' ); }, get ariaValueText() { return this.getAriaValueText(); }, + /** + * @protected + * + * @param {string|null} pdomNamespace + */ + validatePDOMNamespace( pdomNamespace ) { + assert && assert( pdomNamespace === null || typeof pdomNamespace === 'string' ); + }, + /** * Sets the namespace for the primary element (relevant for MathML/SVG/etc.) * @public @@ -1370,15 +1501,7 @@ const ParallelDOM = { * @returns {Node} - For chaining */ setPDOMNamespace: function( pdomNamespace ) { - assert && assert( pdomNamespace === null || typeof pdomNamespace === 'string' ); - - if ( this._pdomNamespace !== pdomNamespace ) { - this._pdomNamespace = pdomNamespace; - - // If the namespace changes, tear down the view and redraw the whole thing, there is no easy mutable solution here. - this.onPDOMContentChange(); - } - + this._record._set_( 'pdomNamespace', pdomNamespace ); return this; }, set pdomNamespace( value ) { this.setPDOMNamespace( value ); }, @@ -1390,10 +1513,33 @@ const ParallelDOM = { * @returns {string|null} */ getPDOMNamespace: function() { - return this._pdomNamespace; + return this._record._get_( 'pdomNamespace' ); }, get pdomNamespace() { return this.getPDOMNamespace(); }, + /** + * @protected + * + * @param {string|null} ariaLabel + */ + validateAriaLabel( ariaLabel ) { + assert && assert( ariaLabel === null || typeof ariaLabel === 'string' ); + }, + + /** + * @protected + * + * @param {string|null} ariaLabel + */ + onAfterAriaLabel( ariaLabel ) { + if ( ariaLabel === null ) { + this.removePDOMAttribute( 'aria-label' ); + } + else { + this.setPDOMAttribute( 'aria-label', ariaLabel ); + } + }, + /** * Sets the 'aria-label' attribute for labelling the Node's primary sibling. By using the * 'aria-label' attribute, the label will be read on focus, but can not be found with the @@ -1401,20 +1547,9 @@ const ParallelDOM = { * @public * * @param {string|null} ariaLabel - the text for the aria label attribute - */ - setAriaLabel: function( ariaLabel ) { - assert && assert( ariaLabel === null || typeof ariaLabel === 'string' ); - - if ( this._ariaLabel !== ariaLabel ) { - this._ariaLabel = ariaLabel; - - if ( this._ariaLabel === null ) { - this.removePDOMAttribute( 'aria-label' ); - } - else { - this.setPDOMAttribute( 'aria-label', ariaLabel ); - } - } + */ + setAriaLabel: function( ariaLabel ) { + this._record._set_( 'ariaLabel', ariaLabel ); }, set ariaLabel( ariaLabel ) { this.setAriaLabel( ariaLabel ); }, @@ -1425,38 +1560,50 @@ const ParallelDOM = { * @returns {string|null} */ getAriaLabel: function() { - return this._ariaLabel; + return this._record._get_( 'ariaLabel' ); }, get ariaLabel() { return this.getAriaLabel(); }, /** - * Set the focus highlight for this node. By default, the focus highlight will be a pink rectangle that - * surrounds the node's local bounds. If focus highlight is set to 'invisible', the node will not have - * any highlighting when it receives focus. - * @public + * @protected * * @param {Node|Shape|string.<'invisible'>} focusHighlight */ - setFocusHighlight: function( focusHighlight ) { + validateFocusHighlight( focusHighlight ) { assert && assert( focusHighlight === null || focusHighlight instanceof Node || focusHighlight instanceof Shape || focusHighlight === 'invisible' ); + }, - if ( this._focusHighlight !== focusHighlight ) { - this._focusHighlight = focusHighlight; + /** + * @protected + * + * @param {Node|Shape|string.<'invisible'>} focusHighlight + */ + onAfterFocusHighlight( focusHighlight ) { + // if the focus highlight is layerable in the scene graph, update visibility so that it is only + // visible when associated node has focus + if ( this.focusHighlightLayerable ) { - // if the focus highlight is layerable in the scene graph, update visibility so that it is only - // visible when associated node has focus - if ( this._focusHighlightLayerable ) { + // if focus highlight is layerable, it must be a node in the scene graph + assert && assert( focusHighlight instanceof Node ); + focusHighlight.visible = this.focused; + } - // if focus highlight is layerable, it must be a node in the scene graph - assert && assert( focusHighlight instanceof Node ); - focusHighlight.visible = this.focused; - } + this.focusHighlightChangedEmitter.emit(); + }, - this.focusHighlightChangedEmitter.emit(); - } + /** + * Set the focus highlight for this node. By default, the focus highlight will be a pink rectangle that + * surrounds the node's local bounds. If focus highlight is set to 'invisible', the node will not have + * any highlighting when it receives focus. + * @public + * + * @param {Node|Shape|string.<'invisible'>} focusHighlight + */ + setFocusHighlight: function( focusHighlight ) { + this._record._set_( 'focusHighlight', focusHighlight ); }, set focusHighlight( focusHighlight ) { this.setFocusHighlight( focusHighlight ); }, @@ -1467,10 +1614,35 @@ const ParallelDOM = { * @returns {Node|Shape|string<'invisible'>} */ getFocusHighlight: function() { - return this._focusHighlight; + return this._record._get_( 'focusHighlight' ); }, get focusHighlight() { return this.getFocusHighlight(); }, + /** + * @protected + * + * @param {boolean} focusHighlightLayerable + */ + validateFocusHighlightLayerable( focusHighlightLayerable ) { + assert && assert( typeof focusHighlightLayerable === 'boolean' ); + }, + + /** + * @protected + * + * @param {boolean} focusHighlightLayerable + */ + onAfterFocusHighlightLayerable( focusHighlightLayerable ) { + const focusHighlight = this.focusHighlight; + + // if a focus highlight is defined (it must be a node), update its visibility so it is linked to focus + // of the associated node + if ( focusHighlight ) { + assert && assert( focusHighlight instanceof Node ); + focusHighlight.visible = this.focused; + } + }, + /** * Setting a flag to break default and allow the focus highlight to be (z) layered into the scene graph. * This will set the visibility of the layered focus highlight, it will always be invisible until this node has @@ -1480,17 +1652,7 @@ const ParallelDOM = { * @param {Boolean} focusHighlightLayerable */ setFocusHighlightLayerable: function( focusHighlightLayerable ) { - - if ( this._focusHighlightLayerable !== focusHighlightLayerable ) { - this._focusHighlightLayerable = focusHighlightLayerable; - - // if a focus highlight is defined (it must be a node), update its visibility so it is linked to focus - // of the associated node - if ( this._focusHighlight ) { - assert && assert( this._focusHighlight instanceof Node ); - this._focusHighlight.visible = this.focused; - } - } + this._record._set_( 'focusHighlightLayerable', focusHighlightLayerable ); }, set focusHighlightLayerable( focusHighlightLayerable ) { this.setFocusHighlightLayerable( focusHighlightLayerable ); }, @@ -1501,10 +1663,19 @@ const ParallelDOM = { * @returns {Boolean} */ getFocusHighlightLayerable: function() { - return this._focusHighlightLayerable; + return this._record._get_( 'focusHighlightLayerable' ); }, get focusHighlightLayerable() { return this.getFocusHighlightLayerable(); }, + /** + * @protected + * + * @param {boolean|Node} groupHighlight + */ + validateGroupFocusHighlight( groupHighlight ) { + assert && assert( typeof groupHighlight === 'boolean' || groupHighlight instanceof Node ); + }, + /** * Set whether or not this node has a group focus highlight. If this node has a group focus highlight, an extra * focus highlight will surround this node whenever a descendant node has focus. Generally @@ -1517,8 +1688,7 @@ const ParallelDOM = { * @param {boolean|Node} groupHighlight */ setGroupFocusHighlight: function( groupHighlight ) { - assert && assert( typeof groupHighlight === 'boolean' || groupHighlight instanceof Node ); - this._groupFocusHighlight = groupHighlight; + this._record._set_( 'groupFocusHighlight', groupHighlight ); }, set groupFocusHighlight( groupHighlight ) { this.setGroupFocusHighlight( groupHighlight ); }, @@ -1526,10 +1696,10 @@ const ParallelDOM = { * Get whether or not this node has a 'group' focus highlight, see setter for more information. * @public * - * @returns {Boolean} + * @returns {boolean|Node} */ getGroupFocusHighlight: function() { - return this._groupFocusHighlight; + return this._record._get_( 'groupFocusHighlight' ); }, get groupFocusHighlight() { return this.getGroupFocusHighlight(); }, @@ -1537,7 +1707,7 @@ const ParallelDOM = { /** * Very similar algorithm to setChildren in Node.js * @public - * @param {Array.} ariaLabelledbyAssociations - list of associationObjects, see this._ariaLabelledbyAssociations. + * @param {Array.} ariaLabelledbyAssociations - list of associationObjects, see this.ariaLabelledbyAssociations. */ setAriaLabelledbyAssociations: function( ariaLabelledbyAssociations ) { let associationObject; @@ -1553,7 +1723,7 @@ const ParallelDOM = { } // no work to be done if both are empty, return early - if ( ariaLabelledbyAssociations.length === 0 && this._ariaLabelledbyAssociations.length === 0 ) { + if ( ariaLabelledbyAssociations.length === 0 && this._record._getLength_( 'ariaLabelledbyAssociations' ) === 0 ) { return; } @@ -1562,7 +1732,7 @@ const ParallelDOM = { const inBoth = []; // Child nodes that "stay". Will be ordered for the "after" case. // get a difference of the desired new list, and the old - arrayDifference( ariaLabelledbyAssociations, this._ariaLabelledbyAssociations, afterOnly, beforeOnly, inBoth ); + arrayDifference( ariaLabelledbyAssociations, this.ariaLabelledbyAssociations, afterOnly, beforeOnly, inBoth ); // remove each current associationObject that isn't in the new list for ( i = 0; i < beforeOnly.length; i++ ) { @@ -1570,7 +1740,7 @@ const ParallelDOM = { this.removeAriaLabelledbyAssociation( associationObject ); } - assert && assert( this._ariaLabelledbyAssociations.length === inBoth.length, + assert && assert( this.ariaLabelledbyAssociations.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 @@ -1586,7 +1756,7 @@ const ParallelDOM = { * @returns {Array.} - the list of current association objects */ getAriaLabelledbyAssociations: function() { - return this._ariaLabelledbyAssociations; + return this._record._get_( 'ariaLabelledbyAssociations' ); }, get ariaLabelledbyAssociations() { return this.getAriaLabelledbyAssociations(); }, @@ -1607,13 +1777,15 @@ const ParallelDOM = { addAriaLabelledbyAssociation: function( associationObject ) { assert && PDOMUtils.validateAssociationObject( associationObject ); + const ariaLabelledbyAssociations = this._record._get_mutable_( 'ariaLabelledbyAssociations' ); + // TODO: assert if this associationObject is already in the association objects list! https://github.com/phetsims/scenery/issues/832 - this._ariaLabelledbyAssociations.push( associationObject ); // Keep track of this association. + ariaLabelledbyAssociations.push( associationObject ); // Keep track of this association. // Flag that this node is is being labelled by the other node, so that if the other node changes it can tell // this node to restore the association appropriately. - associationObject.otherNode._nodesThatAreAriaLabelledbyThisNode.push( this ); + associationObject.otherNode._record._get_mutable_( 'nodesThatAreAriaLabelledbyThisNode' ).push( this ); this.updateAriaLabelledbyAssociationsInPeers(); }, @@ -1623,10 +1795,12 @@ const ParallelDOM = { * @public */ removeAriaLabelledbyAssociation: function( associationObject ) { - assert && assert( _.includes( this._ariaLabelledbyAssociations, associationObject ) ); + const ariaLabelledbyAssociations = this._record._get_mutable_( 'ariaLabelledbyAssociations' ); + + assert && assert( _.includes( ariaLabelledbyAssociations, associationObject ) ); // remove the - const removedObject = this._ariaLabelledbyAssociations.splice( _.indexOf( this._ariaLabelledbyAssociations, associationObject ), 1 ); + const removedObject = ariaLabelledbyAssociations.splice( _.indexOf( ariaLabelledbyAssociations, associationObject ), 1 ); // remove the reference from the other node back to this node because we don't need it anymore removedObject[ 0 ].otherNode.removeNodeThatIsAriaLabelledByThisNode( this ); @@ -1641,9 +1815,12 @@ const ParallelDOM = { */ removeNodeThatIsAriaLabelledByThisNode: function( node ) { assert && assert( node instanceof Node ); - const indexOfNode = _.indexOf( this._nodesThatAreAriaLabelledbyThisNode, node ); + + const nodesThatAreAriaLabelledbyThisNode = this._record._get_mutable_( 'nodesThatAreAriaLabelledbyThisNode' ); + + const indexOfNode = _.indexOf( nodesThatAreAriaLabelledbyThisNode, node ); assert && assert( indexOfNode >= 0 ); - this._nodesThatAreAriaLabelledbyThisNode.splice( indexOfNode, 1 ); + nodesThatAreAriaLabelledbyThisNode.splice( indexOfNode, 1 ); }, /** @@ -1662,11 +1839,12 @@ const ParallelDOM = { * @public (scenery-internal) */ updateOtherNodesAriaLabelledby: function() { + const nodesThatAreAriaLabelledbyThisNode = this.nodesThatAreAriaLabelledbyThisNode; // if any other nodes are aria-labelledby this Node, update those associations too. Since this node's // pdom content needs to be recreated, they need to update their aria-labelledby associations accordingly. - for ( let i = 0; i < this._nodesThatAreAriaLabelledbyThisNode.length; i++ ) { - const otherNode = this._nodesThatAreAriaLabelledbyThisNode[ i ]; + for ( let i = 0; i < nodesThatAreAriaLabelledbyThisNode.length; i++ ) { + const otherNode = nodesThatAreAriaLabelledbyThisNode[ i ]; otherNode.updateAriaLabelledbyAssociationsInPeers(); } }, @@ -1678,14 +1856,14 @@ const ParallelDOM = { * @returns {Array.} */ getNodesThatAreAriaLabelledbyThisNode: function() { - return this._nodesThatAreAriaLabelledbyThisNode; + return this._record._get_( 'nodesThatAreAriaLabelledbyThisNode' ); }, get nodesThatAreAriaLabelledbyThisNode() { return this.getNodesThatAreAriaLabelledbyThisNode(); }, /** * @public - * @param {Array.} ariaDescribedbyAssociations - list of associationObjects, see this._ariaDescribedbyAssociations. + * @param {Array.} ariaDescribedbyAssociations - list of associationObjects, see this.ariaDescribedbyAssociations. */ setAriaDescribedbyAssociations: function( ariaDescribedbyAssociations ) { let associationObject; @@ -1698,7 +1876,7 @@ const ParallelDOM = { } // no work to be done if both are empty - if ( ariaDescribedbyAssociations.length === 0 && this._ariaDescribedbyAssociations.length === 0 ) { + if ( ariaDescribedbyAssociations.length === 0 && this.ariaDescribedbyAssociations.length === 0 ) { return; } @@ -1708,7 +1886,7 @@ const ParallelDOM = { let i; // get a difference of the desired new list, and the old - arrayDifference( ariaDescribedbyAssociations, this._ariaDescribedbyAssociations, afterOnly, beforeOnly, inBoth ); + arrayDifference( ariaDescribedbyAssociations, this.ariaDescribedbyAssociations, afterOnly, beforeOnly, inBoth ); // remove each current associationObject that isn't in the new list for ( i = 0; i < beforeOnly.length; i++ ) { @@ -1716,7 +1894,7 @@ const ParallelDOM = { this.removeAriaDescribedbyAssociation( associationObject ); } - assert && assert( this._ariaDescribedbyAssociations.length === inBoth.length, + assert && assert( this.ariaDescribedbyAssociations.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 @@ -1732,7 +1910,7 @@ const ParallelDOM = { * @returns {Array.} - the list of current association objects */ getAriaDescribedbyAssociations: function() { - return this._ariaDescribedbyAssociations; + return this._record._get_( 'ariaDescribedbyAssociations' ); }, get ariaDescribedbyAssociations() { return this.getAriaDescribedbyAssociations(); }, @@ -1751,13 +1929,15 @@ const ParallelDOM = { */ addAriaDescribedbyAssociation: function( associationObject ) { assert && PDOMUtils.validateAssociationObject( associationObject ); - assert && assert( !_.includes( this._ariaDescribedbyAssociations, associationObject ), 'describedby association already registed' ); + assert && assert( !_.includes( this.ariaDescribedbyAssociations, associationObject ), 'describedby association already registed' ); + + const ariaDescribedbyAssociations = this._record._get_mutable_( 'ariaDescribedbyAssociations' ); - this._ariaDescribedbyAssociations.push( associationObject ); // Keep track of this association. + 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. - associationObject.otherNode._nodesThatAreAriaDescribedbyThisNode.push( this ); + associationObject.otherNode._record._get_mutable_( 'nodesThatAreAriaDescribedbyThisNode' ).push( this ); // update the PDOMPeers with this aria-describedby association this.updateAriaDescribedbyAssociationsInPeers(); @@ -1769,7 +1949,7 @@ const ParallelDOM = { * @returns {boolean} */ hasAriaDescribedbyAssociation: function( associationObject ) { - return _.includes( this._ariaDescribedbyAssociations, associationObject ); + return _.includes( this.ariaDescribedbyAssociations, associationObject ); }, /** @@ -1777,10 +1957,12 @@ const ParallelDOM = { * @public */ removeAriaDescribedbyAssociation: function( associationObject ) { - assert && assert( _.includes( this._ariaDescribedbyAssociations, associationObject ) ); + assert && assert( _.includes( this.ariaDescribedbyAssociations, associationObject ) ); + + const ariaDescribedbyAssociations = this._record._get_mutable_( 'ariaDescribedbyAssociations' ); // remove the - const removedObject = this._ariaDescribedbyAssociations.splice( _.indexOf( this._ariaDescribedbyAssociations, associationObject ), 1 ); + const removedObject = ariaDescribedbyAssociations.splice( _.indexOf( ariaDescribedbyAssociations, associationObject ), 1 ); // remove the reference from the other node back to this node because we don't need it anymore removedObject[ 0 ].otherNode.removeNodeThatIsAriaDescribedByThisNode( this ); @@ -1795,9 +1977,12 @@ const ParallelDOM = { */ removeNodeThatIsAriaDescribedByThisNode: function( node ) { assert && assert( node instanceof Node ); - const indexOfNode = _.indexOf( this._nodesThatAreAriaDescribedbyThisNode, node ); + + const nodesThatAreAriaDescribedbyThisNode = this._record._get_mutable_( 'nodesThatAreAriaDescribedbyThisNode' ); + + const indexOfNode = _.indexOf( nodesThatAreAriaDescribedbyThisNode, node ); assert && assert( indexOfNode >= 0 ); - this._nodesThatAreAriaDescribedbyThisNode.splice( indexOfNode, 1 ); + nodesThatAreAriaDescribedbyThisNode.splice( indexOfNode, 1 ); }, @@ -1818,11 +2003,13 @@ const ParallelDOM = { */ updateOtherNodesAriaDescribedby: function() { + const nodesThatAreAriaDescribedbyThisNode = this.nodesThatAreAriaDescribedbyThisNode; + // if any other nodes are aria-describedby this Node, update those associations too. Since this node's // pdom content needs to be recreated, they need to update their aria-describedby associations accordingly. // TODO: only use unique elements of the array (_.unique) - for ( let i = 0; i < this._nodesThatAreAriaDescribedbyThisNode.length; i++ ) { - const otherNode = this._nodesThatAreAriaDescribedbyThisNode[ i ]; + for ( let i = 0; i < nodesThatAreAriaDescribedbyThisNode.length; i++ ) { + const otherNode = nodesThatAreAriaDescribedbyThisNode[ i ]; otherNode.updateAriaDescribedbyAssociationsInPeers(); } }, @@ -1834,14 +2021,14 @@ const ParallelDOM = { * @returns {Array.} */ getNodesThatAreAriaDescribedbyThisNode: function() { - return this._nodesThatAreAriaDescribedbyThisNode; + return this._record._get_( 'nodesThatAreAriaDescribedbyThisNode' ); }, get nodesThatAreAriaDescribedbyThisNode() { return this.getNodesThatAreAriaDescribedbyThisNode(); }, /** * @public - * @param {Array.} activeDescendantAssociations - list of associationObjects, see this._activeDescendantAssociations. + * @param {Array.} activeDescendantAssociations - list of associationObjects, see this.activeDescendantAssociations. */ setActiveDescendantAssociations: function( activeDescendantAssociations ) { @@ -1855,7 +2042,7 @@ const ParallelDOM = { } // no work to be done if both are empty, safe to return early - if ( activeDescendantAssociations.length === 0 && this._activeDescendantAssociations.length === 0 ) { + if ( activeDescendantAssociations.length === 0 && this.activeDescendantAssociations.length === 0 ) { return; } @@ -1865,7 +2052,7 @@ const ParallelDOM = { let i; // get a difference of the desired new list, and the old - arrayDifference( activeDescendantAssociations, this._activeDescendantAssociations, afterOnly, beforeOnly, inBoth ); + 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++ ) { @@ -1873,7 +2060,7 @@ const ParallelDOM = { this.removeActiveDescendantAssociation( associationObject ); } - assert && assert( this._activeDescendantAssociations.length === inBoth.length, + 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 @@ -1889,7 +2076,7 @@ const ParallelDOM = { * @returns {Array.} - the list of current association objects */ getActiveDescendantAssociations: function() { - return this._activeDescendantAssociations; + return this._record._get_( 'activeDescendantAssociations' ); }, get activeDescendantAssociations() { return this.getActiveDescendantAssociations(); }, @@ -1907,11 +2094,11 @@ const ParallelDOM = { assert && PDOMUtils.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. + this._record._get_mutable_( '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 ); + associationObject.otherNode._record._get_mutable_( 'nodesThatAreActiveDescendantToThisNode' ).push( this ); // update the pdomPeers with this aria-activeDescendant association this.updateActiveDescendantAssociationsInPeers(); @@ -1922,10 +2109,12 @@ const ParallelDOM = { * @public */ removeActiveDescendantAssociation: function( associationObject ) { - assert && assert( _.includes( this._activeDescendantAssociations, associationObject ) ); + assert && assert( _.includes( this.activeDescendantAssociations, associationObject ) ); + + const activeDescendantAssociations = this._record._get_mutable_( 'activeDescendantAssociations' ); // remove the - const removedObject = this._activeDescendantAssociations.splice( _.indexOf( this._activeDescendantAssociations, associationObject ), 1 ); + const removedObject = activeDescendantAssociations.splice( _.indexOf( 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 ); @@ -1940,9 +2129,12 @@ const ParallelDOM = { */ removeNodeThatIsActiveDescendantThisNode: function( node ) { assert && assert( node instanceof Node ); - const indexOfNode = _.indexOf( this._nodesThatAreActiveDescendantToThisNode, node ); + + const nodesThatAreActiveDescendantToThisNode = this._record._get_mutable_( 'nodesThatAreActiveDescendantToThisNode' ); + + const indexOfNode = _.indexOf( nodesThatAreActiveDescendantToThisNode, node ); assert && assert( indexOfNode >= 0 ); - this._nodesThatAreActiveDescendantToThisNode.splice( indexOfNode, 1 ); + nodesThatAreActiveDescendantToThisNode.splice( indexOfNode, 1 ); }, @@ -1963,11 +2155,13 @@ const ParallelDOM = { */ updateOtherNodesActiveDescendant: function() { + const nodesThatAreActiveDescendantToThisNode = this.nodesThatAreActiveDescendantToThisNode; + // if any other nodes are aria-activeDescendant this Node, update those associations too. Since this node's // pdom content needs to be recreated, they need to update their aria-activeDescendant associations accordingly. // TODO: only use unique elements of the array (_.unique) - for ( let i = 0; i < this._nodesThatAreActiveDescendantToThisNode.length; i++ ) { - const otherNode = this._nodesThatAreActiveDescendantToThisNode[ i ]; + for ( let i = 0; i < nodesThatAreActiveDescendantToThisNode.length; i++ ) { + const otherNode = nodesThatAreActiveDescendantToThisNode[ i ]; otherNode.updateActiveDescendantAssociationsInPeers(); } }, @@ -1979,10 +2173,47 @@ const ParallelDOM = { * @returns {Array.} */ getNodesThatAreActiveDescendantToThisNode: function() { - return this._nodesThatAreActiveDescendantToThisNode; + return this._record._get_( 'nodesThatAreActiveDescendantToThisNode' ); }, get nodesThatAreActiveDescendantToThisNode() { return this.getNodesThatAreActiveDescendantToThisNode(); }, + /** + * @protected + * + * @param {Array.|null} pdomOrder + */ + validatePDOMOrder( pdomOrder ) { + assert && assert( Array.isArray( pdomOrder ) || pdomOrder === null, + `Array or null expected, received: ${pdomOrder}` ); + assert && pdomOrder && pdomOrder.forEach( ( node, index ) => { + assert( node === null || node instanceof Node, + `Elements of pdomOrder should be either a Node or null. Element at index ${index} is: ${node}` ); + } ); + assert && pdomOrder && assert( this.getTrails( node => _.includes( pdomOrder, node ) ).length === 0, 'pdomOrder should not include any ancestors or the node itself' ); + }, + + /** + * @protected + * + * @param {Array.|null} pdomOrder + */ + onBeforePDOMOrder( pdomOrder ) { + // Store our own reference to this, so client modifications to the input array won't silently break things. + // See https://github.com/phetsims/scenery/issues/786 + return pdomOrder === null ? null : pdomOrder.slice(); + }, + + /** + * @protected + * + * @param {Array.|null} pdomOrder + * @param {Array.|null} oldPDOMOrder + */ + onAfterPDOMOrder( pdomOrder, oldPDOMOrder ) { + PDOMTree.pdomOrderChange( this, oldPDOMOrder, pdomOrder ); + + this.rendererSummaryRefreshEmitter.emit(); + }, /** * Sets the PDOM/DOM order for this Node. This includes not only focused items, but elements that can be @@ -2041,26 +2272,7 @@ const ParallelDOM = { * @param {Array.|null} pdomOrder */ setPDOMOrder: function( pdomOrder ) { - assert && assert( Array.isArray( pdomOrder ) || pdomOrder === null, - `Array or null expected, received: ${pdomOrder}` ); - assert && pdomOrder && pdomOrder.forEach( ( node, index ) => { - assert( node === null || node instanceof Node, - `Elements of pdomOrder should be either a Node or null. Element at index ${index} is: ${node}` ); - } ); - assert && pdomOrder && assert( this.getTrails( node => _.includes( pdomOrder, node ) ).length === 0, 'pdomOrder should not include any ancestors or the node itself' ); - - // Only update if it has changed - if ( this._pdomOrder !== pdomOrder ) { - const oldPDOMOrder = this._pdomOrder; - - // Store our own reference to this, so client modifications to the input array won't silently break things. - // See https://github.com/phetsims/scenery/issues/786 - this._pdomOrder = pdomOrder === null ? null : pdomOrder.slice(); - - PDOMTree.pdomOrderChange( this, oldPDOMOrder, pdomOrder ); - - this.rendererSummaryRefreshEmitter.emit(); - } + this._record._set_( 'pdomOrder', pdomOrder ); }, set pdomOrder( value ) { this.setPDOMOrder( value ); }, @@ -2071,13 +2283,24 @@ const ParallelDOM = { * @returns {Array.|null} */ getPDOMOrder: function() { - if ( this._pdomOrder ) { - return this._pdomOrder.slice( 0 ); // create a defensive copy + const pdomOrder = this.getInternalPDOMOrder(); + + if ( pdomOrder ) { + return pdomOrder.slice( 0 ); // create a defensive copy } - return this._pdomOrder; + return pdomOrder; }, get pdomOrder() { return this.getPDOMOrder(); }, + /** + * @protected + * + * @returns {Array.|null} + */ + getInternalPDOMOrder() { + return this._record._get_( 'pdomOrder' ); + }, + /** * Returns whether this node has an pdomOrder that is effectively different than the default. * @public @@ -2089,9 +2312,20 @@ const ParallelDOM = { * @returns {boolean} */ hasPDOMOrder: function() { - return this._pdomOrder !== null && - this._pdomOrder.length !== 0 && - ( this._pdomOrder.length > 1 || this._pdomOrder[ 0 ] !== null ); + const pdomOrder = this.getInternalPDOMOrder(); + + return pdomOrder !== null && + pdomOrder.length !== 0 && + ( pdomOrder.length > 1 || pdomOrder[ 0 ] !== null ); + }, + + /** + * @public (scenery-internal) + * + * @param {Node|null} pdomParent + */ + set pdomParent( pdomParent ) { + this._record._set_( 'pdomParent', pdomParent ); }, /** @@ -2101,7 +2335,7 @@ const ParallelDOM = { * @returns {Node|null} */ getPDOMParent: function() { - return this._pdomParent; + return this._record._get_( 'pdomParent' ); }, get pdomParent() { return this.getPDOMParent(); }, @@ -2128,7 +2362,7 @@ const ParallelDOM = { for ( let i = 0; i < this._children.length; i++ ) { const child = this._children[ i ]; - if ( !child._pdomParent ) { + if ( !child.pdomParent ) { nonOrderedChildren.push( child ); } } @@ -2157,6 +2391,24 @@ const ParallelDOM = { } }, + /** + * @protected + * + * @param {boolean} visible + */ + validatePDOMVisible( visible ) { + assert && assert( typeof visible === 'boolean' ); + }, + + /** + * @protected + * + * @param {boolean} visible + */ + onAfterPDOMVisible( visible ) { + this._pdomDisplaysInfo.onPDOMVisibilityChange( visible ); + }, + /** * Hide completely from a screen reader and the browser by setting the hidden attribute on the node's * representative DOM element. If the sibling DOM Elements have a container parent, the container @@ -2168,12 +2420,7 @@ const ParallelDOM = { * @param {boolean} visible */ setPDOMVisible: function( visible ) { - assert && assert( typeof visible === 'boolean' ); - if ( this._pdomVisible !== visible ) { - this._pdomVisible = visible; - - this._pdomDisplaysInfo.onPDOMVisibilityChange( visible ); - } + this._record._set_( 'pdomVisible', visible ); }, set pdomVisible( visible ) { this.setPDOMVisible( visible ); }, @@ -2184,7 +2431,7 @@ const ParallelDOM = { * @returns {boolean} */ isPDOMVisible: function() { - return this._pdomVisible; + return this._record._get_( 'pdomVisible' ); }, get pdomVisible() { return this.isPDOMVisible(); }, @@ -2206,28 +2453,47 @@ const ParallelDOM = { get pdomDisplayed() { return this.isPDOMDisplayed(); }, /** - * Set the value of an input element. Element must be a form element to support the value attribute. The input - * value is converted to string since input values are generally string for HTML. - * @public + * @protected * - * @param {string|number} value + * @param {string|number|null} value */ - setInputValue: function( value ) { + validateInputValue( value ) { assert && assert( value === null || typeof value === 'string' || typeof value === 'number' ); - assert && this._tagName && assert( _.includes( FORM_ELEMENTS, this._tagName.toUpperCase() ), 'dom element must be a form element to support value' ); - - // type cast - value = `${value}`; + assert && this.tagName && assert( _.includes( FORM_ELEMENTS, this.tagName.toUpperCase() ), 'dom element must be a form element to support value' ); + }, - if ( value !== this._inputValue ) { - this._inputValue = value; + /** + * @protected + * + * @param {string|number|null} value + * @returns {string} + */ + onBeforeInputValue( value ) { + return `${value}`; + }, - for ( let i = 0; i < this.pdomInstances.length; i++ ) { - const peer = this.pdomInstances[ i ].peer; - peer.onInputValueChange(); - } + /** + * @protected + * + * @param {string|number|null} value + */ + onAfterInputValue( value ) { + for ( let i = 0; i < this.pdomInstances.length; i++ ) { + const peer = this.pdomInstances[ i ].peer; + peer.onInputValueChange(); } }, + + /** + * Set the value of an input element. Element must be a form element to support the value attribute. The input + * value is converted to string since input values are generally string for HTML. + * @public + * + * @param {string|number|null} value + */ + setInputValue: function( value ) { + this._record._set_( 'inputValue', value ); + }, set inputValue( value ) { this.setInputValue( value ); }, /** @@ -2237,35 +2503,47 @@ const ParallelDOM = { * @returns {string} */ getInputValue: function() { - return this._inputValue; + return this._record._get_( 'inputValue' ); }, get inputValue() { return this.getInputValue(); }, /** - * Set whether or not the checked attribute appears on the dom elements associated with this Node's - * pdom content. This is only useful for inputs of type 'radio' and 'checkbox'. A 'checked' input - * is considered selected to the browser and assistive technology. + * @protected * - * @public * @param {boolean} checked */ - setPDOMChecked: function( checked ) { + validatePDOMChecked( checked ) { assert && assert( typeof checked === 'boolean' ); - if ( this._tagName ) { - assert && assert( this._tagName.toUpperCase() === INPUT_TAG, 'Cannot set checked on a non input tag.' ); + if ( this.tagName ) { + assert && assert( this.tagName.toUpperCase() === INPUT_TAG, 'Cannot set checked on a non input tag.' ); } - if ( this._inputType ) { - assert && assert( INPUT_TYPES_THAT_SUPPORT_CHECKED.indexOf( this._inputType.toUpperCase() ) >= 0, `inputType does not support checked: ${this._inputType}` ); + if ( this.inputType ) { + assert && assert( INPUT_TYPES_THAT_SUPPORT_CHECKED.indexOf( this.inputType.toUpperCase() ) >= 0, `inputType does not support checked: ${this.inputType}` ); } + }, - if ( this._pdomChecked !== checked ) { - this._pdomChecked = checked; + /** + * @protected + * + * @param {boolean} checked + */ + onAfterPDOMChecked( checked ) { + this.setPDOMAttribute( 'checked', checked, { + asProperty: true + } ); + }, - this.setPDOMAttribute( 'checked', checked, { - asProperty: true - } ); - } + /** + * Set whether or not the checked attribute appears on the dom elements associated with this Node's + * pdom content. This is only useful for inputs of type 'radio' and 'checkbox'. A 'checked' input + * is considered selected to the browser and assistive technology. + * + * @public + * @param {boolean} checked + */ + setPDOMChecked: function( checked ) { + this._record._set_( 'pdomChecked', checked ); }, set pdomChecked( checked ) { this.setPDOMChecked( checked ); }, @@ -2276,7 +2554,7 @@ const ParallelDOM = { * @returns {boolean} */ getPDOMChecked: function() { - return this._pdomChecked; + return this._record._get_( 'pdomChecked' ); }, get pdomChecked() { return this.getPDOMChecked(); }, @@ -2291,10 +2569,19 @@ const ParallelDOM = { * } */ getPDOMAttributes: function() { - return this._pdomAttributes.slice( 0 ); // defensive copy + return this.getInternalPDOMAttributes().slice( 0 ); // defensive copy }, get pdomAttributes() { return this.getPDOMAttributes(); }, + /** + * @public (scenery-internal) + * + * @returns {Array.} + */ + getInternalPDOMAttributes() { + return this._record._get_( 'pdomAttributes' ); + }, + /** * Set a particular attribute or property for this Node's primary sibling, generally to provide extra semantic information for * a screen reader. @@ -2325,16 +2612,18 @@ const ParallelDOM = { assert && assert( ASSOCIATION_ATTRIBUTES.indexOf( attribute ) < 0, 'setPDOMAttribute does not support association attributes' ); + const pdomAttributes = this._record._get_mutable_( 'pdomAttributes' ); + // if the pdom attribute already exists in the list, remove it - no need // to remove from the peers, existing attributes will simply be replaced in the DOM - for ( let i = 0; i < this._pdomAttributes.length; i++ ) { - const currentAttribute = this._pdomAttributes[ i ]; + for ( let i = 0; i < pdomAttributes.length; i++ ) { + const currentAttribute = pdomAttributes[ i ]; if ( currentAttribute.attribute === attribute && currentAttribute.options.namespace === options.namespace && currentAttribute.options.elementName === options.elementName ) { if ( currentAttribute.options.asProperty === options.asProperty ) { - this._pdomAttributes.splice( i, 1 ); + pdomAttributes.splice( i, 1 ); } else { @@ -2344,7 +2633,7 @@ const ParallelDOM = { } } - this._pdomAttributes.push( { + pdomAttributes.push( { attribute: attribute, value: value, options: options @@ -2378,11 +2667,12 @@ const ParallelDOM = { }, options ); let attributeRemoved = false; - for ( let i = 0; i < this._pdomAttributes.length; i++ ) { - if ( this._pdomAttributes[ i ].attribute === attribute && - this._pdomAttributes[ i ].options.namespace === options.namespace && - this._pdomAttributes[ i ].options.elementName === options.elementName ) { - this._pdomAttributes.splice( i, 1 ); + const pdomAttributes = this._record._get_mutable_( 'pdomAttributes' ); + for ( let i = 0; i < pdomAttributes.length; i++ ) { + if ( pdomAttributes[ i ].attribute === attribute && + pdomAttributes[ i ].options.namespace === options.namespace && + pdomAttributes[ i ].options.elementName === options.elementName ) { + pdomAttributes.splice( i, 1 ); attributeRemoved = true; } } @@ -2432,16 +2722,39 @@ const ParallelDOM = { }, options ); let attributeFound = false; - for ( let i = 0; i < this._pdomAttributes.length; i++ ) { - if ( this._pdomAttributes[ i ].attribute === attribute && - this._pdomAttributes[ i ].options.namespace === options.namespace && - this._pdomAttributes[ i ].options.elementName === options.elementName ) { + const pdomAttributes = this.getInternalPDOMAttributes(); + for ( let i = 0; i < pdomAttributes.length; i++ ) { + if ( pdomAttributes[ i ].attribute === attribute && + pdomAttributes[ i ].options.namespace === options.namespace && + pdomAttributes[ i ].options.elementName === options.elementName ) { attributeFound = true; } } return attributeFound; }, + /** + * @protected + * + * @param {boolean|null} focusable + */ + validateFocusable( focusable ) { + assert && assert( focusable === null || typeof focusable === 'boolean' ); + }, + + /** + * @protected + * + * @param {boolean|null} focusable + */ + onAfterFocusable( focusable ) { + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + + // after the override is set, update the focusability of the peer based on this node's value for focusable + // which may be true or false (but not null) + this._pdomInstances[ i ].peer.setFocusable( this.focusable ); + } + }, /** * Make the DOM element explicitly focusable with a tab index. Native HTML form elements will generally be in @@ -2453,18 +2766,7 @@ const ParallelDOM = { * @param {boolean|null} focusable - null to use the default browser focus for the primary element */ setFocusable: function( focusable ) { - assert && assert( focusable === null || typeof focusable === 'boolean' ); - - if ( this._focusableOverride !== focusable ) { - this._focusableOverride = focusable; - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - - // after the override is set, update the focusability of the peer based on this node's value for focusable - // which may be true or false (but not null) - this._pdomInstances[ i ].peer.setFocusable( this.focusable ); - } - } + this._record._set_( 'focusableOverride', focusable ); }, set focusable( isFocusable ) { this.setFocusable( isFocusable ); }, @@ -2476,20 +2778,32 @@ const ParallelDOM = { * @returns {boolean} */ isFocusable: function() { - if ( this._focusableOverride !== null ) { - return this._focusableOverride; + const focusableOverride = this._record._get_( 'focusableOverride' ); + + if ( focusableOverride !== null ) { + return focusableOverride; } // if there isn't a tagName yet, then there isn't an element, so we aren't focusable. To support option order. - else if ( this._tagName === null ) { + else if ( this.tagName === null ) { return false; } else { - return PDOMUtils.tagIsDefaultFocusable( this._tagName ); + return PDOMUtils.tagIsDefaultFocusable( this.tagName ); } }, get focusable() { return this.isFocusable(); }, + /** + * @protected + * + * @param {Node|null} node + */ + onAfterPDOMTransformSourceNode( node ) { + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + this._pdomInstances[ i ].peer.setPDOMTransformSourceNode( node ); + } + }, /** * Sets the source Node that controls positioning of the primary sibling. Transforms along the trail to this @@ -2506,11 +2820,7 @@ const ParallelDOM = { * @param {Node|null} node */ setPDOMTransformSourceNode: function( node ) { - this._pdomTransformSourceNode = node; - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - this._pdomInstances[ i ].peer.setPDOMTransformSourceNode( this._pdomTransformSourceNode ); - } + this._record._set_( 'pdomTransformSourceNode', node ); }, set pdomTransformSourceNode( node ) { this.setPDOMTransformSourceNode( node ); }, @@ -2521,10 +2831,21 @@ const ParallelDOM = { * @returns {Node|null} */ getPDOMTransformSourceNode: function() { - return this._pdomTransformSourceNode; + return this._record._get_( 'pdomTransformSourceNode' ); }, get pdomTransformSourceNode() { return this.getPDOMTransformSourceNode(); }, + /** + * @protected + * + * @param {boolean} positionInPDOM + */ + onAfterPositionInPDOM( positionInPDOM ) { + for ( let i = 0; i < this._pdomInstances.length; i++ ) { + this._pdomInstances[ i ].peer.setPositionInPDOM( positionInPDOM ); + } + }, + /** * Sets whether the PDOM sibling elements are positioned in the correct place in the viewport. Doing so is a * requirement for custom gestures on touch based screen readers. However, doing this DOM layout is expensive so @@ -2539,11 +2860,7 @@ const ParallelDOM = { * @param {boolean} positionInPDOM */ setPositionInPDOM( positionInPDOM ) { - this._positionInPDOM = positionInPDOM; - - for ( let i = 0; i < this._pdomInstances.length; i++ ) { - this._pdomInstances[ i ].peer.setPositionInPDOM( positionInPDOM ); - } + this._record._set_( 'positionInPDOM', positionInPDOM ); }, set positionInPDOM( positionInPDOM ) { this.setPositionInPDOM( positionInPDOM ); }, @@ -2554,7 +2871,7 @@ const ParallelDOM = { * @returns {boolean} */ getPositionInPDOM() { - return this._positionInPDOM; + return this._record._get_( 'positionInPDOM' ); }, get positionInPDOM() { return this.getPositionInPDOM(); }, @@ -2569,10 +2886,15 @@ const ParallelDOM = { * See https://github.com/phetsims/a11y-research/issues/156 for more information. */ setExcludeLabelSiblingFromInput: function() { - this.excludeLabelSiblingFromInput = true; + this._record._set_( 'excludeLabelSiblingFromInput', true ); + this.onPDOMContentChange(); }, + get excludeLabelSiblingFromInput() { + return this._record._get_( 'excludeLabelSiblingFromInput' ); + }, + /***********************************************************************************************************/ // SCENERY-INTERNAL AND PRIVATE METHODS /***********************************************************************************************************/ @@ -2645,7 +2967,10 @@ const ParallelDOM = { nestedChildStack.push( item.children ); } - const arrayPDOMOrder = node._pdomOrder === null ? [] : node._pdomOrder; + let arrayPDOMOrder = node.getInternalPDOMOrder(); + if ( arrayPDOMOrder === null ) { + arrayPDOMOrder = []; + } // push specific focused nodes to the stack pruneStack = pruneStack.concat( arrayPDOMOrder ); @@ -2701,7 +3026,7 @@ const ParallelDOM = { PDOMTree.pdomContentChange( this ); // recompute the heading level for this node if it is using the pdomHeading API. - this._pdomHeading && this.computeHeadingLevel(); + this.pdomHeading && this.computeHeadingLevel(); this.rendererSummaryRefreshEmitter.emit(); }, @@ -2715,7 +3040,7 @@ const ParallelDOM = { * @returns {boolean} */ get hasPDOMContent() { - return !!this._tagName; + return !!this.tagName; }, /** @@ -2821,6 +3146,200 @@ const ParallelDOM = { this._pdomInstances.splice( index, 1 ); } } ); + + return { + tagName: { + defaultValue: null, + validate: proto.validateTagName, + onAfter: proto.onPDOMContentChange // TODO: this could be setting PDOM content twice + }, + containerTagName: { + defaultValue: null, + validate: proto.validateContainerTagName, + onAfter: proto.onPDOMContentChange + }, + labelTagName: { + defaultValue: null, + validate: proto.validateLabelTagName, + onAfter: proto.onPDOMContentChange + }, + descriptionTagName: { + defaultValue: null, + validate: proto.validateDescriptionTagName, + onAfter: proto.onPDOMContentChange + }, + inputType: { + defaultValue: null, + validate: proto.validateInputType, + onAfter: proto.onAfterInputType + }, + inputValue: { + defaultValue: null, + validate: proto.validateInputValue, + onBefore: proto.onBeforeInputValue, + onAfter: proto.onAfterInputValue + }, + pdomChecked: { + defaultValue: false, + validate: proto.validatePDOMChecked, + onAfter: proto.onAfterPDOMChecked + }, + appendLabel: { + defaultValue: false, + validate: proto.validateAppendLabel, + onAfter: proto.onPDOMContentChange + }, + appendDescription: { + defaultValue: false, + validate: proto.validateAppendDescription, + onAfter: proto.onPDOMContentChange + }, + pdomAttributes: { + defaultValue: [], + create: () => [] + }, + labelContent: { + defaultValue: null, + validate: proto.validateLabelContent, + onAfter: proto.onAfterLabelContent + }, + innerContent: { + defaultValue: null, + validate: proto.validateInnerContent, + onAfter: proto.onAfterInnerContent + }, + descriptionContent: { + defaultValue: null, + validate: proto.validateDescriptionContent, + onAfter: proto.onAfterDescriptionContent + }, + pdomNamespace: { + defaultValue: null, + validate: proto.validatePDOMNamespace, + onAfter: proto.onPDOMContentChange + }, + ariaLabel: { + defaultValue: null, + validate: proto.validateAriaLabel, + onAfter: proto.onAfterAriaLabel + }, + ariaRole: { + defaultValue: null, + validate: proto.validateAriaRole, + onAfter: proto.onAfterAriaRole + }, + containerAriaRole: { + defaultValue: null, + validate: proto.validateContainerAriaRole, + onAfter: proto.onAfterContainerAriaRole + }, + ariaValueText: { + defaultValue: null, + validate: proto.validateAriaValueText, + onAfter: proto.onAfterAriaValueText + }, + ariaLabelledbyAssociations: { + defaultValue: [], + create: () => [] + }, + nodesThatAreAriaLabelledbyThisNode: { + defaultValue: [], + create: () => [] + }, + ariaDescribedbyAssociations: { + defaultValue: [], + create: () => [] + }, + nodesThatAreAriaDescribedbyThisNode: { + defaultValue: [], + create: () => [] + }, + activeDescendantAssociations: { + defaultValue: [], + create: () => [] + }, + nodesThatAreActiveDescendantToThisNode: { + defaultValue: [], + create: () => [] + }, + focusableOverride: { + defaultValue: null, + validate: proto.validateFocusable, + onAfter: proto.onAfterFocusable + }, + focusHighlight: { + defaultValue: null, + validate: proto.validateFocusHighlight, + onAfter: proto.onAfterFocusHighlight + }, + focusHighlightLayerable: { + defaultValue: false, + validate: proto.validateFocusHighlightLayerable, + onAfter: proto.onAfterFocusHighlightLayerable + }, + groupFocusHighlight: { + defaultValue: false, + validate: proto.validateGroupFocusHighlight + }, + pdomVisible: { + defaultValue: true, + validate: proto.validatePDOMVisible, + onAfter: proto.onAfterPDOMVisible + }, + pdomOrder: { + defaultValue: null, + validate: proto.validatePDOMOrder, + onBefore: proto.onBeforePDOMOrder, + onAfter: proto.onAfterPDOMOrder + }, + pdomParent: { + defaultValue: null + }, + pdomTransformSourceNode: { + defaultValue: null, + onAfter: proto.onAfterPDOMTransformSourceNode + }, + positionInPDOM: { + defaultValue: false, + onAfter: proto.onAfterPositionInPDOM + }, + excludeLabelSiblingFromInput: { + defaultValue: false + }, + accessibleName: { + defaultValue: null, + validate: proto.validateAccessibleName, + onAfter: proto.onPDOMContentChange + }, + accessibleNameBehavior: { + defaultValue: ParallelDOM.BASIC_ACCESSIBLE_NAME_BEHAVIOR, + validate: proto.validateAccessibleNameBehavior, + onAfter: proto.onPDOMContentChange + }, + helpText: { + defaultValue: null, + validate: proto.validateHelpText, + onAfter: proto.onPDOMContentChange + }, + helpTextBehavior: { + defaultValue: ParallelDOM.HELP_TEXT_AFTER_CONTENT, + validate: proto.validateHelpTextBehavior, + onAfter: proto.onPDOMContentChange + }, + pdomHeading: { + defaultValue: null, + validate: proto.validatePDOMHeading, + onAfter: proto.onPDOMContentChange + }, + pdomHeadingBehavior: { + defaultValue: DEFAULT_PDOM_HEADING_BEHAVIOR, + validate: proto.validatePDOMHeadingBehavior, + onAfter: proto.onPDOMContentChange + }, + headingLevel: { + defaultValue: null + } + }; }, /** diff --git a/js/display/BackboneDrawable.js b/js/display/BackboneDrawable.js index efda6eeba..bca16f29a 100644 --- a/js/display/BackboneDrawable.js +++ b/js/display/BackboneDrawable.js @@ -303,8 +303,9 @@ class BackboneDrawable extends Drawable { const node = this.watchedFilterNodes[ i ]; const opacity = node.getEffectiveOpacity(); - for ( let j = 0; j < node._filters.length; j++ ) { - filterString += `${filterString ? ' ' : ''}${node._filters[ j ].getCSSFilterString()}`; + const filters = node.getInternalFilters(); + for ( let j = 0; j < filters.length; j++ ) { + filterString += `${filterString ? ' ' : ''}${filters[ j ].getCSSFilterString()}`; } // Apply opacity after other effects diff --git a/js/display/CanvasBlock.js b/js/display/CanvasBlock.js index cbc693d69..a50731770 100644 --- a/js/display/CanvasBlock.js +++ b/js/display/CanvasBlock.js @@ -313,7 +313,8 @@ class CanvasBlock extends FittedBlock { // We should not apply opacity or other filters at or below the filter root if ( i > filterRootIndex ) { - if ( node._filters.length ) { + const filters = node.getInternalFilters(); + if ( filters.length ) { sceneryLog && sceneryLog.CanvasBlock && sceneryLog.CanvasBlock( `Pop filters ${trail.subtrailTo( node ).toDebugString()}` ); const topWrapper = this.wrapperStack[ this.wrapperStackIndex ]; @@ -322,7 +323,6 @@ class CanvasBlock extends FittedBlock { bottomWrapper.context.setTransform( 1, 0, 0, 1, 0, 0 ); - const filters = node._filters; // We need to fall back to a different filter behavior with Chrome, since it over-darkens otherwise with the // built-in feature. // NOTE: Not blocking chromium anymore, see https://github.com/phetsims/scenery/issues/1139 @@ -391,7 +391,7 @@ class CanvasBlock extends FittedBlock { this.pushWrapper(); } - if ( node._filters.length ) { + if ( node.getInternalFilters().length ) { sceneryLog && sceneryLog.CanvasBlock && sceneryLog.CanvasBlock( `Push filters ${trail.subtrailTo( node ).toDebugString()}` ); // Push filters diff --git a/js/display/Instance.js b/js/display/Instance.js index 5e5c28711..0ea711912 100644 --- a/js/display/Instance.js +++ b/js/display/Instance.js @@ -378,7 +378,7 @@ class Instance { this.groupRenderer = 0; this.sharedCacheRenderer = 0; - const hints = this.node._hints; + const renderer = this.node.getInternalRenderer(); this.isUnderCanvasCache = this.isSharedCanvasCacheRoot || ( this.parent ? ( this.parent.isUnderCanvasCache || this.parent.isInstanceCanvasCache || this.parent.isSharedCanvasCacheSelf ) : false ); @@ -386,20 +386,21 @@ class Instance { // set up our preferred renderer list (generally based on the parent) this.preferredRenderers = this.parent ? this.parent.preferredRenderers : defaultPreferredRenderers; // allow the node to modify its preferred renderers (and those of its descendants) - if ( hints.renderer ) { - this.preferredRenderers = Renderer.pushOrderBitmask( this.preferredRenderers, hints.renderer ); + if ( renderer ) { + this.preferredRenderers = Renderer.pushOrderBitmask( this.preferredRenderers, renderer ); } const hasClip = this.node.hasClipArea(); - const hasFilters = this.node.effectiveOpacity !== 1 || hints.usesOpacity || this.node._filters.length > 0; + const filters = this.node.getInternalFilters(); + const hasFilters = this.node.effectiveOpacity !== 1 || this.node.usesOpacity || filters.length > 0; // let hasNonDOMFilter = false; let hasNonSVGFilter = false; let hasNonCanvasFilter = false; // let hasNonWebGLFilter = false; if ( hasFilters ) { // NOTE: opacity is OK with all of those (currently) - for ( let i = 0; i < this.node._filters.length; i++ ) { - const filter = this.node._filters[ i ]; + for ( let i = 0; i < filters.length; i++ ) { + const filter = filters[ i ]; // TODO: how to handle this, if we split AT the node? // if ( !filter.isDOMCompatible() ) { @@ -416,7 +417,8 @@ class Instance { // } } } - const requiresSplit = hints.cssTransform || hints.layerSplit; + const cssTransform = this.node.cssTransform; + const requiresSplit = cssTransform || this.node.layerSplit; const backboneRequired = this.isDisplayRoot || ( !this.isUnderCanvasCache && requiresSplit ); // Support either "all Canvas" or "all SVG" opacity/clip @@ -426,6 +428,9 @@ class Instance { ( !hasNonCanvasFilter && this.node._rendererSummary.isSubtreeRenderedExclusivelyCanvas( this.preferredRenderers ) ) ); const useBackbone = applyTransparencyWithBlock ? false : ( backboneRequired || hasFilters || hasClip ); + const canvasCacheHint = false; + const singleCacheHint = false; + // check if we need a backbone or cache // if we are under a canvas cache, we will NEVER have a backbone // splits are accomplished just by having a backbone @@ -434,17 +439,17 @@ class Instance { if ( useBackbone ) { this.isBackbone = true; this.isVisibilityApplied = true; - this.isTransformed = this.isDisplayRoot || !!hints.cssTransform; // for now, only trigger CSS transform if we have the specific hint + this.isTransformed = this.isDisplayRoot || !!cssTransform; // for now, only trigger CSS transform if we have the specific hint //OHTWO TODO: check whether the force acceleration hint is being used by our DOMBlock this.groupRenderer = Renderer.bitmaskDOM; // probably won't be used } - else if ( !applyTransparencyWithBlock && ( hasFilters || hasClip || hints.canvasCache ) ) { + else if ( !applyTransparencyWithBlock && ( hasFilters || hasClip || canvasCacheHint ) ) { // everything underneath needs to be renderable with Canvas, otherwise we cannot cache assert && assert( this.node._rendererSummary.isSingleCanvasSupported(), `hints.canvasCache provided, but not all node contents can be rendered with Canvas under ${ this.node.constructor.name}` ); - if ( hints.singleCache ) { + if ( singleCacheHint ) { // TODO: scale options - fixed size, match highest resolution (adaptive), or mipmapped if ( this.isSharedCanvasCacheRoot ) { this.isSharedCanvasCacheSelf = true; diff --git a/js/display/SVGGroup.js b/js/display/SVGGroup.js index 36d439c69..55fd09db7 100644 --- a/js/display/SVGGroup.js +++ b/js/display/SVGGroup.js @@ -301,7 +301,8 @@ class SVGGroup { svgGroup.removeAttribute( 'opacity' ); } - const needsFilter = this.willApplyFilters && this.node._filters.length; + const filters = this.node.getInternalFilters(); + const needsFilter = this.willApplyFilters && filters.length; const filterId = `filter-${this.id}`; if ( needsFilter ) { @@ -319,9 +320,9 @@ class SVGGroup { // Fill in elements into our filter let filterRegionPercentageIncrease = 50; let inName = 'SourceGraphic'; - const length = this.node._filters.length; + const length = filters.length; for ( let i = 0; i < length; i++ ) { - const filter = this.node._filters[ i ]; + const filter = filters[ i ]; const resultName = i === length - 1 ? undefined : `e${i}`; // Last result should be undefined filter.applySVGFilter( this.filterElement, inName, resultName ); diff --git a/js/display/WebGLBlock.js b/js/display/WebGLBlock.js index c96e4b170..250c3dd0e 100644 --- a/js/display/WebGLBlock.js +++ b/js/display/WebGLBlock.js @@ -353,13 +353,15 @@ class WebGLBlock extends FittedBlock { this.spriteSheets[ i ].updateTexture(); } + const firstDrawableWebGLScale = this.firstDrawable.node.webglScale; + // temporary hack for supporting webglScale if ( this.firstDrawable && this.firstDrawable === this.lastDrawable && this.firstDrawable.node && - this.firstDrawable.node._hints.webglScale !== null && - this.backingScale !== this.originalBackingScale * this.firstDrawable.node._hints.webglScale ) { - this.backingScale = this.originalBackingScale * this.firstDrawable.node._hints.webglScale; + firstDrawableWebGLScale !== null && + this.backingScale !== this.originalBackingScale * firstDrawableWebGLScale ) { + this.backingScale = this.originalBackingScale * firstDrawableWebGLScale; this.dirtyFit = true; } diff --git a/js/nodes/Node.js b/js/nodes/Node.js index 335227553..80eb1478d 100644 --- a/js/nodes/Node.js +++ b/js/nodes/Node.js @@ -154,6 +154,7 @@ import BooleanProperty from '../../../axon/js/BooleanProperty.js'; import EnabledProperty from '../../../axon/js/EnabledProperty.js'; import Property from '../../../axon/js/Property.js'; +import Record from '../../../axon/js/Record.js'; import TinyEmitter from '../../../axon/js/TinyEmitter.js'; import TinyForwardingProperty from '../../../axon/js/TinyForwardingProperty.js'; import TinyProperty from '../../../axon/js/TinyProperty.js'; @@ -276,7 +277,8 @@ const DEFAULT_OPTIONS = { cssTransform: false, excludeInvisible: false, webglScale: null, - preventFit: false + preventFit: false, + layoutOptions: null }; class Node extends PhetioObject { @@ -370,20 +372,10 @@ class Node extends PhetioObject { // NOTE: This is fired synchronously when the clipArea of the Node is toggled this.clipAreaProperty = new TinyProperty( DEFAULT_OPTIONS.clipArea ); - // @private - Areas for hit intersection. If set on a Node, no descendants can handle events. - this._mouseArea = DEFAULT_OPTIONS.mouseArea; // {Shape|Bounds2} for mouse position in the local coordinate frame - this._touchArea = DEFAULT_OPTIONS.touchArea; // {Shape|Bounds2} for touch and pen position in the local coordinate frame - - // @private {string|null} - The CSS cursor to be displayed over this Node. null should be the default (inherit) value. - this._cursor = DEFAULT_OPTIONS.cursor; - // @public (scenery-internal) - Not for public use, but used directly internally for performance. this._children = []; // {Array.} - Ordered array of child Nodes. this._parents = []; // {Array.} - Unordered array of parent Nodes. - // @private {boolean} - Whether we will do more accurate (and tight) bounds computations for rotations and shears. - this._transformBounds = DEFAULT_OPTIONS.transformBounds; - /* * Set up the transform reference. we add a listener so that the transform itself can be modified directly * by reference, triggering the event notifications for Scenery The reference to the Transform3 will never change. @@ -463,9 +455,6 @@ class Node extends PhetioObject { // @private {boolean} - [mutable] Whether invisible children will be excluded from this Node's bounds this._excludeInvisibleChildrenFromBounds = false; - // @private {Object|null} - Options that can be provided to layout managers to adjust positioning for this node. - this._layoutOptions = null; - this._boundsDirty = true; // @private {boolean} - Whether bounds needs to be recomputed to be valid. this._localBoundsDirty = true; // @private {boolean} - Whether localBounds needs to be recomputed to be valid. this._selfBoundsDirty = true; // @private {boolean} - Whether selfBounds needs to be recomputed to be valid. @@ -479,43 +468,6 @@ class Node extends PhetioObject { this._originalChildBounds = this.childBoundsProperty._value; } - // @public (scenery-internal) {Array.} - this._filters = []; - - // @public (scenery-internal) {Object} - Where rendering-specific settings are stored. They are generally modified - // internally, so there is no ES5 setter for hints. - this._hints = { - // {number} - What type of renderer should be forced for this Node. Uses the internal bitmask structure declared - // in scenery.js and Renderer.js. - renderer: DEFAULT_OPTIONS.renderer === null ? 0 : Renderer.fromName( DEFAULT_OPTIONS.renderer ), - - // {boolean} - Whether it is anticipated that opacity will be switched on. If so, having this set to true will - // make switching back-and-forth between opacity:1 and other opacities much faster. - usesOpacity: DEFAULT_OPTIONS.usesOpacity, - - // {boolean} - Whether layers should be split before and after this Node. - layerSplit: DEFAULT_OPTIONS.layerSplit, - - // {boolean} - Whether this Node and its subtree should handle transforms by using a CSS transform of a div. - cssTransform: DEFAULT_OPTIONS.cssTransform, - - // {boolean} - When rendered as Canvas, whether we should use full (device) resolution on retina-like devices. - // TODO: ensure that this is working? 0.2 may have caused a regression. - fullResolution: false, - - // {boolean} - Whether SVG (or other) content should be excluded from the DOM tree when invisible - // (instead of just being hidden) - excludeInvisible: DEFAULT_OPTIONS.excludeInvisible, - - // {number|null} - If non-null, a multiplier to the detected pixel-to-pixel scaling of the WebGL Canvas - webglScale: DEFAULT_OPTIONS.webglScale, - - // {boolean} - If true, Scenery will not fit any blocks that contain drawables attached to Nodes underneath this - // Node's subtree. This will typically prevent Scenery from triggering bounds computation for this - // sub-tree, and movement of this Node or its descendants will never trigger the refitting of a block. - preventFit: DEFAULT_OPTIONS.preventFit - }; - // @public {TinyEmitter} - This is fired only once for any single operation that may change the children of a Node. // For example, if a Node's children are [ a, b ] and setChildren( [ a, x, y, z ] ) is called on it, the // childrenChanged event will only be fired once after the entire operation of changing the children is completed. @@ -1301,7 +1253,7 @@ class Node extends PhetioObject { const oldBounds = scratchBounds2.set( ourBounds ); // store old value in a temporary Bounds2 // no need to do the more expensive bounds transformation if we are still axis-aligned - if ( this._transformBounds && !this._transform.getMatrix().isAxisAligned() ) { + if ( this.transformBounds && !this._transform.getMatrix().isAxisAligned() ) { // mutates the matrix and bounds during recursion const matrix = scratchMatrix3.set( this.getMatrix() ); // calls below mutate this matrix @@ -1378,7 +1330,7 @@ class Node extends PhetioObject { `Child bounds mismatch after validateBounds: ${ this.childBoundsProperty._value.toString()}, expected: ${childBounds.toString()}` ); assertSlow && assertSlow( this._localBoundsOverridden || - this._transformBounds || + this.transformBounds || this.boundsProperty._value.equalsEpsilon( fullBounds, epsilon ), `Bounds mismatch after validateBounds: ${this.boundsProperty._value.toString() }, expected: ${fullBounds.toString()}. This could have happened if a bounds instance owned by a Node` + @@ -1803,6 +1755,15 @@ class Node extends PhetioObject { return this.getSafeTransformedVisibleBounds(); } + /** + * @protected + * + * @param {Bounds2} transformBounds + */ + validateTransformBounds( transformBounds ) { + assert && assert( typeof transformBounds === 'boolean', 'transformBounds should be boolean' ); + } + /** * Sets the flag that determines whether we will require more accurate (and expensive) bounds computation for this * node's transform. @@ -1818,14 +1779,7 @@ class Node extends PhetioObject { * @returns {Node} - Returns 'this' reference, for chaining */ setTransformBounds( transformBounds ) { - assert && assert( typeof transformBounds === 'boolean', 'transformBounds should be boolean' ); - - if ( this._transformBounds !== transformBounds ) { - this._transformBounds = transformBounds; - - this.invalidateBounds(); - } - + this._record._set_( 'transformBounds', transformBounds ); return this; // allow chaining } @@ -1846,7 +1800,7 @@ class Node extends PhetioObject { * @returns {boolean} */ getTransformBounds() { - return this._transformBounds; + return this._record._get_( 'transformBounds' ); } /** @@ -4030,9 +3984,11 @@ class Node extends PhetioObject { assert && assert( Array.isArray( filters ), 'filters should be an array' ); assert && assert( _.every( filters, filter => filter instanceof Filter ), 'filters should consist of Filter objects only' ); + const ourFilters = this._record._get_mutable_( 'filters' ); + // We re-use the same array internally, so we don't reference a potentially-mutable array from outside. - this._filters.length = 0; - this._filters.push( ...filters ); + ourFilters.length = 0; + ourFilters.push( ...filters ); this.invalidateHint(); this.filterChangeEmitter.emit(); @@ -4055,7 +4011,17 @@ class Node extends PhetioObject { * @returns {Array.} */ getFilters() { - return this._filters.slice(); + return this.getInternalFilters().slice(); + } + + /** + * Returns the non-opacity filters for this Node, with a direct reference. + * @public (scenery-internal) + * + * @returns {Array.} + */ + getInternalFilters() { + return this._record._get_( 'filters' ); } /** @@ -4582,25 +4548,40 @@ class Node extends PhetioObject { return this.getInputListeners(); } + /** + * @protected + * + * @param {string|null} cursor + */ + validateCursor( cursor ) { + assert && assert( typeof cursor === 'string' || cursor === null ); + } + + /** + * @protected + * + * @param {string|null} cursor + * @returns {string|null} + */ + onBeforeCursor( cursor ) { + return cursor === 'auto' ? null : cursor; + } + /** * Sets the CSS cursor string that should be used when the mouse is over this node. null is the default, and * indicates that ancestor nodes (or the browser default) should be used. * @public * + * TODO: consider a mapping of types to set reasonable defaults + * + * auto default none inherit help pointer progress wait crosshair text vertical-text alias copy move no-drop not-allowed + * e-resize n-resize w-resize s-resize nw-resize ne-resize se-resize sw-resize ew-resize ns-resize nesw-resize nwse-resize + * context-menu cell col-resize row-resize all-scroll url( ... ) --> does it support data URLs? + * * @param {string|null} cursor - A CSS cursor string, like 'pointer', or 'none' */ setCursor( cursor ) { - assert && assert( typeof cursor === 'string' || cursor === null ); - - // TODO: consider a mapping of types to set reasonable defaults - /* - auto default none inherit help pointer progress wait crosshair text vertical-text alias copy move no-drop not-allowed - e-resize n-resize w-resize s-resize nw-resize ne-resize se-resize sw-resize ew-resize ns-resize nesw-resize nwse-resize - context-menu cell col-resize row-resize all-scroll url( ... ) --> does it support data URLs? - */ - - // allow the 'auto' cursor type to let the ancestors or scene pick the cursor type - this._cursor = cursor === 'auto' ? null : cursor; + this._record._set_( 'cursor', cursor ); } /** @@ -4620,7 +4601,7 @@ class Node extends PhetioObject { * @returns {string|null} */ getCursor() { - return this._cursor; + return this._record._get_( 'cursor' ); } /** @@ -4633,6 +4614,27 @@ class Node extends PhetioObject { return this.getCursor(); } + /** + * @protected + * + * @param {Bounds2|Shape|null} mouseArea + */ + validateMouseArea( mouseArea ) { + assert && assert( mouseArea === null || mouseArea instanceof Shape || mouseArea instanceof Bounds2, + 'mouseArea needs to be a kite.Shape, dot.Bounds2, or null' ); + } + + /** + * @protected + * + * @param {Bounds2|Shape|null} area + * @param {Bounds2|Shape|null} oldArea + */ + onAfterMouseArea( area, oldArea ) { + this._picker.onMouseAreaChange(); + if ( assertSlow ) { this._picker.audit(); } + } + /** * Sets the hit-tested mouse area for this Node (see constructor for more advanced documentation). Use null for the * default behavior. @@ -4641,14 +4643,7 @@ class Node extends PhetioObject { * @param {Bounds2|Shape|null} area */ setMouseArea( area ) { - assert && assert( area === null || area instanceof Shape || area instanceof Bounds2, 'mouseArea needs to be a kite.Shape, dot.Bounds2, or null' ); - - if ( this._mouseArea !== area ) { - this._mouseArea = area; // TODO: could change what is under the mouse, invalidate! - - this._picker.onMouseAreaChange(); - if ( assertSlow ) { this._picker.audit(); } - } + this._record._set_( 'mouseArea', area ); } /** @@ -4668,7 +4663,7 @@ class Node extends PhetioObject { * @returns {Bounds2|Shape|null} */ getMouseArea() { - return this._mouseArea; + return this._record._get_( 'mouseArea' ); } /** @@ -4681,6 +4676,27 @@ class Node extends PhetioObject { return this.getMouseArea(); } + /** + * @protected + * + * @param {Bounds2|Shape|null} touchArea + */ + validateTouchArea( touchArea ) { + assert && assert( touchArea === null || touchArea instanceof Shape || touchArea instanceof Bounds2, + 'mouseArea needs to be a kite.Shape, dot.Bounds2, or null' ); + } + + /** + * @protected + * + * @param {Bounds2|Shape|null} area + * @param {Bounds2|Shape|null} oldArea + */ + onAfterTouchArea( area, oldArea ) { + this._picker.onTouchAreaChange(); + if ( assertSlow ) { this._picker.audit(); } + } + /** * Sets the hit-tested touch area for this Node (see constructor for more advanced documentation). Use null for the * default behavior. @@ -4689,14 +4705,7 @@ class Node extends PhetioObject { * @param {Bounds2|Shape|null} area */ setTouchArea( area ) { - assert && assert( area === null || area instanceof Shape || area instanceof Bounds2, 'touchArea needs to be a kite.Shape, dot.Bounds2, or null' ); - - if ( this._touchArea !== area ) { - this._touchArea = area; // TODO: could change what is under the touch, invalidate! - - this._picker.onTouchAreaChange(); - if ( assertSlow ) { this._picker.audit(); } - } + this._record._set_( 'touchArea', area ); } /** @@ -4716,7 +4725,7 @@ class Node extends PhetioObject { * @returns {Bounds2|Shape|null} */ getTouchArea() { - return this._touchArea; + return this._record._get_( 'touchArea' ); } /** @@ -4830,21 +4839,21 @@ class Node extends PhetioObject { } /** - * Sets a preferred renderer for this Node and its sub-tree. Scenery will attempt to use this renderer under here - * unless it isn't supported, OR another preferred renderer is set as a closer ancestor. Acceptable values are: - * - null (default, no preference) - * - 'canvas' - * - 'svg' - * - 'dom' - * - 'webgl' - * @public + * @protected * * @param {string|null} renderer */ - setRenderer( renderer ) { + validateRenderer( renderer ) { assert && assert( renderer === null || renderer === 'canvas' || renderer === 'svg' || renderer === 'dom' || renderer === 'webgl', 'Renderer input should be null, or one of: "canvas", "svg", "dom" or "webgl".' ); + } + /** + * @protected + * + * @param {string|null} renderer + */ + onBeforeRenderer( renderer ) { let newRenderer = 0; if ( renderer === 'canvas' ) { newRenderer = Renderer.bitmaskCanvas; @@ -4858,14 +4867,27 @@ class Node extends PhetioObject { else if ( renderer === 'webgl' ) { newRenderer = Renderer.bitmaskWebGL; } + assert && assert( ( renderer === null ) === ( newRenderer === 0 ), 'We should only end up with no actual renderer if renderer is null' ); - if ( this._hints.renderer !== newRenderer ) { - this._hints.renderer = newRenderer; + return newRenderer; + } - this.invalidateHint(); - } + /** + * Sets a preferred renderer for this Node and its sub-tree. Scenery will attempt to use this renderer under here + * unless it isn't supported, OR another preferred renderer is set as a closer ancestor. Acceptable values are: + * - null (default, no preference) + * - 'canvas' + * - 'svg' + * - 'dom' + * - 'webgl' + * @public + * + * @param {string|null} renderer + */ + setRenderer( renderer ) { + this._record._set_( 'renderer', renderer ); } /** @@ -4885,23 +4907,25 @@ class Node extends PhetioObject { * @returns {string|null} */ getRenderer() { - if ( this._hints.renderer === 0 ) { + const renderer = this.getInternalRenderer(); + + if ( renderer === 0 ) { return null; } - else if ( this._hints.renderer === Renderer.bitmaskCanvas ) { + else if ( renderer === Renderer.bitmaskCanvas ) { return 'canvas'; } - else if ( this._hints.renderer === Renderer.bitmaskSVG ) { + else if ( renderer === Renderer.bitmaskSVG ) { return 'svg'; } - else if ( this._hints.renderer === Renderer.bitmaskDOM ) { + else if ( renderer === Renderer.bitmaskDOM ) { return 'dom'; } - else if ( this._hints.renderer === Renderer.bitmaskWebGL ) { + else if ( renderer === Renderer.bitmaskWebGL ) { return 'webgl'; } assert && assert( false, 'Seems to be an invalid renderer?' ); - return this._hints.renderer; + return renderer; } /** @@ -4914,21 +4938,33 @@ class Node extends PhetioObject { return this.getRenderer(); } + /** + * @public (scenery-internal) + * + * @returns {number} + */ + getInternalRenderer() { + return this._record._get_with_default_mapped( 'renderer' ); + } + + /** + * @protected + * + * @param {boolean} layerSplit + */ + validateLayerSplit( layerSplit ) { + assert && assert( typeof layerSplit === 'boolean' ); + } + /** * Sets whether or not Scenery will try to put this Node (and its descendants) into a separate SVG/Canvas/WebGL/etc. * layer, different from other siblings or other nodes. Can be used for performance purposes. * @public * - * @param {boolean} split + * @param {boolean} layerSplit */ - setLayerSplit( split ) { - assert && assert( typeof split === 'boolean' ); - - if ( split !== this._hints.layerSplit ) { - this._hints.layerSplit = split; - - this.invalidateHint(); - } + setLayerSplit( layerSplit ) { + this._record._set_( 'layerSplit', layerSplit ); } /** @@ -4948,7 +4984,7 @@ class Node extends PhetioObject { * @returns {boolean} */ isLayerSplit() { - return this._hints.layerSplit; + return this._record._get_( 'layerSplit' ); } /** @@ -4961,21 +4997,27 @@ class Node extends PhetioObject { return this.isLayerSplit(); } + /** + * @protected + * + * @param {boolean} usesOpacity + */ + validateUsesOpacity( usesOpacity ) { + assert && assert( typeof usesOpacity === 'boolean' ); + } + /** * Sets whether or not Scenery will take into account that this Node plans to use opacity. Can have performance * gains if there need to be multiple layers for this node's descendants. * @public * + * Whether it is anticipated that opacity will be switched on. If so, having this set to true will make switching + * back-and-forth between opacity:1 and other opacities much faster. + * * @param {boolean} usesOpacity */ setUsesOpacity( usesOpacity ) { - assert && assert( typeof usesOpacity === 'boolean' ); - - if ( usesOpacity !== this._hints.usesOpacity ) { - this._hints.usesOpacity = usesOpacity; - - this.invalidateHint(); - } + this._record._set_( 'usesOpacity', usesOpacity ); } /** @@ -4995,7 +5037,7 @@ class Node extends PhetioObject { * @returns {boolean} */ getUsesOpacity() { - return this._hints.usesOpacity; + return this._record._get_( 'usesOpacity' ); } /** @@ -5008,6 +5050,15 @@ class Node extends PhetioObject { return this.getUsesOpacity(); } + /** + * @protected + * + * @param {boolean} cssTransform + */ + validateCSSTransform( cssTransform ) { + assert && assert( typeof cssTransform === 'boolean' ); + } + /** * Sets a flag for whether whether the contents of this Node and its children should be displayed in a separate * DOM element that is transformed with CSS transforms. It can have potential speedups, since the browser may not @@ -5017,13 +5068,7 @@ class Node extends PhetioObject { * @param {boolean} cssTransform */ setCSSTransform( cssTransform ) { - assert && assert( typeof cssTransform === 'boolean' ); - - if ( cssTransform !== this._hints.cssTransform ) { - this._hints.cssTransform = cssTransform; - - this.invalidateHint(); - } + this._record._set_( 'cssTransform', cssTransform ); } /** @@ -5043,7 +5088,7 @@ class Node extends PhetioObject { * @returns {boolean} */ isCSSTransformed() { - return this._hints.cssTransform; + return this._record._get_( 'cssTransform' ); } /** @@ -5056,6 +5101,15 @@ class Node extends PhetioObject { return this.isCSSTransformed(); } + /** + * @protected + * + * @param {boolean} excludeInvisible + */ + validateExcludeInvisible( excludeInvisible ) { + assert && assert( typeof excludeInvisible === 'boolean' ); + } + /** * Sets a performance flag for whether layers/DOM elements should be excluded (or included) when things are * invisible. The default is false, and invisible content is in the DOM, but hidden. @@ -5064,13 +5118,7 @@ class Node extends PhetioObject { * @param {boolean} excludeInvisible */ setExcludeInvisible( excludeInvisible ) { - assert && assert( typeof excludeInvisible === 'boolean' ); - - if ( excludeInvisible !== this._hints.excludeInvisible ) { - this._hints.excludeInvisible = excludeInvisible; - - this.invalidateHint(); - } + this._record._set_( 'excludeInvisible', excludeInvisible ); } /** @@ -5090,7 +5138,7 @@ class Node extends PhetioObject { * @returns {boolean} */ isExcludeInvisible() { - return this._hints.excludeInvisible; + return this._record._get_( 'excludeInvisible' ); } /** @@ -5154,20 +5202,30 @@ class Node extends PhetioObject { } /** - * Sets options that are provided to layout managers in order to customize positioning of this node. - * @public + * @protected * * @param {Object|null} layoutOptions */ - setLayoutOptions( layoutOptions ) { + validateLayoutOptions( layoutOptions ) { assert && assert( layoutOptions === null || ( typeof layoutOptions === 'object' && Object.getPrototypeOf( layoutOptions ) === Object.prototype ), 'layoutOptions should be null or an plain options-style object' ); + } - if ( layoutOptions !== this._layoutOptions ) { - this._layoutOptions = layoutOptions; + /** + * @protected + */ + onAfterLayoutOptions() { + this.layoutOptionsChangedEmitter.emit(); + } - this.layoutOptionsChangedEmitter.emit(); - } + /** + * Sets options that are provided to layout managers in order to customize positioning of this node. + * @public + * + * @param {Object|null} layoutOptions + */ + setLayoutOptions( layoutOptions ) { + this._record._set_( 'layoutOptions', layoutOptions ); } /** @@ -5194,7 +5252,7 @@ class Node extends PhetioObject { * @returns {Object|null} */ get layoutOptions() { - return this.getLayoutOptions(); + return this._record._get_( 'layoutOptions' ); } /** @@ -5206,20 +5264,27 @@ class Node extends PhetioObject { this.layoutOptions = merge( {}, this.layoutOptions, layoutOptions ); } + /** + * @protected + * + * @param {boolean} preventFit + */ + validatePreventFit( preventFit ) { + assert && assert( typeof preventFit === 'boolean' ); + } + /** * Sets the preventFit performance flag. * @public * + * If true, Scenery will not fit any blocks that contain drawables attached to Nodes underneath this + * Node's subtree. This will typically prevent Scenery from triggering bounds computation for this + * sub-tree, and movement of this Node or its descendants will never trigger the refitting of a block. + * * @param {boolean} preventFit */ setPreventFit( preventFit ) { - assert && assert( typeof preventFit === 'boolean' ); - - if ( preventFit !== this._hints.preventFit ) { - this._hints.preventFit = preventFit; - - this.invalidateHint(); - } + this._record._set_( 'preventFit', preventFit ); } /** @@ -5239,7 +5304,7 @@ class Node extends PhetioObject { * @returns {boolean} */ isPreventFit() { - return this._hints.preventFit; + return this._record._get_( 'preventFit' ); } /** @@ -5252,6 +5317,15 @@ class Node extends PhetioObject { return this.isPreventFit(); } + /** + * @protected + * + * @param {boolean} webglScale + */ + validateWebGLScale( webglScale ) { + assert && assert( webglScale === null || ( typeof webglScale === 'number' && isFinite( webglScale ) ) ); + } + /** * Sets whether there is a custom WebGL scale applied to the Canvas, and if so what scale. * @public @@ -5259,13 +5333,7 @@ class Node extends PhetioObject { * @param {number|null} webglScale */ setWebGLScale( webglScale ) { - assert && assert( webglScale === null || ( typeof webglScale === 'number' && isFinite( webglScale ) ) ); - - if ( webglScale !== this._hints.webglScale ) { - this._hints.webglScale = webglScale; - - this.invalidateHint(); - } + this._record._set_( 'webglScale', webglScale ); } /** @@ -5285,7 +5353,7 @@ class Node extends PhetioObject { * @returns {number|null} */ getWebGLScale() { - return this._hints.webglScale; + this._record._get_( 'webglScale' ); } /** @@ -5622,7 +5690,7 @@ class Node extends PhetioObject { const child = this._children[ i ]; if ( child.isVisible() ) { - const requiresScratchCanvas = child.effectiveOpacity !== 1 || child.clipArea || child._filters.length; + const requiresScratchCanvas = child.effectiveOpacity !== 1 || child.clipArea || child.getInternalFilters().length; wrapper.context.save(); matrix.multiplyMatrix( child._transform.getMatrix() ); @@ -5648,16 +5716,17 @@ class Node extends PhetioObject { wrapper.context.globalAlpha = child.effectiveOpacity; let setFilter = false; - if ( child._filters.length ) { + const filters = child.getInternalFilters(); + if ( filters.length ) { // Filters shouldn't be too often, so less concerned about the GC here (and this is so much easier to read). // Performance bottleneck for not using this fallback style, so we're allowing it for Chrome even though // the visual differences may be present, see https://github.com/phetsims/scenery/issues/1139 - if ( Features.canvasFilter && _.every( child._filters, filter => filter.isDOMCompatible() ) ) { - wrapper.context.filter = child._filters.map( filter => filter.getCSSFilterString() ).join( ' ' ); + if ( Features.canvasFilter && _.every( filters, filter => filter.isDOMCompatible() ) ) { + wrapper.context.filter = filters.map( filter => filter.getCSSFilterString() ).join( ' ' ); setFilter = true; } else { - child._filters.forEach( filter => filter.applyCanvasFilter( childWrapper ) ); + filters.forEach( filter => filter.applyCanvasFilter( childWrapper ) ); } } @@ -6884,6 +6953,14 @@ class Node extends PhetioObject { } } + /** + * @protected + * + * @returns {Object} + */ + get recordConfig() { + return Node.RECORD_CONFIG; + } /** * A default for getTrails() searches, returns whether the Node has no parents. @@ -6943,7 +7020,64 @@ Node.DEFAULT_OPTIONS = DEFAULT_OPTIONS; scenery.register( 'Node', Node ); // Node is composed with this feature of Interactive Description -ParallelDOM.compose( Node ); +const parallelDOMRecordConfig = ParallelDOM.compose( Node ); + +// @protected {Object} +Node.RECORD_CONFIG = Record.combineConfigs( PhetioObject.RECORD_CONFIG, parallelDOMRecordConfig, Record.configFromDefaults( DEFAULT_OPTIONS ), { + mouseArea: { + onAfter: Node.prototype.onAfterMouseArea, + validate: Node.prototype.validateMouseArea + }, + touchArea: { + onAfter: Node.prototype.onAfterTouchArea, + validate: Node.prototype.validateTouchArea + }, + usesOpacity: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateUsesOpacity + }, + layerSplit: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateLayerSplit + }, + cssTransform: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateCSSTransform + }, + excludeInvisible: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateExcludeInvisible + }, + webglScale: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateWebGLScale + }, + preventFit: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validatePreventFit + }, + renderer: { + onAfter: Node.prototype.invalidateHint, + validate: Node.prototype.validateRenderer, + onBefore: Node.prototype.onBeforeRenderer + }, + cursor: { + validate: Node.prototype.validateCursor, + onBefore: Node.prototype.onBeforeCursor + }, + filters: { + defaultValue: [], + create: () => [] + }, + transformBounds: { + validate: Node.prototype.validateTransformBounds, + onAfter: Node.prototype.invalidateBounds + }, + layoutOptions: { + validate: Node.prototype.validateLayoutOptions, + onAfter: Node.prototype.onAfterLayoutOptions + } +} ); // @public {IOType} Node.NodeIO = new IOType( 'NodeIO', { diff --git a/js/util/RendererSummary.js b/js/util/RendererSummary.js index ff9c5c96d..cee408cc2 100644 --- a/js/util/RendererSummary.js +++ b/js/util/RendererSummary.js @@ -368,8 +368,8 @@ class RendererSummary { } // NOTE: If changing, see Instance.updateRenderingState - const requiresSplit = node._hints.cssTransform || node._hints.layerSplit; - const rendererHint = node._hints.renderer; + const requiresSplit = node.cssTransform || node.layerSplit; + const rendererHint = node.getInternalRenderer(); // Whether this subtree will be able to support a single SVG element // NOTE: If changing, see Instance.updateRenderingState