phetsims / center-and-variability

"Center and Variability" is an educational simulation in HTML5, by PhET Interactive Simulations.
GNU General Public License v3.0
1 stars 2 forks source link

Add keyboard alt-input for the cards on the Median screen #351

Closed catherinecarter closed 1 year ago

catherinecarter commented 1 year ago

Currently on the Median screen, the tabs skip over the cards in the Accordion box. When tabbing into the accordion box, the cards will each get their own tab so the user can move each card if they wish.

marlitas commented 1 year ago

Made some progress here, but the slider functionality for the cards needs work. It will navigate to cards, but we need to figure out which property is going to be best to move the cards along. It could be the cellPositionProperty, but we'll need a way to adjust the enabledRangeProperty as the list of cards grows. We'll also need a way to track it's old value... I'm wondering if some of this could be done through a link to the cellPositionProperty, but I ran out of time to really investigate things.

Here's a patch that will hopefully help a bit.

```diff Subject: [PATCH] Set pdom order for Median screen and add alternative input to cards --- Index: js/median/view/MedianAccordionBox.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/MedianAccordionBox.ts b/js/median/view/MedianAccordionBox.ts --- a/js/median/view/MedianAccordionBox.ts (revision a993c261b5a60d7cb40d94f8ccc0a19510f52632) +++ b/js/median/view/MedianAccordionBox.ts (date 1689199740546) @@ -47,8 +47,9 @@ margin: CAVConstants.ACCORDION_BOX_HORIZONTAL_MARGIN } ); - backgroundNode.addChild( new CAVInfoButton( model.isInfoVisibleProperty, backgroundShape, tandem.createTandem( 'infoButton' ) ) ); + const infoButton = new CAVInfoButton( model.isInfoVisibleProperty, backgroundShape, tandem.createTandem( 'infoButton' ) ); + backgroundNode.addChild( infoButton ); backgroundNode.addChild( cardNodeContainer ); backgroundNode.addChild( checkboxGroupAlignBox ); @@ -62,6 +63,12 @@ } ); this.cardNodeContainer = cardNodeContainer; + + this.pdomOrder = [ + this.cardNodeContainer, + checkboxGroup, + infoButton + ]; } } Index: js/median/view/CardNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/CardNode.ts b/js/median/view/CardNode.ts --- a/js/median/view/CardNode.ts (revision a993c261b5a60d7cb40d94f8ccc0a19510f52632) +++ b/js/median/view/CardNode.ts (date 1689201086743) @@ -14,7 +14,6 @@ import SoccerBall from '../../soccer-common/model/SoccerBall.js'; import Vector2 from '../../../../dot/js/Vector2.js'; import CardModel from '../model/CardModel.js'; -import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; import CardNodeContainer from './CardNodeContainer.js'; import SoundClip from '../../../../tambo/js/sound-generators/SoundClip.js'; import soundManager from '../../../../tambo/js/soundManager.js'; @@ -22,9 +21,12 @@ import cvCardDropSound_mp3 from '../../../sounds/cvCardDropSound_mp3.js'; import CAVQueryParameters from '../../common/CAVQueryParameters.js'; import CAVConstants from '../../common/CAVConstants.js'; +import AccessibleSlider, { AccessibleSliderOptions } from '../../../../sun/js/accessibility/AccessibleSlider.js'; +import WithRequired from '../../../../phet-core/js/types/WithRequired.js'; type SelfOptions = EmptySelfOptions; -export type CardNodeOptions = SelfOptions & NodeOptions & PickRequired; +type ParentOptions = WithRequired & WithRequired; +export type CardNodeOptions = SelfOptions & ParentOptions; export const cardPickUpSoundClip = new SoundClip( cvCardPickupSound_mp3, { initialOutputLevel: 0.3, @@ -42,7 +44,7 @@ export const PICK_UP_DELTA_X = -4; export const PICK_UP_DELTA_Y = -4; -export default class CardNode extends Node { +export default class CardNode extends AccessibleSlider( Node, 0 ) { public readonly dragListener: DragListener; @@ -73,10 +75,14 @@ children: [ card ] } ); - const options = optionize()( { + const options = optionize()( { children: [ offsetContainer ], cursor: 'pointer', - phetioVisiblePropertyInstrumented: false + phetioVisiblePropertyInstrumented: false, + keyboardStep: 1, + shiftKeyboardStep: 1, + pageKeyboardStep: 5, + roundToStepSize: true }, providedOptions ); super( options ); Index: js/median/view/CardNodeContainer.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/CardNodeContainer.ts b/js/median/view/CardNodeContainer.ts --- a/js/median/view/CardNodeContainer.ts (revision a993c261b5a60d7cb40d94f8ccc0a19510f52632) +++ b/js/median/view/CardNodeContainer.ts (date 1689201086728) @@ -38,6 +38,7 @@ import CardContainerModel from '../model/CardContainerModel.js'; import CardModel from '../model/CardModel.js'; import CAVSoccerSceneModel from '../../common/model/CAVSoccerSceneModel.js'; +import DynamicProperty from '../../../../axon/js/DynamicProperty.js'; const successSoundClip = new SoundClip( cvSuccessOptions002_mp3, { initialOutputLevel: 0.2 @@ -77,8 +78,20 @@ // Allocate all the cards at start-up. Each card node must be associated with a card model. this.cardNodes = model.cards.map( ( cardModel, index ) => { + // The drag listener requires a numeric value (does not support null), so map it through a DynamicProperty + const dynamicProperty = new DynamicProperty( new Property( cardModel.cellPositionProperty ), { + bidirectional: true, + map: function( value: number | null ) { return value === null ? 0 : value;}, + inverseMap: function( value: number ) { return value === 0 ? null : value; } + } ); + const cardNode = new CardNode( this, cardModel, { - tandem: options.tandem.createTandem( 'cardNodes' ).createTandem1Indexed( 'cardNode', index ) + tandem: options.tandem.createTandem( 'cardNodes' ).createTandem1Indexed( 'cardNode', index ), + enabledRangeProperty: new Property( CAVConstants.PHYSICAL_RANGE ), + valueProperty: dynamicProperty, + startDrag: () => { + + } } ); this.cardMap.set( cardNode.cardModel, cardNode ); Index: js/common/view/CAVScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/common/view/CAVScreenView.ts b/js/common/view/CAVScreenView.ts --- a/js/common/view/CAVScreenView.ts (revision a993c261b5a60d7cb40d94f8ccc0a19510f52632) +++ b/js/common/view/CAVScreenView.ts (date 1689199740574) @@ -75,6 +75,7 @@ protected readonly backScreenViewLayer; private readonly middleScreenViewLayer = new Node(); private readonly frontScreenViewLayer; + protected readonly screenViewRootNode = new Node(); protected readonly intervalToolLayer = new Node(); @@ -89,6 +90,7 @@ private readonly updateMedianNode: () => void; private readonly updateDragIndicatorNode: () => void; protected readonly numberOfKicksProperty: DynamicProperty; + protected readonly kickButtonGroup: KickButtonGroup; public constructor( model: CAVModel, providedOptions: CAVScreenViewOptions ) { const options = optionize()( {}, providedOptions ); @@ -163,6 +165,10 @@ backLayerToggleNode ] } ); + this.backScreenViewLayer.pdomOrder = [ + backLayerToggleNode, + this.intervalToolLayer + ]; this.resetAllButton = new ResetAllButton( { listener: () => { @@ -202,7 +208,7 @@ } }, options.questionBarOptions ) ); - const kickButtonGroup = new KickButtonGroup( model, { + this.kickButtonGroup = new KickButtonGroup( model, { // Center under where the soccer player nodes will be. Since the SoccerPlayerNode are positioned in the // SceneView, we can't use those node bounds to position the kick buttons, so this is a manually tuned magic number. @@ -265,7 +271,7 @@ this.eraseButton, this.resetAllButton, this.questionBar, - kickButtonGroup, + this.kickButtonGroup, playAreaMedianIndicatorNode ] } ); @@ -298,9 +304,12 @@ this.middleScreenViewLayer.addChild( dragIndicatorArrowNode ); - this.addChild( this.backScreenViewLayer ); - this.addChild( this.middleScreenViewLayer ); - this.addChild( this.frontScreenViewLayer ); + // Add to screenViewRootNode for alternativeInput + this.screenViewRootNode.addChild( this.backScreenViewLayer ); + this.screenViewRootNode.addChild( this.middleScreenViewLayer ); + this.screenViewRootNode.addChild( this.frontScreenViewLayer ); + + this.addChild( this.screenViewRootNode ); } // calculate where the top object is at a given value @@ -341,7 +350,7 @@ this.accordionBox.boundsProperty.link( this.updateDragIndicatorNode ); } - protected setBottomControls( controlNode: Node, tandem: Tandem ): void { + protected setBottomControls( controlNode: Node, tandem: Tandem ): AlignBox { // In order to use the AlignBox we need to know the distance from the top of the screen, to the top of the grass. const BOTTOM_CHECKBOX_PANEL_MARGIN = 12.5; @@ -362,13 +371,17 @@ ] } ); - this.addChild( new AlignBox( controlsVBox, { + const bottomControls = new AlignBox( controlsVBox, { alignBounds: this.layoutBounds, xAlign: 'right', yAlign: 'bottom', xMargin: BOTTOM_CHECKBOX_PANEL_MARGIN, yMargin: BOTTOM_CHECKBOX_PANEL_Y_MARGIN - } ) ); + } ); + + this.screenViewRootNode.addChild( bottomControls ); + + return bottomControls; } public getSoccerPlayerImageSet( soccerPlayer: SoccerPlayer, sceneModel: SoccerSceneModel ): SoccerPlayerImageSet { Index: js/median/view/MedianScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/MedianScreenView.ts b/js/median/view/MedianScreenView.ts --- a/js/median/view/MedianScreenView.ts (revision a993c261b5a60d7cb40d94f8ccc0a19510f52632) +++ b/js/median/view/MedianScreenView.ts (date 1689199357799) @@ -49,7 +49,7 @@ const iconGroup = new AlignGroup(); - this.setBottomControls( new VerticalCheckboxGroup( [ + const bottomControls = this.setBottomControls( new VerticalCheckboxGroup( [ PlayAreaCheckboxFactory.getPredictMedianCheckboxItem( iconGroup, model ), PlayAreaCheckboxFactory.getMedianCheckboxItem( iconGroup, model ) ], { @@ -74,6 +74,16 @@ infoDialog.hide(); } } ); + + this.screenViewRootNode.pdomOrder = [ + this.kickButtonGroup, + this.backScreenViewLayer, + bottomControls, + this.accordionBox, + infoDialog, + this.eraseButton, + this.resetAllButton + ]; } } ```
samreid commented 1 year ago

