// Copyright 2018-2025, University of Colorado Boulder
/**
 * General dialog type. Migrated from Joist on 4/10/2018
 *
 * @author Jonathan Olson (PhET Interactive Simulations)
 * @author Sam Reid (PhET Interactive Simulations)
 * @author Andrea Lin (PhET Interactive Simulations)
 * @author Chris Malley (PixelZoom, Inc.)
 */
import { string_sun_a11y_closed_StringProperty } from './strings/a11y/closed.js';
import { string_sun_a11y_titleClosePattern_StringProperty } from './strings/a11y/titleClosePattern.js';
import '../../globals.js';
import { assert } from '../../scenerystack/assert.js';
import Multilink from '../../axon/js/Multilink.js';
import PatternStringProperty from '../../axon/js/PatternStringProperty.js';
import TinyProperty from '../../axon/js/TinyProperty.js';
import ScreenView from '../../joist/js/ScreenView.js';
import getGlobal from '../../phet-core/js/getGlobal.js';
import optionize from '../../phet-core/js/optionize.js';
import CloseButton from '../../scenery-phet/js/buttons/CloseButton.js';
import FocusManager from '../../scenery/js/accessibility/FocusManager.js';
import { findStringProperty } from '../../scenery/js/accessibility/pdom/findStringProperty.js';
import ParallelDOM from '../../scenery/js/accessibility/pdom/ParallelDOM.js';
import PDOMPeer from '../../scenery/js/accessibility/pdom/PDOMPeer.js';
import PDOMUtils from '../../scenery/js/accessibility/pdom/PDOMUtils.js';
import voicingManager from '../../scenery/js/accessibility/voicing/voicingManager.js';
import AlignBox from '../../scenery/js/layout/nodes/AlignBox.js';
import HBox from '../../scenery/js/layout/nodes/HBox.js';
import VBox from '../../scenery/js/layout/nodes/VBox.js';
import KeyboardListener from '../../scenery/js/listeners/KeyboardListener.js';
import Node from '../../scenery/js/nodes/Node.js';
import assertNoAdditionalChildren from '../../scenery/js/util/assertNoAdditionalChildren.js';
import FullScreen from '../../scenery/js/util/FullScreen.js';
import nullSoundPlayer from '../../tambo/js/nullSoundPlayer.js';
import sharedSoundPlayers from '../../tambo/js/sharedSoundPlayers.js';
import Tandem, { DYNAMIC_ARCHETYPE_NAME } from '../../tandem/js/Tandem.js';
import DynamicMarkerIO from '../../tandem/js/types/DynamicMarkerIO.js';
import IOType from '../../tandem/js/types/IOType.js';
import Utterance from '../../utterance-queue/js/Utterance.js';
import ButtonNode from './buttons/ButtonNode.js';
import Panel from './Panel.js';
import Popupable from './Popupable.js';
import sun from './sun.js';
class Dialog extends Popupable(Panel, 1) {
    /**
     * @param content - The content to display inside the dialog (not including the title)
     * @param providedOptions
     */
    constructor(content, providedOptions) {
        const options = optionize()({
            // DialogOptions
            xSpacing: 10,
            ySpacing: 10,
            topMargin: 15,
            bottomMargin: 15,
            leftMargin: null,
            maxWidthMargin: 12,
            maxHeightMargin: 12,
            closeButtonLength: 18.2,
            closeButtonTopMargin: 10,
            closeButtonRightMargin: 10,
            title: null,
            titleAlign: 'center',
            layoutStrategy: defaultLayoutStrategy,
            closeButtonListener: () => this.hide(),
            closeButtonColor: 'black',
            closeButtonTouchAreaXDilation: 0,
            closeButtonTouchAreaYDilation: 0,
            closeButtonMouseAreaXDilation: 0,
            closeButtonMouseAreaYDilation: 0,
            closeButtonVoicingDialogTitle: null,
            closeButtonLastInPDOM: false,
            openedSoundPlayer: sharedSoundPlayers.get('generalOpen'),
            closedSoundPlayer: sharedSoundPlayers.get('generalClose'),
            sim: getGlobal('phet.joist.sim'),
            showCallback: null,
            hideCallback: null,
            // PopupableOptions
            layoutBounds: ScreenView.DEFAULT_LAYOUT_BOUNDS,
            focusOnShowNode: null,
            // PanelOptions
            cornerRadius: 10, // {number} radius of the dialog's corners
            resize: true, // {boolean} whether to resize if content's size changes
            fill: 'white', // {string|Color}
            stroke: 'black', // {string|Color}
            backgroundPickable: true,
            maxHeight: null, // if not provided, then dynamically calculate based on the layoutBounds of the current screen, see updateLayoutMultilink
            maxWidth: null, // if not provided, then dynamically calculate based on the layoutBounds of the current screen, see updateLayoutMultilink
            // phet-io
            tandemNameSuffix: ['Dialog', DYNAMIC_ARCHETYPE_NAME], // DYNAMIC_ARCHETYPE_NAME means that this Dialog is an archetype.
            phetioType: Dialog.DialogIO,
            phetioState: true, // Dialog is often a dynamic element, and thus needs to be in state to trigger element creation.
            phetioVisiblePropertyInstrumented: false, // visible isn't toggled when showing a Dialog
            // pdom options
            tagName: 'div',
            ariaRole: 'dialog',
            positionInPDOM: true
        }, providedOptions);
        assert && assert(options.sim, 'sim must be provided, as Dialog needs a Sim instance');
        assert && assert(options.xMargin === undefined, 'Dialog sets xMargin');
        options.xMargin = 0;
        assert && assert(options.yMargin === undefined, 'Dialog sets yMargin');
        options.yMargin = 0;
        // if left margin is specified in options, use it. otherwise, set it to make the left right gutters symmetrical
        if (options.leftMargin === null) {
            options.leftMargin = options.xSpacing + options.closeButtonLength + options.closeButtonRightMargin;
        }
        assert && assert(options.maxHeight === null || typeof options.maxHeight === 'number');
        assert && assert(options.maxWidth === null || typeof options.maxWidth === 'number');
        // Apply maxWidth/maxHeight depending on the margins and layoutBounds
        if (!options.maxWidth && options.layoutBounds) {
            options.maxWidth = applyDoubleMargin(options.layoutBounds.width, options.maxWidthMargin);
        }
        if (!options.maxHeight && options.layoutBounds) {
            options.maxHeight = applyDoubleMargin(options.layoutBounds.height, options.maxHeightMargin);
        }
        // We need an "unattached" utterance so that when the close button fires, hiding the close button, we still hear
        // the context response. But we still should only hear this context response when "Sim Voicing" is enabled.
        const contextResponseUtterance = new Utterance({
            priority: Utterance.MEDIUM_PRIORITY,
            voicingCanAnnounceProperties: [voicingManager.voicingFullyEnabledProperty]
        });
        // create close button - a flat "X"
        const closeButton = new CloseButton({
            iconLength: options.closeButtonLength,
            baseColor: 'transparent',
            buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
            // no margins since the flat X takes up all the space
            xMargin: 0,
            yMargin: 0,
            listener: () => {
                // Context response first, before potentially changing focus with the callback listener
                closeButton.voicingSpeakContextResponse({
                    utterance: contextResponseUtterance
                });
                options.closeButtonListener();
            },
            pathOptions: {
                stroke: options.closeButtonColor
            },
            // phet-io
            tandem: options.tandem?.createTandem('closeButton'),
            phetioState: false, // close button should not be in state
            // It is a usability concern to change either of these, also there are other ways to exit a Dialog, so using
            // these is buggy.
            phetioVisiblePropertyInstrumented: false,
            phetioEnabledPropertyInstrumented: false,
            // turn off default sound generation, Dialog will create its own sounds
            soundPlayer: nullSoundPlayer,
            // voicing
            voicingContextResponse: string_sun_a11y_closed_StringProperty
        });
        let closeButtonVoicingNameResponseProperty;
        if (options.closeButtonVoicingDialogTitle) {
            const titleProperty = typeof options.closeButtonVoicingDialogTitle === 'string' ? new TinyProperty(options.closeButtonVoicingDialogTitle) : options.closeButtonVoicingDialogTitle;
            closeButtonVoicingNameResponseProperty = closeButton.voicingNameResponse = new PatternStringProperty(string_sun_a11y_titleClosePattern_StringProperty, { title: titleProperty }, { tandem: Tandem.OPT_OUT });
        }
        // touch/mouse areas for the close button
        closeButton.touchArea = closeButton.bounds.dilatedXY(options.closeButtonTouchAreaXDilation, options.closeButtonTouchAreaYDilation);
        closeButton.mouseArea = closeButton.bounds.dilatedXY(options.closeButtonMouseAreaXDilation, options.closeButtonMouseAreaYDilation);
        // Container Nodes for the accessibleName and accessibleHelpText, to support the desired PDOM order and markup for the dialog.
        const accessibleNameNode = new Node({ tagName: 'h1' });
        const accessibleHelpTextNode = new Node({ tagName: 'p' });
        // pdom - set the order of content, close button first so remaining content can be read from top to bottom
        // with virtual cursor
        let pdomOrder = [accessibleNameNode, accessibleHelpTextNode, options.title, content];
        options.closeButtonLastInPDOM ? pdomOrder.push(closeButton) : pdomOrder.unshift(closeButton);
        pdomOrder = pdomOrder.filter(node => node !== undefined && node !== null);
        // pdom - fall back to focusing the closeButton by default if there is no focusOnShowNode or the
        // content is not focusable
        assert && assert(pdomOrder[0]);
        options.focusOnShowNode = options.focusOnShowNode ? options.focusOnShowNode :
            pdomOrder[0].focusable ? pdomOrder[0] :
                closeButton;
        assert && assert(options.focusOnShowNode instanceof Node, 'should be non-null and defined');
        assert && assert(options.focusOnShowNode.focusable, 'focusOnShowNode must be focusable.');
        // Align content, title, and close button using spacing and margin options
        // align content and title (if provided) vertically
        const contentAndTitle = new VBox({
            children: options.title ? [options.title, content] : [content],
            spacing: options.ySpacing,
            align: options.titleAlign
        });
        // add topMargin, bottomMargin, and leftMargin
        const contentAndTitleWithMargins = new AlignBox(contentAndTitle, {
            topMargin: options.topMargin,
            bottomMargin: options.bottomMargin,
            leftMargin: options.leftMargin
        });
        // add closeButtonTopMargin and closeButtonRightMargin
        const closeButtonWithMargins = new AlignBox(closeButton, {
            topMargin: options.closeButtonTopMargin,
            rightMargin: options.closeButtonRightMargin
        });
        // create content for Panel
        const dialogContent = new HBox({
            children: [accessibleNameNode, accessibleHelpTextNode, contentAndTitleWithMargins, closeButtonWithMargins],
            spacing: options.xSpacing,
            align: 'top'
        });
        super(dialogContent, options);
        // The Dialog's display runs on this Property, so add the listener that controls show/hide.
        this.isShowingProperty.lazyLink(isShowing => {
            if (isShowing) {
                // sound generation
                options.openedSoundPlayer.play();
                // Do this last
                options.showCallback && options.showCallback();
            }
            else {
                // sound generation
                options.closedSoundPlayer.play();
                // Do this last
                options.hideCallback && options.hideCallback();
            }
        });
        this.sim = options.sim;
        this.closeButton = closeButton;
        const updateLayoutMultilink = Multilink.multilink([
            this.sim.boundsProperty,
            this.sim.screenBoundsProperty,
            this.sim.scaleProperty,
            this.sim.selectedScreenProperty,
            this.isShowingProperty,
            this.localBoundsProperty
        ], (bounds, screenBounds, scale) => {
            if (bounds && screenBounds && scale) {
                options.layoutStrategy(this, bounds, screenBounds, scale);
            }
        });
        // Setter after the super call
        this.pdomOrder = pdomOrder;
        // When setting the accessibleName on the dialog, it forwards the content to the implementation Node.
        ParallelDOM.forwardAccessibleName(this, accessibleNameNode);
        // When setting the accessibleHelpText on the dialog, it forwards the text to the inner content of the implementation Node.
        this.accessibleHelpTextBehavior = (node, options, accessibleHelpText, forwardingCallbacks) => {
            forwardingCallbacks.push(() => {
                accessibleHelpTextNode.innerContent = accessibleHelpText;
            });
            return options;
        };
        // If no accessibleName has been provided, try to find one from the title by default
        if (!options.accessibleName && options.title) {
            this.accessibleName = findStringProperty(options.title);
        }
        // pdom - set the aria-labelledby relation so that whenever focus enters the dialog the accessible name is read
        this.addAriaLabelledbyAssociation({
            thisElementName: PDOMPeer.PRIMARY_SIBLING,
            otherNode: accessibleNameNode,
            otherElementName: PDOMPeer.LABEL_SIBLING
        });
        // pdom - close the dialog when pressing "escape"
        const keyboardListener = new KeyboardListener({
            keys: ['escape', 'tab', 'shift+tab'],
            fire: (event, keysPressed) => {
                assert && assert(event, 'event should be non-null and defined for this listener');
                const domEvent = event;
                if (keysPressed === 'escape') {
                    domEvent.preventDefault();
                    this.hide();
                }
                else if ((keysPressed === 'tab' || keysPressed === 'shift+tab') && FullScreen.isFullScreen()) {
                    // prevent a particular bug in Windows 7/8.1 Firefox where focus gets trapped in the document
                    // when the navigation bar is hidden and there is only one focusable element in the DOM
                    // see https://bugzilla.mozilla.org/show_bug.cgi?id=910136
                    assert && assert(FocusManager.pdomFocus); // {Focus|null}
                    const activeId = FocusManager.pdomFocus.trail.getUniqueId();
                    const noNextFocusable = PDOMUtils.getNextFocusable().id === activeId;
                    const noPreviousFocusable = PDOMUtils.getPreviousFocusable().id === activeId;
                    if (noNextFocusable && noPreviousFocusable) {
                        domEvent.preventDefault();
                    }
                }
            }
        });
        this.addInputListener(keyboardListener);
        this.disposeDialog = () => {
            updateLayoutMultilink.dispose();
            closeButtonWithMargins.dispose();
            this.removeInputListener(keyboardListener);
            keyboardListener.dispose();
            closeButtonVoicingNameResponseProperty && closeButtonVoicingNameResponseProperty.dispose();
            closeButton.dispose();
            contextResponseUtterance.dispose();
            contentAndTitle.dispose();
            // remove dialog content from scene graph, but don't dispose because Panel
            // needs to remove listeners on the content in its dispose()
            dialogContent.removeAllChildren();
            dialogContent.detach();
        };
        // Set content through the constructor, Dialog does not support decorating with additional content
        assert && assertNoAdditionalChildren(this);
    }
    dispose() {
        this.disposeDialog();
        super.dispose();
    }
}
Dialog.DialogIO = new IOType('DialogIO', {
    valueType: Dialog,
    // Since many Dialogs are dynamic elements, these need to be in the state. The value of the state object doesn't
    // matter, but it instead just serves as a marker to tell the state engine to recreate the Dialog (if dynamic) when
    // setting state.
    supertype: DynamicMarkerIO
});
export default Dialog;
// Default value for options.layoutStrategy, centers the Dialog in the layoutBounds.
function defaultLayoutStrategy(dialog, simBounds, screenBounds, scale) {
    if (dialog.layoutBounds) {
        dialog.center = dialog.layoutBounds.center;
    }
}
/**
 * @param dimension - width or height dimension
 * @param margin - margin to be applied to the dimension
 */
function applyDoubleMargin(dimension, margin) {
    return (dimension > margin * 2) ? (dimension - margin * 2) : dimension;
}
sun.register('Dialog', Dialog);
