import _ from 'lodash';
import '../../chipper/js/browser/initialize-globals.js';
import '../../assert/js/assert.js';
import '../../globals.js';
// Copyright 2013-2025, University of Colorado Boulder
/**
 * Scenery-based combo box. Composed of a button and a popup 'list box' of items. ComboBox has no interaction of its
 * own, all interaction is handled by its subcomponents. The list box is displayed when the button is pressed, and
 * dismissed when an item is selected, the user clicks on the button, or the user clicks outside the list. The list
 * can be displayed either above or below the button.
 *
 * The supporting types and classes are:
 *
 * ComboBoxItem - items provided to ComboBox constructor
 * ComboBoxButton - the button
 * ComboBoxListBox - the list box
 * ComboBoxListItemNode - an item in the list box
 *
 * For info on ComboBox UI design, including a11y, see https://github.com/phetsims/sun/blob/main/doc/ComboBox.md
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */
import BooleanProperty from '../../axon/js/BooleanProperty.js';
import DerivedProperty from '../../axon/js/DerivedProperty.js';
import Multilink from '../../axon/js/Multilink.js';
import dotRandom from '../../dot/js/dotRandom.js';
import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js';
import optionize from '../../phet-core/js/optionize.js';
import { extendsWidthSizable, FocusManager, isWidthSizable, MatrixBetweenProperty, Node, PDOMPeer, PDOMUtils, WidthSizable } from '../../scenery/js/imports.js';
import sharedSoundPlayers from '../../tambo/js/sharedSoundPlayers.js';
import EventType from '../../tandem/js/EventType.js';
import Tandem from '../../tandem/js/Tandem.js';
import IOType from '../../tandem/js/types/IOType.js';
import ComboBoxButton from './ComboBoxButton.js';
import ComboBoxListBox from './ComboBoxListBox.js';
import { getGroupItemNodes } from './GroupItemOptions.js';
import sun from './sun.js';
import SunConstants from './SunConstants.js';
import SunUtil from './SunUtil.js';
// const
const LIST_POSITION_VALUES = ['above', 'below']; // where the list pops up relative to the button
const ALIGN_VALUES = ['left', 'right', 'center']; // alignment of item on button and in list
// Tandem names for ComboBoxItem must have this suffix.
const ITEM_TANDEM_NAME_SUFFIX = 'Item';
// The definition for how ComboBox sets its accessibleName and helpText in the PDOM. Forward it onto its button. See
// ComboBox.md for further style guide and documentation on the pattern.
const ACCESSIBLE_NAME_BEHAVIOR = (node, options, accessibleName, otherNodeCallbacks) => {
    otherNodeCallbacks.push(() => {
        node.button.accessibleName = accessibleName;
    });
    return options;
};
const HELP_TEXT_BEHAVIOR = (node, options, helpText, otherNodeCallbacks) => {
    otherNodeCallbacks.push(() => {
        node.button.helpText = helpText;
    });
    return options;
};
class ComboBox extends WidthSizable(Node) {
    /**
     * @param property - must be settable and linkable, but needs to support Property, DerivedProperty and DynamicProperty
     * @param items - items, in the order that they appear in the listbox
     * @param listParent node that will be used as the list's parent, use this to ensure that the list is in front of everything else
     * @param [providedOptions]
     */
    constructor(property, items, listParent, providedOptions) {
        assert && assert(_.uniqBy(items, (item) => item.value).length === items.length, 'items must have unique values');
        assert && items.forEach(item => {
            assert && assert(!item.tandemName || item.tandemName.endsWith(ITEM_TANDEM_NAME_SUFFIX), `ComboBoxItem tandemName must end with '${ITEM_TANDEM_NAME_SUFFIX}': ${item.tandemName}`);
        });
        // See https://github.com/phetsims/sun/issues/542
        assert && assert(listParent.maxWidth === null, 'ComboBox is responsible for scaling listBox. Setting maxWidth for listParent may result in buggy behavior.');
        const options = optionize()({
            align: 'left',
            listPosition: 'below',
            labelXSpacing: 10,
            disabledOpacity: 0.5,
            cornerRadius: 4,
            highlightFill: 'rgb( 245, 245, 245 )',
            xMargin: 12,
            yMargin: 8,
            // button
            buttonFill: 'white',
            buttonStroke: 'black',
            buttonLineWidth: 1,
            buttonTouchAreaXDilation: 0,
            buttonTouchAreaYDilation: 0,
            buttonMouseAreaXDilation: 0,
            buttonMouseAreaYDilation: 0,
            // list
            listFill: 'white',
            listStroke: 'black',
            listLineWidth: 1,
            openedSoundPlayer: sharedSoundPlayers.get('generalOpen'),
            closedNoChangeSoundPlayer: sharedSoundPlayers.get('generalClose'),
            // pdom
            tagName: 'div', // must have accessible content to support behavior functions
            buttonLabelTagName: 'p',
            accessibleNameBehavior: ACCESSIBLE_NAME_BEHAVIOR,
            helpTextBehavior: HELP_TEXT_BEHAVIOR,
            comboBoxVoicingNameResponsePattern: SunConstants.VALUE_NAMED_PLACEHOLDER,
            comboBoxVoicingContextResponse: null,
            comboBoxVoicingHintResponse: null,
            // phet-io
            tandem: Tandem.REQUIRED,
            tandemNameSuffix: 'ComboBox',
            phetioType: ComboBox.ComboBoxIO,
            phetioFeatured: true,
            phetioEventType: EventType.USER,
            visiblePropertyOptions: { phetioFeatured: true },
            phetioEnabledPropertyInstrumented: true // opt into default PhET-iO instrumented enabledProperty
        }, providedOptions);
        const nodes = getGroupItemNodes(items, options.tandem.createTandem('items'));
        // pdom - If an item has not been given an accessibleName, try to find a default value from the visual Node.
        // Assigned to the item, because the accessible name is used in the ComboBoxButton and ComboBoxListItemNode.
        items.forEach(((item, i) => {
            if (!item.accessibleName) {
                item.accessibleName = PDOMUtils.findStringProperty(nodes[i]);
            }
        }));
        assert && nodes.forEach(node => {
            assert && assert(!node.hasPDOMContent, 'Accessibility is provided by ComboBoxItemNode and ' +
                'ComboBoxItem.accessibleName. Additional PDOM content in the provided ' +
                'Node could break accessibility.');
        });
        // validate option values
        assert && assert(options.xMargin > 0 && options.yMargin > 0, `margins must be > 0, xMargin=${options.xMargin}, yMargin=${options.yMargin}`);
        assert && assert(_.includes(LIST_POSITION_VALUES, options.listPosition), `invalid listPosition: ${options.listPosition}`);
        assert && assert(_.includes(ALIGN_VALUES, options.align), `invalid align: ${options.align}`);
        super();
        this.nodes = nodes;
        this.listPosition = options.listPosition;
        this.button = new ComboBoxButton(property, items, nodes, {
            align: options.align,
            arrowDirection: (options.listPosition === 'below') ? 'down' : 'up',
            cornerRadius: options.cornerRadius,
            xMargin: options.xMargin,
            yMargin: options.yMargin,
            baseColor: options.buttonFill,
            stroke: options.buttonStroke,
            lineWidth: options.buttonLineWidth,
            touchAreaXDilation: options.buttonTouchAreaXDilation,
            touchAreaYDilation: options.buttonTouchAreaYDilation,
            mouseAreaXDilation: options.buttonMouseAreaXDilation,
            mouseAreaYDilation: options.buttonMouseAreaYDilation,
            localPreferredWidthProperty: this.localPreferredWidthProperty,
            localMinimumWidthProperty: this.localMinimumWidthProperty,
            comboBoxVoicingNameResponsePattern: options.comboBoxVoicingNameResponsePattern,
            // pdom - accessibleName and helpText are set via behavior functions on the ComboBox
            labelTagName: options.buttonLabelTagName,
            // phet-io
            tandem: options.tandem.createTandem('button')
        });
        this.addChild(this.button);
        this.listBox = new ComboBoxListBox(property, items, nodes, this.hideListBox.bind(this), // callback to hide the list box
        () => {
            // Check that it is focusable, for example, displayOnlyProperty can change that state, see https://github.com/phetsims/sun/issues/451
            if (this.button.isFocusable()) {
                this.button.blockNextVoicingFocusListener();
                this.button.focus();
            }
        }, this.button, options.tandem.createTandem('listBox'), {
            align: options.align,
            highlightFill: options.highlightFill,
            xMargin: options.xMargin,
            yMargin: options.yMargin,
            cornerRadius: options.cornerRadius,
            fill: options.listFill,
            stroke: options.listStroke,
            lineWidth: options.listLineWidth,
            visible: false,
            comboBoxListItemNodeOptions: {
                comboBoxVoicingNameResponsePattern: options.comboBoxVoicingNameResponsePattern,
                voicingContextResponse: options.comboBoxVoicingContextResponse,
                voicingHintResponse: options.comboBoxVoicingHintResponse
            },
            // sound generation
            openedSoundPlayer: options.openedSoundPlayer,
            closedNoChangeSoundPlayer: options.closedNoChangeSoundPlayer,
            // pdom
            // the list box is aria-labelledby its own label sibling
            ariaLabelledbyAssociations: [{
                    otherNode: this.button,
                    otherElementName: PDOMPeer.LABEL_SIBLING,
                    thisElementName: PDOMPeer.PRIMARY_SIBLING
                }]
        });
        listParent.addChild(this.listBox);
        this.listParent = listParent;
        // We want the drop-down combo list to be just AFTER the main ComboBox content in the PDOM.
        this.pdomOrder = [null, this.listBox];
        const listBoxMatrixProperty = new MatrixBetweenProperty(this.button, this.listParent, {
            fromCoordinateFrame: 'parent',
            toCoordinateFrame: 'local'
        });
        Multilink.multilink([listBoxMatrixProperty, this.button.localBoundsProperty, this.listBox.localBoundsProperty], matrix => {
            this.scaleAndPositionListBox(matrix);
        });
        // The listBox is not a child Node of ComboBox and, as a result, listen to opacity of the ComboBox and keep
        // the listBox in sync with them. See https://github.com/phetsims/sun/issues/587
        this.opacityProperty.link(opacity => { this.listBox.opacityProperty.value = opacity; });
        this.mutate(options);
        if (assert && Tandem.VALIDATION && this.isPhetioInstrumented()) {
            items.forEach(item => {
                assert && assert(item.tandemName !== null, `PhET-iO instrumented ComboBoxes require ComboBoxItems to have tandemName: ${item.value}`);
            });
        }
        // Clicking on the button toggles visibility of the list box
        this.button.addListener(() => {
            this.listBox.visibleProperty.value = !this.listBox.visibleProperty.value;
            this.listBox.visibleProperty.value && this.listBox.focusListItemNode(property.value);
        });
        this.display = null;
        this.clickToDismissListener = {
            down: event => {
                // If fuzzing is enabled, exercise this listener some percentage of the time, so that this listener is tested.
                // The rest of the time, ignore this listener, so that the listbox remains popped up, and we test making
                // choices from the listbox. See https://github.com/phetsims/sun/issues/677 for the initial implementation,
                // and See https://github.com/phetsims/aqua/issues/136 for the probability value chosen.
                if (!phet.chipper.isFuzzEnabled() || dotRandom.nextDouble() < 0.005) {
                    // Ignore if we click over the button, since the button will handle hiding the list.
                    if (!(event.trail.containsNode(this.button) || event.trail.containsNode(this.listBox))) {
                        this.hideListBox();
                    }
                }
            }
        };
        this.dismissWithFocusListener = focus => {
            if (focus && !focus.trail.containsNode(this.listBox)) {
                this.hideListBox();
            }
        };
        FocusManager.pdomFocusProperty.link(this.dismissWithFocusListener);
        this.listBox.visibleProperty.link(visible => {
            if (visible) {
                // show the list box
                this.scaleListBox();
                this.listBox.moveToFront();
                // manage clickToDismissListener
                assert && assert(!this.display, 'unexpected display');
                this.display = this.getUniqueTrail().rootNode().getRootedDisplays()[0];
                this.display.addInputListener(this.clickToDismissListener);
            }
            else {
                // manage clickToDismissListener
                if (this.display && this.display.hasInputListener(this.clickToDismissListener)) {
                    this.display.removeInputListener(this.clickToDismissListener);
                    this.display = null;
                }
            }
        });
        this.displayOnlyProperty = new BooleanProperty(false, {
            tandem: options.tandem.createTandem('displayOnlyProperty'),
            phetioFeatured: true,
            phetioDocumentation: 'disables interaction with the ComboBox and ' +
                'makes it appear like a display that shows the current selection'
        });
        this.displayOnlyProperty.link(displayOnly => {
            this.hideListBox();
            this.button.setDisplayOnly(displayOnly);
            this.inputEnabled = !displayOnly;
        });
        this.addLinkedElement(property, {
            tandemName: 'property'
        });
        assert && SunUtil.validateLinkedElementInstrumentation(this, property);
        // Hide the list box when the ComboBox is hidden
        this.visibleProperty.link(visible => {
            if (!visible) {
                this.hideListBox();
            }
        });
        this.disposeComboBox = () => {
            listBoxMatrixProperty.dispose();
            if (this.display && this.display.hasInputListener(this.clickToDismissListener)) {
                this.display.removeInputListener(this.clickToDismissListener);
            }
            FocusManager.pdomFocusProperty.unlink(this.dismissWithFocusListener);
            // dispose of subcomponents
            this.displayOnlyProperty.dispose(); // tandems must be cleaned up
            this.listBox.dispose();
            this.button.dispose();
            nodes.forEach(node => node.dispose());
        };
        // support for binder documentation, stripped out in builds and only runs when ?binder is specified
        assert && window.phet?.chipper?.queryParameters?.binder && InstanceRegistry.registerDataURL('sun', 'ComboBox', this);
    }
    dispose() {
        this.disposeComboBox();
        super.dispose();
    }
    /**
     * Shows the list box.
     */
    showListBox() {
        this.listBox.visibleProperty.value = true;
    }
    /**
     * Hides the list box.
     */
    hideListBox() {
        this.listBox.visibleProperty.value = false;
    }
    /**
     * Because the button and list box have different parents (and therefore different coordinate frames)
     * they may be scaled differently. This method scales the list box so that items on the button and in
     * the list appear to be the same size.
     */
    scaleListBox() {
        // To support an empty list box due to PhET-iO customization, see https://github.com/phetsims/sun/issues/606
        if (!this.listBox.localBounds.isEmpty()) {
            const buttonScale = this.button.localToGlobalBounds(this.button.localBounds).width / this.button.localBounds.width;
            const listBoxScale = this.listBox.localToGlobalBounds(this.listBox.localBounds).width / this.listBox.localBounds.width;
            this.listBox.scale(buttonScale / listBoxScale);
        }
    }
    scaleAndPositionListBox(listBoxMatrix) {
        if (listBoxMatrix) {
            // Scale the box before positioning.
            this.scaleListBox();
            if (this.listPosition === 'above') {
                this.listBox.leftBottom = listBoxMatrix.timesVector2(this.button.leftTop);
            }
            else {
                this.listBox.leftTop = listBoxMatrix.timesVector2(this.button.leftBottom);
            }
        }
    }
    /**
     * Sets the visibility of items that correspond to a value. If the selected item has this value, it's your
     * responsibility to change the Property value to something else. Otherwise, the combo box button will continue
     * to display this value.
     * @param value - the value associated with the ComboBoxItem
     * @param visible
     */
    setItemVisible(value, visible) {
        this.listBox.setItemVisible(value, visible);
    }
    /**
     * Is the item that corresponds to a value visible when the listbox is popped up?
     * @param value - the value associated with the ComboBoxItem
     */
    isItemVisible(value) {
        return this.listBox.isItemVisible(value);
    }
    static getMaxItemWidthProperty(nodes) {
        const widthProperties = _.flatten(nodes.map(node => {
            const properties = [node.boundsProperty];
            if (extendsWidthSizable(node)) {
                properties.push(node.isWidthResizableProperty);
                properties.push(node.minimumWidthProperty);
            }
            return properties;
        }));
        return DerivedProperty.deriveAny(widthProperties, () => {
            return Math.max(...nodes.map(node => isWidthSizable(node) ? node.minimumWidth || 0 : node.width));
        });
    }
    static getMaxItemHeightProperty(nodes) {
        const heightProperties = nodes.map(node => node.boundsProperty);
        return DerivedProperty.deriveAny(heightProperties, () => {
            return Math.max(...nodes.map(node => node.height));
        });
    }
}
ComboBox.ComboBoxIO = new IOType('ComboBoxIO', {
    valueType: ComboBox,
    documentation: 'A combo box is composed of a push button and a listbox. The listbox contains items that represent ' +
        'choices. Pressing the button pops up the listbox. Selecting from an item in the listbox sets the ' +
        'value of an associated Property. The button shows the item that is currently selected.',
    supertype: Node.NodeIO,
    events: ['listBoxShown', 'listBoxHidden']
});
export default ComboBox;
sun.register('ComboBox', ComboBox);
//# sourceMappingURL=ComboBox.js.map