diff --git a/js/accessibility/pdom/ParallelDOM.ts b/js/accessibility/pdom/ParallelDOM.ts index 4ee5ce55c..753ebe9fb 100644 --- a/js/accessibility/pdom/ParallelDOM.ts +++ b/js/accessibility/pdom/ParallelDOM.ts @@ -231,7 +231,7 @@ const ACCESSIBILITY_OPTION_KEYS = [ 'ariaDescribedbyAssociations', 'activeDescendantAssociations', - 'createFocusPanTargetBounds', + 'focusPanTargetBoundsProperty', 'limitPanDirection', 'positionInPDOM', @@ -288,7 +288,7 @@ export type ParallelDOMOptions = { ariaDescribedbyAssociations?: Association[]; // sets the list of aria-describedby associations between from this node to others (including itself) activeDescendantAssociations?: Association[]; // sets the list of aria-activedescendant associations between from this node to others (including itself) - createFocusPanTargetBounds?: ( () => Bounds2 ) | null; // A function that sets the global bounds for an AnimatedPanZoomListener to keep in view + focusPanTargetBoundsProperty?: TReadOnlyProperty | null; // A Property with bounds that describe the bounds of this Node that should remain displayed by the global AnimatedPanZoomListener limitPanDirection?: LimitPanDirection | null; // A constraint on the direction of panning when interacting with this Node. positionInPDOM?: boolean; // Sets whether the node's DOM elements are positioned in the viewport @@ -513,7 +513,7 @@ export default class ParallelDOM extends PhetioObject { // If this is provided, the AnimatedPanZoomListener will attempt to keep this Node in view as long as it has // focus - private _createFocusPanTargetBounds: ( () => Bounds2 ) | null; + private _focusPanTargetBoundsProperty: TReadOnlyProperty | null; // If provided, the AnimatedPanZoomListener will ONLY pan in the specified direction private _limitPanDirection: LimitPanDirection | null; @@ -608,7 +608,7 @@ export default class ParallelDOM extends PhetioObject { this._pdomOrder = null; this._pdomParent = null; this._pdomTransformSourceNode = null; - this._createFocusPanTargetBounds = null; + this._focusPanTargetBoundsProperty = null; this._limitPanDirection = null; this._pdomDisplaysInfo = new PDOMDisplaysInfo( this as unknown as Node ); this._pdomInstances = []; @@ -2581,39 +2581,42 @@ export default class ParallelDOM extends PhetioObject { } /** - * Sets a function on this Node that will be used by the animatedPanZoomSingleton. It will try to keep these global - * bounds visible in the viewport when this Node (or any ancestor) has a transformation change while actively - * focused. This is useful if the bounds of your focusable Node do not accurately surround the conceptual interactive - * component. + * Used by the animatedPanZoomSingleton. It will try to keep these bounds visible in the viewport when this Node + * (or any ancestor) has a transform change while focused. This is useful if the bounds of your focusable + * Node do not accurately surround the conceptual interactive component. If null, this Node's local bounds + * are used. * - * @param createFocusPanTargetBounds - returns bounds in the global coordinate frame + * At this time, the Property cannot be changed after it is set. */ - public setCreateFocusPanTargetBounds( createFocusPanTargetBounds: null | ( () => Bounds2 ) ): void { - this._createFocusPanTargetBounds = createFocusPanTargetBounds; - } + public setFocusPanTargetBoundsProperty( boundsProperty: null | TReadOnlyProperty ): void { + // We may call this more than once with mutate + if ( boundsProperty !== this._focusPanTargetBoundsProperty ) { + assert && assert( !this._focusPanTargetBoundsProperty, 'Cannot change focusPanTargetBoundsProperty after it is set.' ); + this._focusPanTargetBoundsProperty = boundsProperty; + } + } /** * Returns the function for creating global bounds to keep in the viewport while the component has focus, see the - * setCreateFocusPanTargetBounds function for more information. + * setFocusPanTargetBoundsProperty function for more information. */ - public getCreateFocusPanTargetBounds(): null | ( () => Bounds2 ) { - return this._createFocusPanTargetBounds; + public getFocusPanTargetBoundsProperty(): null | TReadOnlyProperty { + return this._focusPanTargetBoundsProperty; } /** - * See setCreateFocusPanTargetBounds for more information. - * @param createFocusPanTargetBounds + * See setFocusPanTargetBoundsProperty for more information. */ - public set createFocusPanTargetBounds( createFocusPanTargetBounds: null | ( () => Bounds2 ) ) { - this.setCreateFocusPanTargetBounds( createFocusPanTargetBounds ); + public set focusPanTargetBoundsProperty( boundsProperty: null | TReadOnlyProperty ) { + this.setFocusPanTargetBoundsProperty( boundsProperty ); } /** - * See getCreateFocusPanTargetBounds for more information. + * See getFocusPanTargetBoundsProperty for more information. */ - public get createFocusPanTargetBounds(): null | ( () => Bounds2 ) { - return this.getCreateFocusPanTargetBounds(); + public get focusPanTargetBoundsProperty(): null | TReadOnlyProperty { + return this.getFocusPanTargetBoundsProperty(); } /** diff --git a/js/listeners/AnimatedPanZoomListener.ts b/js/listeners/AnimatedPanZoomListener.ts index fcb1de6c9..f5504bc6a 100644 --- a/js/listeners/AnimatedPanZoomListener.ts +++ b/js/listeners/AnimatedPanZoomListener.ts @@ -16,10 +16,11 @@ import platform from '../../../phet-core/js/platform.js'; import EventType from '../../../tandem/js/EventType.js'; import isSettingPhetioStateProperty from '../../../tandem/js/isSettingPhetioStateProperty.js'; import PhetioAction from '../../../tandem/js/PhetioAction.js'; -import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, Mouse, MultiListenerPress, Node, LimitPanDirection, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail, TransformTracker } from '../imports.js'; +import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, LimitPanDirection, Mouse, MultiListenerPress, Node, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail, TransformTracker } from '../imports.js'; import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js'; import Tandem from '../../../tandem/js/Tandem.js'; import BooleanProperty from '../../../axon/js/BooleanProperty.js'; +import { PropertyLinkListener } from '../../../axon/js/TReadOnlyProperty.js'; // constants const MOVE_CURSOR = 'all-scroll'; @@ -111,6 +112,10 @@ class AnimatedPanZoomListener extends PanZoomListener { // the targetNode in view during animation. private _transformTracker: TransformTracker | null = null; + // A listener on the focusPanTargetBoundsProperty of the focused Node that will keep those bounds displayed in + // the viewport. + private _focusBoundsListener: PropertyLinkListener | null = null; + private readonly disposeAnimatedPanZoomListener: () => void; /** @@ -568,13 +573,24 @@ class AnimatedPanZoomListener extends PanZoomListener { /** * Handle a change of focus by immediately panning so that the focused Node is in view. Also sets up the - * TransformTracker which will automatically keep the target in the viewport as it is animates. + * TransformTracker which will automatically keep the target in the viewport as it is animates, and a listener + * on the focusPanTargetBoundsProperty (if provided) to handle Node other size or custom changes. */ - public handleFocusChange( focus: Focus | null ): void { + public handleFocusChange( focus: Focus | null, previousFocus: Focus | null ): void { + + // Remove listeners on the previous focus watching transform and bounds changes if ( this._transformTracker ) { this._transformTracker.dispose(); this._transformTracker = null; } + if ( previousFocus && previousFocus.trail.lastNode() && previousFocus.trail.lastNode().focusPanTargetBoundsProperty ) { + const previousBoundsProperty = previousFocus.trail.lastNode().focusPanTargetBoundsProperty!; + assert && assert( this._focusBoundsListener && previousBoundsProperty.hasListener( this._focusBoundsListener ), + 'Focus bounds listener should be linked to the previous Node' + ); + previousBoundsProperty.unlink( this._focusBoundsListener! ); + this._focusBoundsListener = null; + } if ( focus ) { const lastNode = focus.trail.lastNode(); @@ -590,14 +606,16 @@ class AnimatedPanZoomListener extends PanZoomListener { } this._transformTracker = new TransformTracker( trailToTrack ); - this._transformTracker.addListener( () => { + + const focusMovementListener = () => { if ( this.getCurrentScale() > 1 ) { let globalBounds: Bounds2; - if ( lastNode.createFocusPanTargetBounds ) { + if ( lastNode.focusPanTargetBoundsProperty ) { - // This Node has a custom global bounds area that we need to keep in view - globalBounds = lastNode.createFocusPanTargetBounds(); + // This Node has a custom bounds area that we need to keep in view + const localBounds = lastNode.focusPanTargetBoundsProperty.value; + globalBounds = focus.trail.localToGlobalBounds( localBounds ); } else { @@ -608,7 +626,16 @@ class AnimatedPanZoomListener extends PanZoomListener { this.keepBoundsInView( globalBounds, true, lastNode.limitPanDirection ); } - } ); + }; + + // observe changes to the transform + this._transformTracker.addListener( focusMovementListener ); + + // observe changes on the client-provided local bounds + if ( lastNode.focusPanTargetBoundsProperty ) { + this._focusBoundsListener = focusMovementListener; + lastNode.focusPanTargetBoundsProperty.link( this._focusBoundsListener ); + } // Pan to the focus trail right away if it is off-screen this.keepTrailInView( focus.trail, lastNode.limitPanDirection );