diff --git a/js/ComboBox.js b/js/ComboBox.js index 56cc59f9..cb9ba834 100644 --- a/js/ComboBox.js +++ b/js/ComboBox.js @@ -14,6 +14,7 @@ define( require => { // modules const AccessiblePeer = require( 'SCENERY/accessibility/AccessiblePeer' ); + const ComboBoxButtonNode = require( 'SUN/ComboBoxButtonNode' ); const ComboBoxIO = require( 'SUN/ComboBoxIO' ); const ComboBoxItem = require( 'SUN/ComboBoxItem' ); const ComboBoxItemNode = require( 'SUN/ComboBoxItemNode' ); @@ -22,12 +23,9 @@ define( require => { const inherit = require( 'PHET_CORE/inherit' ); const InstanceRegistry = require( 'PHET_CORE/documentation/InstanceRegistry' ); const KeyboardUtil = require( 'SCENERY/accessibility/KeyboardUtil' ); - const Line = require( 'SCENERY/nodes/Line' ); const Node = require( 'SCENERY/nodes/Node' ); - const Path = require( 'SCENERY/nodes/Path' ); const Property = require( 'AXON/Property' ); const Rectangle = require( 'SCENERY/nodes/Rectangle' ); - const Shape = require( 'KITE/Shape' ); const sun = require( 'SUN/sun' ); const Tandem = require( 'TANDEM/Tandem' ); const Vector2 = require( 'DOT/Vector2' ); @@ -36,24 +34,6 @@ define( require => { // const const LIST_POSITION_VALUES = [ 'above', 'below' ]; const ALIGN_VALUES = [ 'left', 'right', 'center' ]; - const BUTTON_NODE_DEFAULT_OPTIONS = { - - // used by ComboBox and ButtonNode - listPosition: 'below', // {string} where the list is positioned relative to the button, see LIST_POSITION_VALUES - align: 'left', // {string} alignment of items on the button and in the list, see ALIGN_VALUES - - // used exclusively by ButtonNode - buttonFill: 'white', - buttonStroke: 'black', - buttonLineWidth: 1, - buttonCornerRadius: 8, - buttonXMargin: 10, - buttonYMargin: 4, - - // a11y - used exclusively by ButtonNode - a11yButtonLabel: '' // {string} accessible label for the button that opens this combobox - }; - const BUTTON_NODE_KEYS = _.keys( BUTTON_NODE_DEFAULT_OPTIONS ); /** * @param {*[]} items - must be created using ComboBox.createItem @@ -90,7 +70,7 @@ define( require => { phetioType: ComboBoxIO, phetioEventType: 'user' - }, BUTTON_NODE_DEFAULT_OPTIONS, options ); + }, ComboBoxButtonNode.DEFAULT_OPTIONS, options ); // validate option values assert && assert( options.disabledOpacity > 0 && options.disabledOpacity < 1, @@ -296,7 +276,8 @@ define( require => { } ); // @private button, will be set to correct value when property observer is registered - this.buttonNode = new ButtonNode( this.getItemNode( property.value ), _.pick( options, BUTTON_NODE_KEYS ) ); + this.buttonNode = new ComboBoxButtonNode( this.getItemNode( property.value ), + _.pick( options, ComboBoxButtonNode.DEFAULT_KEYS ) ); this.addChild( this.buttonNode ); // a11y - the list is labeled by the button's label @@ -551,153 +532,5 @@ define( require => { return new ComboBoxItem( node, value, options ); }; - /** - * The button that shows the current selection. Clicking on it shows the list of items. - */ - class ButtonNode extends Node { - - /** - * @param {Node} itemNode - * @param {Object} [options] - */ - constructor( itemNode, options ) { - - options = _.extend( { - - // phet-io - tandem: Tandem.optional, // ButtonNode is not currently instrumented - - // a11y - tagName: 'button', - labelTagName: 'span', - containerTagName: 'div' - - }, BUTTON_NODE_DEFAULT_OPTIONS, options ); - - super(); - - // @private - this.buttonXMargin = options.buttonXMargin; - this.buttonYMargin = options.buttonYMargin; - - //TODO #314 missing visibility annotation - this.labelContent = options.a11yButtonLabel; - - //TODO #314 missing visibility annotation - // the button is labelledby its own label, and then (second) by itself. Order matters! - assert && assert( !options.ariaLabelledbyAssociations, 'ButtonNode sets ariaLabelledbyAssociations' ); - this.ariaLabelledbyAssociations = [ - { - otherNode: this, - otherElementName: AccessiblePeer.LABEL_SIBLING, - thisElementName: AccessiblePeer.PRIMARY_SIBLING - }, - { - otherNode: this, - otherElementName: AccessiblePeer.PRIMARY_SIBLING, - thisElementName: AccessiblePeer.PRIMARY_SIBLING - } - ]; - - // signify to AT that this button opens a menu - this.setAccessibleAttribute( 'aria-haspopup', 'listbox' ); - - //TODO #314 this should be private, and its value should be passed set via options or a setter methods - // @public - if assigned, it will be removed on disposal. - this.a11yListener = null; - - // up or down arrow - const arrow = new Path( null, { - fill: 'black' - } ); - const arrowWidth = 0.5 * itemNode.height; - const arrowHeight = arrowWidth * Math.sqrt( 3 ) / 2; // height of equilateral triangle - if ( options.listPosition === 'above' ) { - arrow.shape = new Shape().moveTo( 0, arrowHeight ).lineTo( arrowWidth / 2, 0 ).lineTo( arrowWidth, arrowHeight ).close(); // up arrow - } - else { - arrow.shape = new Shape().moveTo( 0, 0 ).lineTo( arrowWidth, 0 ).lineTo( arrowWidth / 2, arrowHeight ).close(); // down arrow - } - - // button background - const backgroundWidth = itemNode.width + ( 4 * this.buttonXMargin ) + arrow.width; - const backgroundHeight = itemNode.height + ( 2 * this.buttonYMargin ); - - // @private - this.background = new Rectangle( 0, 0, backgroundWidth, backgroundHeight, { - cornerRadius: options.buttonCornerRadius, - fill: options.buttonFill, - stroke: options.buttonStroke, - lineWidth: options.buttonLineWidth - } ); - - // vertical separator to left of arrow - const separator = new Line( 0, 0, 0, backgroundHeight, { - stroke: 'black', - lineWidth: options.buttonLineWidth - } ); - - // @private parent for the selected ComboBoxItemNode - this.itemNodeParent = new Node(); - - // rendering order - this.addChild( this.background ); - this.addChild( arrow ); - this.addChild( separator ); - this.addChild( this.itemNodeParent ); - - this.setItemNode( itemNode ); - - // layout - separator.left = this.itemNodeParent.right + this.buttonXMargin; - separator.top = this.background.top; - arrow.left = separator.right + this.buttonXMargin; - arrow.centerY = this.background.centerY; - - // @private - this.disposeButtonNode = () => { - this.a11yListener && this.removeInputListener( this.a11yListener ); - }; - - this.mutate( options ); - } - - /** - * Sets the item that is displayed on the button. - * @param {ComboBoxItemNode} itemNode - */ - setItemNode( itemNode ){ - - // Remove the previous itemNode - assert && assert( this.itemNodeParent.getChildrenCount() <= 1, - 'itemNodeParent should never have more than 1 child' ); - this.itemNodeParent.removeAllChildren(); - - // Add the new itemNode and adjust layout. Do not transform itemNode, because it's shared with the list. - this.itemNodeParent.addChild( itemNode ); - this.itemNodeParent.left = this.background.left + this.buttonXMargin; - this.itemNodeParent.top = this.background.top + this.buttonYMargin; - - // TODO sun#314 is there a better way to do this? - itemNode.a11yShowItem( false ); - - // Only set if defined, since it is an option, see ComboBox.createItem - if ( itemNode.a11yLabel ) { - this.innerContent = itemNode.a11yLabel; - } - } - - /** - * @public - * @override - */ - dispose() { - this.disposeButtonNode(); - super.dispose(); - } - } - - sun.register( 'ComboBox.ButtonNode', ButtonNode ); - return ComboBox; } ); \ No newline at end of file diff --git a/js/ComboBoxButtonNode.js b/js/ComboBoxButtonNode.js new file mode 100644 index 00000000..f972da9e --- /dev/null +++ b/js/ComboBoxButtonNode.js @@ -0,0 +1,190 @@ +// Copyright 2019, University of Colorado Boulder + +/** + * The button on a combo box box. This was originally an inner class of ComboBox (ComboBox.ButtonNode), + * but was promoted to its own source file when it grew more complex due to PhET-iO and a11y instrumentation. + * + * @author Chris Malley (PixelZoom, Inc.) + */ +define( require => { + 'use strict'; + + // modules + const AccessiblePeer = require( 'SCENERY/accessibility/AccessiblePeer' ); + const Line = require( 'SCENERY/nodes/Line' ); + const Node = require( 'SCENERY/nodes/Node' ); + const Path = require( 'SCENERY/nodes/Path' ); + const Rectangle = require( 'SCENERY/nodes/Rectangle' ); + const Shape = require( 'KITE/Shape' ); + const sun = require( 'SUN/sun' ); + const Tandem = require( 'TANDEM/Tandem' ); + + // constants + const DEFAULT_OPTIONS = { + + // used by ComboBox and ButtonNode + listPosition: 'below', // {string} where the list is positioned relative to the button, see LIST_POSITION_VALUES + align: 'left', // {string} alignment of items on the button and in the list, see ALIGN_VALUES + + // used exclusively by ButtonNode + buttonFill: 'white', + buttonStroke: 'black', + buttonLineWidth: 1, + buttonCornerRadius: 8, + buttonXMargin: 10, + buttonYMargin: 4, + + // a11y - used exclusively by ButtonNode + a11yButtonLabel: '' // {string} accessible label for the button that opens this combobox + }; + const DEFAULT_KEYS = _.keys( DEFAULT_OPTIONS ); + + class ComboBoxButtonNode extends Node { + + /** + * @param {Node} itemNode + * @param {Object} [options] + */ + constructor( itemNode, options ) { + + options = _.extend( {}, DEFAULT_OPTIONS, { + + // phet-io + tandem: Tandem.optional, // ComboBoxButtonNode is not currently instrumented + + // a11y + tagName: 'button', + labelTagName: 'span', + containerTagName: 'div' + + }, options ); + + super(); + + // @private + this.buttonXMargin = options.buttonXMargin; + this.buttonYMargin = options.buttonYMargin; + + //TODO #314 missing visibility annotation + this.labelContent = options.a11yButtonLabel; + + //TODO #314 missing visibility annotation + // the button is labelledby its own label, and then (second) by itself. Order matters! + assert && assert( !options.ariaLabelledbyAssociations, 'ComboBoxButtonNode sets ariaLabelledbyAssociations' ); + this.ariaLabelledbyAssociations = [ + { + otherNode: this, + otherElementName: AccessiblePeer.LABEL_SIBLING, + thisElementName: AccessiblePeer.PRIMARY_SIBLING + }, + { + otherNode: this, + otherElementName: AccessiblePeer.PRIMARY_SIBLING, + thisElementName: AccessiblePeer.PRIMARY_SIBLING + } + ]; + + // signify to AT that this button opens a menu + this.setAccessibleAttribute( 'aria-haspopup', 'listbox' ); + + //TODO #314 this should be private, and its value should be passed set via options or a setter methods + // @public - if assigned, it will be removed on disposal. + this.a11yListener = null; + + // up or down arrow + const arrow = new Path( null, { + fill: 'black' + } ); + const arrowWidth = 0.5 * itemNode.height; + const arrowHeight = arrowWidth * Math.sqrt( 3 ) / 2; // height of equilateral triangle + if ( options.listPosition === 'above' ) { + arrow.shape = new Shape().moveTo( 0, arrowHeight ).lineTo( arrowWidth / 2, 0 ).lineTo( arrowWidth, arrowHeight ).close(); // up arrow + } + else { + arrow.shape = new Shape().moveTo( 0, 0 ).lineTo( arrowWidth, 0 ).lineTo( arrowWidth / 2, arrowHeight ).close(); // down arrow + } + + // button background + const backgroundWidth = itemNode.width + ( 4 * this.buttonXMargin ) + arrow.width; + const backgroundHeight = itemNode.height + ( 2 * this.buttonYMargin ); + + // @private + this.background = new Rectangle( 0, 0, backgroundWidth, backgroundHeight, { + cornerRadius: options.buttonCornerRadius, + fill: options.buttonFill, + stroke: options.buttonStroke, + lineWidth: options.buttonLineWidth + } ); + + // vertical separator to left of arrow + const separator = new Line( 0, 0, 0, backgroundHeight, { + stroke: 'black', + lineWidth: options.buttonLineWidth + } ); + + // @private parent for the selected ComboBoxItemNode + this.itemNodeParent = new Node(); + + // rendering order + this.addChild( this.background ); + this.addChild( arrow ); + this.addChild( separator ); + this.addChild( this.itemNodeParent ); + + this.setItemNode( itemNode ); + + // layout + separator.left = this.itemNodeParent.right + this.buttonXMargin; + separator.top = this.background.top; + arrow.left = separator.right + this.buttonXMargin; + arrow.centerY = this.background.centerY; + + // @private + this.disposeComboBoxButtonNode = () => { + this.a11yListener && this.removeInputListener( this.a11yListener ); + }; + + this.mutate( options ); + } + + /** + * Sets the item that is displayed on the button. + * @param {ComboBoxItemNode} itemNode + */ + setItemNode( itemNode ){ + + // Remove the previous itemNode + assert && assert( this.itemNodeParent.getChildrenCount() <= 1, + 'itemNodeParent should never have more than 1 child' ); + this.itemNodeParent.removeAllChildren(); + + // Add the new itemNode and adjust layout. Do not transform itemNode, because it's shared with the list. + this.itemNodeParent.addChild( itemNode ); + this.itemNodeParent.left = this.background.left + this.buttonXMargin; + this.itemNodeParent.top = this.background.top + this.buttonYMargin; + + // TODO sun#314 is there a better way to do this? + itemNode.a11yShowItem( false ); + + // Only set if defined, since it is an option, see ComboBox.createItem + if ( itemNode.a11yLabel ) { + this.innerContent = itemNode.a11yLabel; + } + } + + /** + * @public + * @override + */ + dispose() { + this.disposeComboBoxButtonNode(); + super.dispose(); + } + } + + //TODO sun#430 make these go away, use nested options.buttonOptions in ComboBox + ComboBoxButtonNode.DEFAULT_OPTIONS = DEFAULT_OPTIONS; + ComboBoxButtonNode.DEFAULT_KEYS = DEFAULT_KEYS; + + return sun.register( 'ComboBoxButtonNode', ComboBoxButtonNode ); +} ); \ No newline at end of file diff --git a/js/ComboBoxItemNode.js b/js/ComboBoxItemNode.js index c82e90b1..23b3cbe6 100644 --- a/js/ComboBoxItemNode.js +++ b/js/ComboBoxItemNode.js @@ -2,8 +2,8 @@ /** * Node for an item in a combo box. Typically instantiated by ComboBox, not by client code. This was originally - * an inner class of ComboBox, but was promoted to its own source file when it grew more complex due to - * PhET-iO and a11y instrumentation. + * an inner class of ComboBox (ComboBox.ItemNode), but was promoted to its own source file when it grew more + * complex due to PhET-iO and a11y instrumentation. * * @author Chris Malley (PixelZoom, Inc.) */