Patch with ideas and TODOs:

```diff Subject: [PATCH] Show "1 meter" instead of "1 meters" for IQR in the info dialog, see https://github.com/phetsims/center-and-variability/issues/325 --- Index: js/median/view/MedianAccordionBox.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/MedianAccordionBox.ts b/js/median/view/MedianAccordionBox.ts --- a/js/median/view/MedianAccordionBox.ts (revision 0f6ae0a101c2c51765ac8aa5300bb28bf5e51148) +++ b/js/median/view/MedianAccordionBox.ts (date 1689686172930) @@ -47,8 +47,9 @@ margin: CAVConstants.ACCORDION_BOX_HORIZONTAL_MARGIN } ); - backgroundNode.addChild( new CAVInfoButton( model.isInfoVisibleProperty, backgroundShape, tandem.createTandem( 'infoButton' ) ) ); + const infoButton = new CAVInfoButton( model.isInfoVisibleProperty, backgroundShape, tandem.createTandem( 'infoButton' ) ); + backgroundNode.addChild( infoButton ); backgroundNode.addChild( cardNodeContainer ); backgroundNode.addChild( checkboxGroupAlignBox ); @@ -62,6 +63,12 @@ } ); this.cardNodeContainer = cardNodeContainer; + + this.pdomOrder = [ + this.cardNodeContainer, + checkboxGroup, + infoButton + ]; } } Index: js/median/view/CardNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/CardNode.ts b/js/median/view/CardNode.ts --- a/js/median/view/CardNode.ts (revision 0f6ae0a101c2c51765ac8aa5300bb28bf5e51148) +++ b/js/median/view/CardNode.ts (date 1689687677390) @@ -14,7 +14,6 @@ import SoccerBall from '../../soccer-common/model/SoccerBall.js'; import Vector2 from '../../../../dot/js/Vector2.js'; import CardModel from '../model/CardModel.js'; -import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; import CardNodeContainer from './CardNodeContainer.js'; import SoundClip from '../../../../tambo/js/sound-generators/SoundClip.js'; import soundManager from '../../../../tambo/js/soundManager.js'; @@ -22,9 +21,12 @@ import cvCardDropSound_mp3 from '../../../sounds/cvCardDropSound_mp3.js'; import CAVQueryParameters from '../../common/CAVQueryParameters.js'; import CAVConstants from '../../common/CAVConstants.js'; +import AccessibleSlider, { AccessibleSliderOptions } from '../../../../sun/js/accessibility/AccessibleSlider.js'; +import WithRequired from '../../../../phet-core/js/types/WithRequired.js'; type SelfOptions = EmptySelfOptions; -export type CardNodeOptions = SelfOptions & NodeOptions & PickRequired; +type ParentOptions = WithRequired & WithRequired; +export type CardNodeOptions = SelfOptions & ParentOptions; export const cardPickUpSoundClip = new SoundClip( cvCardPickupSound_mp3, { initialOutputLevel: 0.3, @@ -42,7 +44,7 @@ export const PICK_UP_DELTA_X = -4; export const PICK_UP_DELTA_Y = -4; -export default class CardNode extends Node { +export default class CardNode extends AccessibleSlider( Node, 0 ) { public readonly dragListener: DragListener; @@ -50,6 +52,7 @@ private cardsToTheLeft: CardNode[] = []; + // TODO: Maybe rename cardModel => card??? Or maybe not. Or maybe "model" public constructor( public readonly cardNodeContainer: CardNodeContainer, public readonly cardModel: CardModel, providedOptions: CardNodeOptions ) { const cornerRadius = 10; @@ -62,6 +65,7 @@ font: new PhetFont( 24 ) } ); + // TODO: rename this to cardNode const card = new Node( { children: [ rectangle, text ] } ); @@ -73,14 +77,32 @@ children: [ card ] } ); - const options = optionize()( { + const options = optionize()( { children: [ offsetContainer ], cursor: 'pointer', - phetioVisiblePropertyInstrumented: false + phetioVisiblePropertyInstrumented: false, + keyboardStep: 1, + shiftKeyboardStep: 1, + + // TODO: let's test this + pageKeyboardStep: 5, + roundToStepSize: true }, providedOptions ); super( options ); + // private getHomePosition( card: CardModel ): Vector2 { + // assert && assert( card.cellPositionProperty.value !== null, `The card's cell position cannot be null. cellPositionProperty: ${card.cellPositionProperty.value}` ); + // return new Vector2( this.getCardPositionX( card.cellPositionProperty.value! ), 0 ); + // } + // + // public setAtHomeCell( card: CardModel ): void { + // card.positionProperty.value = this.getHomePosition( card ); + // } + options.valueProperty.link( cellIndex => { + cardModel.positionProperty.value = new Vector2( cardNodeContainer.model.getCardPositionX( cellIndex ), 0 ); + } ); + cardModel.soccerBall.valueProperty.link( value => { text.string = value === null ? '' : value + ''; text.center = rectangle.center; Index: js/common/view/CAVScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/common/view/CAVScreenView.ts b/js/common/view/CAVScreenView.ts --- a/js/common/view/CAVScreenView.ts (revision 0f6ae0a101c2c51765ac8aa5300bb28bf5e51148) +++ b/js/common/view/CAVScreenView.ts (date 1689686580849) @@ -75,6 +75,7 @@ protected readonly backScreenViewLayer; private readonly middleScreenViewLayer = new Node(); private readonly frontScreenViewLayer; + protected readonly screenViewRootNode = new Node(); protected readonly intervalToolLayer = new Node(); @@ -89,6 +90,7 @@ private readonly updateMedianNode: () => void; private readonly updateDragIndicatorNode: () => void; protected readonly numberOfKicksProperty: DynamicProperty; + protected readonly kickButtonGroup: KickButtonGroup; public constructor( model: CAVModel, providedOptions: CAVScreenViewOptions ) { const options = optionize()( {}, providedOptions ); @@ -163,6 +165,10 @@ backLayerToggleNode ] } ); + this.backScreenViewLayer.pdomOrder = [ + backLayerToggleNode, + this.intervalToolLayer + ]; this.resetAllButton = new ResetAllButton( { listener: () => { @@ -202,7 +208,7 @@ } }, options.questionBarOptions ) ); - const kickButtonGroup = new KickButtonGroup( model, { + this.kickButtonGroup = new KickButtonGroup( model, { // Center under where the soccer player nodes will be. Since the SoccerPlayerNode are positioned in the // SceneView, we can't use those node bounds to position the kick buttons, so this is a manually tuned magic number. @@ -265,7 +271,7 @@ this.eraseButton, this.resetAllButton, this.questionBar, - kickButtonGroup, + this.kickButtonGroup, playAreaMedianIndicatorNode ] } ); @@ -298,9 +304,13 @@ this.middleScreenViewLayer.addChild( dragIndicatorArrowNode ); - this.addChild( this.backScreenViewLayer ); - this.addChild( this.middleScreenViewLayer ); - this.addChild( this.frontScreenViewLayer ); + // TODO: check for other this.addChild calls that should move into screenViewRootNode + // Add to screenViewRootNode for alternativeInput + this.screenViewRootNode.addChild( this.backScreenViewLayer ); + this.screenViewRootNode.addChild( this.middleScreenViewLayer ); + this.screenViewRootNode.addChild( this.frontScreenViewLayer ); + + this.addChild( this.screenViewRootNode ); } // calculate where the top object is at a given value @@ -341,7 +351,9 @@ this.accordionBox.boundsProperty.link( this.updateDragIndicatorNode ); } - protected setBottomControls( controlNode: Node, tandem: Tandem ): void { + protected setBottomControls( controlNode: Node, tandem: Tandem ): AlignBox { + + // TODO: only call once? or rename to addBottomControls // In order to use the AlignBox we need to know the distance from the top of the screen, to the top of the grass. const BOTTOM_CHECKBOX_PANEL_LEFT_MARGIN = 30; @@ -365,12 +377,16 @@ const checkboxBounds = this.layoutBounds.withMinX( this.layoutBounds.minX + CAVConstants.NUMBER_LINE_MARGIN_X + CAVConstants.CHART_VIEW_WIDTH + BOTTOM_CHECKBOX_PANEL_LEFT_MARGIN ); - this.addChild( new AlignBox( controlsVBox, { + const bottomControls = new AlignBox( controlsVBox, { alignBounds: checkboxBounds, xAlign: 'left', yAlign: 'bottom', yMargin: BOTTOM_CHECKBOX_PANEL_Y_MARGIN - } ) ); + } ); + + this.screenViewRootNode.addChild( bottomControls ); + + return bottomControls; } public getSoccerPlayerImageSet( soccerPlayer: SoccerPlayer, sceneModel: SoccerSceneModel ): SoccerPlayerImageSet { Index: js/median/view/CardNodeContainer.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/CardNodeContainer.ts b/js/median/view/CardNodeContainer.ts --- a/js/median/view/CardNodeContainer.ts (revision 0f6ae0a101c2c51765ac8aa5300bb28bf5e51148) +++ b/js/median/view/CardNodeContainer.ts (date 1689687677385) @@ -38,6 +38,7 @@ import CardContainerModel from '../model/CardContainerModel.js'; import CardModel from '../model/CardModel.js'; import CAVSoccerSceneModel from '../../common/model/CAVSoccerSceneModel.js'; +import DynamicProperty from '../../../../axon/js/DynamicProperty.js'; const successSoundClip = new SoundClip( cvSuccessOptions002_mp3, { initialOutputLevel: 0.2 @@ -47,7 +48,7 @@ export type CardNodeContainerOptions = EmptySelfOptions & WithRequired; export default class CardNodeContainer extends Node { - private readonly model: CardContainerModel; + public readonly model: CardContainerModel; public readonly cardNodes: CardNode[]; private readonly cardMap = new Map(); private readonly medianBarNode = new MedianBarNode( { @@ -77,8 +78,29 @@ // Allocate all the cards at start-up. Each card node must be associated with a card model. this.cardNodes = model.cards.map( ( cardModel, index ) => { + // The drag listener requires a numeric value (does not support null), so map it through a DynamicProperty + + // TODO: Should cellPosition be renamed to cellIndex? + const dynamicProperty = new DynamicProperty( new Property( cardModel.cellPositionProperty ), { + bidirectional: true, + + // TODO: I feel we have used this pattern elsewhere in the sim, should we see if something could be factored out? + map: function( value: number | null ) { return value === null ? 0 : value;}, + inverseMap: function( value: number ) { return value === 0 ? null : value; } + } ); + + dynamicProperty.debug( 'test' ); + const cardNode = new CardNode( this, cardModel, { - tandem: options.tandem.createTandem( 'cardNodes' ).createTandem1Indexed( 'cardNode', index ) + tandem: options.tandem.createTandem( 'cardNodes' ).createTandem1Indexed( 'cardNode', index ), + + // TODO: If there are only 4 cards, don't allow the card to go to 15. So adjust + // this range based on the total number of cards + enabledRangeProperty: new Property( CAVConstants.PHYSICAL_RANGE ), + valueProperty: dynamicProperty, + startDrag: () => { + + } } ); this.cardMap.set( cardNode.cardModel, cardNode ); Index: js/median/view/MedianScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/MedianScreenView.ts b/js/median/view/MedianScreenView.ts --- a/js/median/view/MedianScreenView.ts (revision 0f6ae0a101c2c51765ac8aa5300bb28bf5e51148) +++ b/js/median/view/MedianScreenView.ts (date 1689686172934) @@ -49,7 +49,7 @@ const iconGroup = new AlignGroup(); - this.setBottomControls( new VerticalCheckboxGroup( [ + const bottomControls = this.setBottomControls( new VerticalCheckboxGroup( [ PlayAreaCheckboxFactory.getPredictMedianCheckboxItem( iconGroup, model ), PlayAreaCheckboxFactory.getMedianCheckboxItem( iconGroup, model ) ], { @@ -74,6 +74,16 @@ infoDialog.hide(); } } ); + + this.screenViewRootNode.pdomOrder = [ + this.kickButtonGroup, + this.backScreenViewLayer, + bottomControls, + this.accordionBox, + infoDialog, + this.eraseButton, + this.resetAllButton + ]; } } ```
samreid commented 1 year ago

I am concerned that we should check in with @catherinecarter and probably @emily-phet about the intended design here before going much further. When manipulating the cards with the keyboard, should there be animation? If so, should the focused card animate, or just the displaced cards when it moves? Should the moved card be "offset" like when dragging? Should the user have to press spacebar to pick up a card? Should there be any continuous motion, or should it all be discrete (pressing arrow moves a card a full cell)? Should the behavior be any different with shift or pageup/down?

samreid commented 1 year ago

Attaching to related issue https://github.com/phetsims/scenery/issues/1238

samreid commented 1 year ago

I mentioned in slack to @jessegreenberg:

It would be nice to develop drag listeners for mouse/touch have them naturally (or with a minimal adapter) work for keyboard.

I'm not sure if we have anything like that at the moment, or if we should investigate.

samreid commented 1 year ago

@emily-phet said:

Hmm, I had thought that Taliesin had been consulted earlier in the design process re some of the more interesting interactions in this sim. Perhaps that did not happen? Looking into it, the interaction with the cards, and possibly the soccer balls, should probably follow this pattern: https://salesforce-ux.github.io/dnd-a11y-patterns/#/sortB?_k=pdhjs7. That would require some changes. For the case of the cards: tabbing to them would tab to the cards as a whole group. The whole group would then get a thick pink outline, with initial focus (think outline) on the first card. Arrow keys navigate to different cards. When the card group is tabbed to, a visual should appear indicating the user can press spacebar to grab a card (we have examples of this kind of thing in BASE, Faraday’s Law, Friction, and probably others. When spacebar is pressed, the selected cards is animated as raised up, and then as arrow key is pressed this card animates to new position. Spacebar drops it in place (you’d hear associated sounds as well). Keyboard help dialog would need to have a section on this.

Note - listbox has been used within PhET sims, but only as part of combobox, I believe. Not on its own.

Reasons to take this approach over the current “tab to each one and arrow key moves left/right”:

Taliesin will be back from vacation tomorrow, I think, and it would be good to check that she agrees with my interpretation of things.

samreid commented 1 year ago

@catherinecarter said:

Thanks, Emily! Your response will help frame the thinking on this one. Yes, I met with Taliesin a few months ago (https://github.com/phetsims/center-and-variability/issues/162) but I don’t think we discussed the details of the cards, just that they needed to be included. Our conversation focused mainly on the soccer balls, so I’ll reach out to her tomorrow and pick the conversation up again to include a more detailed discussion of the cards. @samreid, perhaps we can include @terracoda in our meeting, I think her input would be valuable. Or, I can meet with Taliesin and report back, whichever is a better use of your time.

samreid commented 1 year ago

Meeting scheduled for Friday, I'll unassign and put this issue on-hold until then.

terracoda commented 1 year ago

Looking forward to connecting and discussing these interactions more deeply.

samreid commented 1 year ago

See also https://github.com/phetsims/geometric-optics/issues/258 for discussion about whether to use a "grab" 2-step interaction to pick up an object. We noticed many sims have a 2-step spacebar grab, but geometric optics does not.

emily-phet commented 1 year ago

@samreid re 2-step interaction - yes, the optimal in the vast majority of cases for custom objects is a 2-step interaction. Note - we have a specific way of defining a "custom interaction", which might be helpful to talk through with all the devs involved, if you're not familiar. In sims where a more comprehensive inclusive feature set is not on the horizon, and at the preference of the design teams, a different approach has been taken at times.

Regardless - the cases of this that I'm aware of are not a "sorting" variation of a grab-and-drag interactions, which is the case for the cards in CaV and possibly the soccer balls. So sorting has additional considerations.

samreid commented 1 year ago

My meeting notes from today, please correct or elaborate:

https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-rearrangeable/

Tab to the group Use arrow keys to select an item in group Spacebar/enter to select an item. Arrow keys to move the item. Spacebar/enter drops the item and the group gets focus again Or escape key unselects it.

For the soccer balls, maybe show a dotted line vs solid line when grabbed? Focus single balls within the group instead of a column of soccer balls.

Unanswered questions: Do we need card animation when a card moves?

CC adding:

marlitas commented 1 year ago

I think setting up a time with @jessegreenberg to talk through how we would implement this would be incredibly helpful. Will confirm we are moving forward with this implementation before setting up that meeting.

marlitas commented 1 year ago

Assigning @catherinecarter to confirm interactions and labeling as high priority as we need to move on this sooner rather than later.

catherinecarter commented 1 year ago

Decisions:

Still to do:

jessegreenberg commented 1 year ago

Here is a patch with a KeyboardListener and some thoughts to get us started:

```diff Subject: [PATCH] Initial commit for code creation --- Index: js/soccer-common/view/SoccerSceneView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/soccer-common/view/SoccerSceneView.ts b/js/soccer-common/view/SoccerSceneView.ts --- a/js/soccer-common/view/SoccerSceneView.ts (revision 75ed300dfa09856b168fdef18edfa9163e82f04c) +++ b/js/soccer-common/view/SoccerSceneView.ts (date 1690232846275) @@ -59,8 +59,7 @@ modelViewTransform, soccerBallsInputEnabledProperty, { tandem: options.tandem.createTandem( 'soccerBallNodes' ).createTandem1Indexed( 'soccerBallNode', index ), - pickable: false, - enabledRangeProperty: new Property( physicalRange ) + pickable: false } ); backLayerSoccerBallLayer.addChild( soccerBallNode ); Index: js/soccer-common/view/SoccerBallNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/soccer-common/view/SoccerBallNode.ts b/js/soccer-common/view/SoccerBallNode.ts --- a/js/soccer-common/view/SoccerBallNode.ts (revision 75ed300dfa09856b168fdef18edfa9163e82f04c) +++ b/js/soccer-common/view/SoccerBallNode.ts (date 1690232755767) @@ -13,7 +13,7 @@ import SoccerBall from '../model/SoccerBall.js'; import ModelViewTransform2 from '../../../../phetcommon/js/view/ModelViewTransform2.js'; import TProperty from '../../../../axon/js/TProperty.js'; -import { DragListener, Image, Node } from '../../../../scenery/js/imports.js'; +import { DragListener, FocusHighlightFromNode, Image, KeyboardListener, Node } from '../../../../scenery/js/imports.js'; import Bounds2 from '../../../../dot/js/Bounds2.js'; import BooleanProperty from '../../../../axon/js/BooleanProperty.js'; import Multilink from '../../../../axon/js/Multilink.js'; @@ -21,22 +21,19 @@ import ballDark_png from '../../../images/ballDark_png.js'; import ball_png from '../../../images/ball_png.js'; import Vector2 from '../../../../dot/js/Vector2.js'; -import AccessibleSlider, { AccessibleSliderOptions } from '../../../../sun/js/accessibility/AccessibleSlider.js'; +import Range from '../../../../dot/js/Range.js'; import Property from '../../../../axon/js/Property.js'; import optionize, { EmptySelfOptions } from '../../../../phet-core/js/optionize.js'; -import DynamicProperty from '../../../../axon/js/DynamicProperty.js'; import { Shape } from '../../../../kite/js/imports.js'; import DerivedProperty from '../../../../axon/js/DerivedProperty.js'; -import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; -import StrictOmit from '../../../../phet-core/js/types/StrictOmit.js'; import SoccerCommonConstants from '../SoccerCommonConstants.js'; type SelfOptions = EmptySelfOptions; -type ParentOptions = CAVObjectNodeOptions & AccessibleSliderOptions; +type ParentOptions = CAVObjectNodeOptions; -type SoccerBallNodeOptions = SelfOptions & StrictOmit & PickRequired; +type SoccerBallNodeOptions = SelfOptions & ParentOptions; -export default class SoccerBallNode extends AccessibleSlider( SoccerObjectNode, 3 ) { +export default class SoccerBallNode extends SoccerObjectNode { public constructor( soccerBall: SoccerBall, modelViewTransform: ModelViewTransform2, @@ -48,34 +45,17 @@ const enabledProperty = new Property( true ); - // The drag listener requires a numeric value (does not support null), so map it through a DynamicProperty - const dynamicProperty = new DynamicProperty( new Property( soccerBall.valueProperty ), { - bidirectional: true, - map: function( value: number | null ) { return value === null ? 0 : value;}, - inverseMap: function( value: number ) { return value === 0 ? null : value; } - } ); - - let isSliderDragging = false; - const options = optionize()( { cursor: 'pointer', - valueProperty: dynamicProperty, - keyboardStep: 1, - shiftKeyboardStep: 1, - pageKeyboardStep: 5, - roundToStepSize: true, enabledProperty: enabledProperty, + // pdom + tagName: 'div', + focusable: true, + // Data point should be visible if the soccer ball landed visibleProperty: new DerivedProperty( [ soccerBall.soccerBallPhaseProperty ], phase => - phase !== SoccerBallPhase.INACTIVE ), - - startDrag: () => { - isSliderDragging = true; - }, - endDrag: () => { - isSliderDragging = false; - } + phase !== SoccerBallPhase.INACTIVE ) }, providedOptions ); super( soccerBall, modelViewTransform, SoccerCommonConstants.SOCCER_BALL_RADIUS, options ); @@ -96,7 +76,7 @@ } ); // Play sound only when dragging - let isDragging = false; + const isDraggingProperty = new BooleanProperty( false ); // only setup input-related things if dragging is enabled const dragListener = new DragListener( { @@ -104,7 +84,7 @@ positionProperty: soccerBall.dragPositionProperty, transform: modelViewTransform, start: () => { - isDragging = true; + isDraggingProperty.value = true; // if the user presses an object that's animating, allow it to keep animating up in the stack soccerBall.dragStartedEmitter.emit(); @@ -115,17 +95,75 @@ }, end: () => { - isDragging = false; + isDraggingProperty.value = false; } + } ); + + const range = new Range( 1, 15 ); + + // TODO: Remember to dispose this keyboardListener if that is necessary. + // TODO: Logic for this listener will be complex. Create a separate file/class for it? + // TODO: Logic for this might be really similar to the cards (or for any list sorting type interaction). Consider abstracting. + const keyboardListener = new KeyboardListener( { + keys: [ 'enter', 'space', 'escape', 'arrowLeft', 'arrowRight', 'home', 'end', '1', '2', '3', '4', '5' ], + callback: ( event, listener ) => { + const keysPressed = listener.keysPressed; + + if ( keysPressed === 'enter' || keysPressed === 'space' ) { + isDraggingProperty.value = !isDraggingProperty.value; + } + else if ( keysPressed === 'escape' ) { + isDraggingProperty.value = false; + } + + if ( isDraggingProperty.value ) { + if ( keysPressed === 'arrowLeft' || keysPressed === 'arrowRight' ) { + const delta = listener.keysPressed === 'arrowLeft' ? -1 : 1; + soccerBall.valueProperty.value = range.constrainValue( soccerBall.valueProperty.value! + delta ); + } + else if ( keysPressed === '1' || keysPressed === '2' || keysPressed === '3' || keysPressed === '4' || keysPressed === '5' ) { + + // TODO: Make more readable, more values, and also handle modifier keys? parseInt is quick but consider something else. + soccerBall.valueProperty.value = parseInt( keysPressed, 10 ); + } + else if ( keysPressed === 'home' ) { + soccerBall.valueProperty.value = range.min; + } + else if ( keysPressed === 'end' ) { + soccerBall.valueProperty.value = range.max; + } + } + else { + if ( keysPressed === 'arrowLeft' || keysPressed === 'arrowRight' ) { + + // Move focus to the next/previous soccer ball. This ball then needs to know of the others in play area + // which isn't great. It might be better to break this up. One listener for moving this ball, another + // listener on a parent container that can manage focus for all balls. + + // TODO: We also may need a parent container Node to support a group highlight. + } + } + }, + + // TODO: You can probably use down too, whatever you prefer. + listenerFireTrigger: 'up' } ); + this.addInputListener( keyboardListener ); // When the user drags a soccer ball, play audio corresponding to its new position. soccerBall.valueProperty.link( value => { - if ( value !== null && ( isDragging || isSliderDragging ) ) { + if ( value !== null && ( isDraggingProperty.value ) ) { soccerBall.toneEmitter.emit( value ); } } ); + const focusHighlight = new FocusHighlightFromNode( this ); + this.focusHighlight = focusHighlight; + + isDraggingProperty.link( isDragged => { + focusHighlight.makeDashed( isDragged ); + } ); + // pan and zoom - In order to move the CAVObjectNode to a new position the pointer has to move more than half the // unit model length. When the CAVObjectNode is near the edge of the screen while zoomed in, the pointer doesn't // have enough space to move that far. If we make sure that bounds surrounding the CAVObjectNode have a width ```
terracoda commented 1 year ago

We didn’t discuss the exact housing structure for the stacks of soccer balls at the meeting.

I think it makes sense to have them be a list structure with each stack as a list item with a “grab top ball” button.

The list structure (if it works) will provide an automatic group summary of the number of items (stacks) and possibly provide automatic updated counts of items (stacks) as a ball is moved to new places on the field.

This an educated guess and might make the structure of the soccer ball data more connected to the structure of the cards which definitely be a sortable list.

terracoda commented 1 year ago

What I am unsure about is whether each meter mark is a list item and the ones with no balls are empty items.

jessegreenberg commented 1 year ago

Here is a patch from some progress today:

```diff Subject: [PATCH] Initial commit for code creation --- Index: js/soccer-common/view/SoccerSceneView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/soccer-common/view/SoccerSceneView.ts b/js/soccer-common/view/SoccerSceneView.ts --- a/js/soccer-common/view/SoccerSceneView.ts (revision fad539be37dba52930d43a11ad7c2378387b30d0) +++ b/js/soccer-common/view/SoccerSceneView.ts (date 1690311336886) @@ -8,7 +8,7 @@ */ -import { Node } from '../../../../scenery/js/imports.js'; +import { KeyboardListener, Node } from '../../../../scenery/js/imports.js'; import SoccerBallNode from './SoccerBallNode.js'; import { SoccerBallPhase } from '../model/SoccerBallPhase.js'; import SoccerSceneModel from '../model/SoccerSceneModel.js'; @@ -23,6 +23,8 @@ import Property from '../../../../axon/js/Property.js'; import Range from '../../../../dot/js/Range.js'; import DragIndicatorModel from '../model/DragIndicatorModel.js'; +import NumberProperty from '../../../../axon/js/NumberProperty.js'; +import Utils from '../../../../dot/js/Utils.js'; /** * Renders view elements for a CAVSceneModel. Note that to satisfy the correct z-ordering, elements @@ -45,7 +47,9 @@ const soccerBallMap = new Map(); // Keep soccer balls in one layer so we can control the focus order - const backLayerSoccerBallLayer = new Node(); + const backLayerSoccerBallLayer = new Node( { + groupFocusHighlight: true + } ); const backLayer = new Node( { children: [ backLayerSoccerBallLayer ] } ); @@ -59,8 +63,7 @@ modelViewTransform, soccerBallsInputEnabledProperty, { tandem: options.tandem.createTandem( 'soccerBallNodes' ).createTandem1Indexed( 'soccerBallNode', index ), - pickable: false, - enabledRangeProperty: new Property( physicalRange ) + pickable: false } ); backLayerSoccerBallLayer.addChild( soccerBallNode ); @@ -130,7 +133,7 @@ const soccerBallNode = soccerBallMap.get( stack[ i ] )!; // Only the top ball in each stack is focusable for keyboard navigation - soccerBallNode.focusable = i === stack.length - 1; + // soccerBallNode.focusable = i === stack.length - 1; // Focus order goes left to right backLayerSoccerBallLayer.setPDOMOrder( sceneModel.getTopSoccerBalls().map( soccerBall => soccerBallMap.get( soccerBall )! ) ); @@ -145,6 +148,43 @@ soccerPlayerNodes.forEach( soccerPlayerNode => frontLayer.addChild( soccerPlayerNode ) ); + // The index of the top soccer ball Nodes that is focusable. + const focusedBallIndexProperty = new NumberProperty( 0 ); + + const keyboardListener = new KeyboardListener( { + keys: [ 'arrowRight', 'arrowLeft' ], + callback: ( event, listener ) => { + + // Use the arrow keys to move DOM focus between the soccer balls. + // Setting focusable on individual balls and then calling focus() on the right ball. + // get all the balls + // get the index of the ball that has focus + // get an array of the 'top' balls + + const topBallNodes = sceneModel.getTopSoccerBalls().map( soccerBall => soccerBallMap.get( soccerBall )! ); + + const delta = listener.keysPressed === 'arrowRight' ? 1 : -1; + const numberOfTopSoccerBalls = sceneModel.getTopSoccerBalls().length; + + const focusedBallNode = topBallNodes[ focusedBallIndexProperty.value ]; + focusedBallNode.focusable = false; + + // We are deciding not to wrap the value around the ends of the range because the grabbed soccer ball + // also does not wrap. + focusedBallIndexProperty.value = Utils.clamp( focusedBallIndexProperty.value + delta, 0, numberOfTopSoccerBalls - 1 ); + + const newFocusedBallNode = topBallNodes[ focusedBallIndexProperty.value ]; + newFocusedBallNode.focusable = true; + newFocusedBallNode.focus(); + + + // Keep focus on a focusable backLayerSoccerBallLayer, and move a focus highlight rectangle to indicate + // which ball will be picked up. + // Setting focusable on all balls at once (until grabbed), and then updating a focus highlight + } + } ); + backLayerSoccerBallLayer.addInputListener( keyboardListener ); + this.backSceneViewLayer = backLayer; this.frontSceneViewLayer = frontLayer; } Index: js/soccer-common/view/SoccerBallNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/soccer-common/view/SoccerBallNode.ts b/js/soccer-common/view/SoccerBallNode.ts --- a/js/soccer-common/view/SoccerBallNode.ts (revision fad539be37dba52930d43a11ad7c2378387b30d0) +++ b/js/soccer-common/view/SoccerBallNode.ts (date 1690311529054) @@ -13,7 +13,7 @@ import SoccerBall from '../model/SoccerBall.js'; import ModelViewTransform2 from '../../../../phetcommon/js/view/ModelViewTransform2.js'; import TProperty from '../../../../axon/js/TProperty.js'; -import { DragListener, Image, Node } from '../../../../scenery/js/imports.js'; +import { DragListener, FocusHighlightFromNode, Image, KeyboardListener, Node } from '../../../../scenery/js/imports.js'; import Bounds2 from '../../../../dot/js/Bounds2.js'; import BooleanProperty from '../../../../axon/js/BooleanProperty.js'; import Multilink from '../../../../axon/js/Multilink.js'; @@ -21,22 +21,19 @@ import ballDark_png from '../../../images/ballDark_png.js'; import ball_png from '../../../images/ball_png.js'; import Vector2 from '../../../../dot/js/Vector2.js'; -import AccessibleSlider, { AccessibleSliderOptions } from '../../../../sun/js/accessibility/AccessibleSlider.js'; +import Range from '../../../../dot/js/Range.js'; import Property from '../../../../axon/js/Property.js'; import optionize, { EmptySelfOptions } from '../../../../phet-core/js/optionize.js'; -import DynamicProperty from '../../../../axon/js/DynamicProperty.js'; import { Shape } from '../../../../kite/js/imports.js'; import DerivedProperty from '../../../../axon/js/DerivedProperty.js'; -import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; -import StrictOmit from '../../../../phet-core/js/types/StrictOmit.js'; import SoccerCommonConstants from '../SoccerCommonConstants.js'; type SelfOptions = EmptySelfOptions; -type ParentOptions = CAVObjectNodeOptions & AccessibleSliderOptions; +type ParentOptions = CAVObjectNodeOptions; -type SoccerBallNodeOptions = SelfOptions & StrictOmit & PickRequired; +type SoccerBallNodeOptions = SelfOptions & ParentOptions; -export default class SoccerBallNode extends AccessibleSlider( SoccerObjectNode, 3 ) { +export default class SoccerBallNode extends SoccerObjectNode { public constructor( soccerBall: SoccerBall, modelViewTransform: ModelViewTransform2, @@ -48,34 +45,17 @@ const enabledProperty = new Property( true ); - // The drag listener requires a numeric value (does not support null), so map it through a DynamicProperty - const dynamicProperty = new DynamicProperty( new Property( soccerBall.valueProperty ), { - bidirectional: true, - map: function( value: number | null ) { return value === null ? 0 : value;}, - inverseMap: function( value: number ) { return value === 0 ? null : value; } - } ); - - let isSliderDragging = false; - const options = optionize()( { cursor: 'pointer', - valueProperty: dynamicProperty, - keyboardStep: 1, - shiftKeyboardStep: 1, - pageKeyboardStep: 5, - roundToStepSize: true, enabledProperty: enabledProperty, + // pdom + tagName: 'div', + focusable: true, + // Data point should be visible if the soccer ball landed visibleProperty: new DerivedProperty( [ soccerBall.soccerBallPhaseProperty ], phase => - phase !== SoccerBallPhase.INACTIVE ), - - startDrag: () => { - isSliderDragging = true; - }, - endDrag: () => { - isSliderDragging = false; - } + phase !== SoccerBallPhase.INACTIVE ) }, providedOptions ); super( soccerBall, modelViewTransform, SoccerCommonConstants.SOCCER_BALL_RADIUS, options ); @@ -96,7 +76,7 @@ } ); // Play sound only when dragging - let isDragging = false; + const isDraggingProperty = new BooleanProperty( false ); // only setup input-related things if dragging is enabled const dragListener = new DragListener( { @@ -104,7 +84,7 @@ positionProperty: soccerBall.dragPositionProperty, transform: modelViewTransform, start: () => { - isDragging = true; + isDraggingProperty.value = true; // if the user presses an object that's animating, allow it to keep animating up in the stack soccerBall.dragStartedEmitter.emit(); @@ -115,17 +95,76 @@ }, end: () => { - isDragging = false; + isDraggingProperty.value = false; } + } ); + + const range = new Range( 1, 15 ); + + // TODO: Remember to dispose this keyboardListener if that is necessary. + // TODO: Logic for this listener will be complex. Create a separate file/class for it? + // TODO: Logic for this might be really similar to the cards (or for any list sorting type interaction). Consider abstracting. + + const keyboardListener = new KeyboardListener( { + keys: [ 'enter', 'space', 'escape', 'arrowLeft', 'arrowRight', 'home', 'end', '1', '2', '3', '4', '5' ], + callback: ( event, listener ) => { + const keysPressed = listener.keysPressed; + + if ( keysPressed === 'enter' || keysPressed === 'space' ) { + isDraggingProperty.value = !isDraggingProperty.value; + } + else if ( keysPressed === 'escape' ) { + isDraggingProperty.value = false; + } + + if ( isDraggingProperty.value ) { + if ( keysPressed === 'arrowLeft' || keysPressed === 'arrowRight' ) { + const delta = listener.keysPressed === 'arrowLeft' ? -1 : 1; + soccerBall.valueProperty.value = range.constrainValue( soccerBall.valueProperty.value! + delta ); + } + else if ( keysPressed === '1' || keysPressed === '2' || keysPressed === '3' || keysPressed === '4' || keysPressed === '5' ) { + + // TODO: Make more readable, more values, and also handle modifier keys? parseInt is quick but consider something else. + soccerBall.valueProperty.value = parseInt( keysPressed, 10 ); + } + else if ( keysPressed === 'home' ) { + soccerBall.valueProperty.value = range.min; + } + else if ( keysPressed === 'end' ) { + soccerBall.valueProperty.value = range.max; + } + } + else { + if ( keysPressed === 'arrowLeft' || keysPressed === 'arrowRight' ) { + + // Move focus to the next/previous soccer ball. This ball then needs to know of the others in play area + // which isn't great. It might be better to break this up. One listener for moving this ball, another + // listener on a parent container that can manage focus for all balls. + + // TODO: We also may need a parent container Node to support a group highlight. + } + } + }, + + // TODO: You can probably use down too, whatever you prefer. + listenerFireTrigger: 'up' } ); + this.addInputListener( keyboardListener ); // When the user drags a soccer ball, play audio corresponding to its new position. soccerBall.valueProperty.link( value => { - if ( value !== null && ( isDragging || isSliderDragging ) ) { + if ( value !== null && ( isDraggingProperty.value ) ) { soccerBall.toneEmitter.emit( value ); } } ); + const focusHighlight = new FocusHighlightFromNode( this ); + this.focusHighlight = focusHighlight; + + isDraggingProperty.link( isDragged => { + focusHighlight.makeDashed( isDragged ); + } ); + // pan and zoom - In order to move the CAVObjectNode to a new position the pointer has to move more than half the // unit model length. When the CAVObjectNode is near the edge of the screen while zoomed in, the pointer doesn't // have enough space to move that far. If we make sure that bounds surrounding the CAVObjectNode have a width @@ -178,7 +217,7 @@ this.addLinkedElement( soccerBall ); // Not focusable until the ball has been kicked into the play area - this.focusable = false; + // this.focusable = false; super.addDebugText( soccerBall ); } ```
samreid commented 1 year ago

I added a rough draft commit for CardNodeContainer, with numerous TODOs for next steps.

samreid commented 1 year ago

We confirmed the design considerations with @catherinecarter previously. We also checked in with @matthew-blackman and caught some other bugs (like the need to uncheck the sort data checkbox). All work here was done in collaboration, and seems in good shape. Closing.