diff --git a/docs/reference/generated/alert-dialog-root.json b/docs/reference/generated/alert-dialog-root.json index 7b06febe0f..5b85dda906 100644 --- a/docs/reference/generated/alert-dialog-root.json +++ b/docs/reference/generated/alert-dialog-root.json @@ -14,6 +14,10 @@ "onOpenChange": { "type": "(open, event, reason) => void", "description": "Event handler called when the dialog is opened or closed." + }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the dialog is opened or closed." } }, "dataAttributes": {}, diff --git a/docs/reference/generated/dialog-root.json b/docs/reference/generated/dialog-root.json index ae72c2198a..7066d546f2 100644 --- a/docs/reference/generated/dialog-root.json +++ b/docs/reference/generated/dialog-root.json @@ -24,6 +24,10 @@ "type": "boolean", "default": "true", "description": "Whether the dialog should prevent outside clicks and lock page scroll when open." + }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the dialog is opened or closed." } }, "dataAttributes": {}, diff --git a/docs/reference/generated/menu-root.json b/docs/reference/generated/menu-root.json index aee7fbf47e..d4d9761051 100644 --- a/docs/reference/generated/menu-root.json +++ b/docs/reference/generated/menu-root.json @@ -25,6 +25,10 @@ "default": "true", "description": "Whether the menu should prevent outside clicks and lock page scroll when open." }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the menu is opened or closed." + }, "disabled": { "type": "boolean", "default": "false", diff --git a/docs/reference/generated/popover-root.json b/docs/reference/generated/popover-root.json index 6b3efcddcc..9a427b2492 100644 --- a/docs/reference/generated/popover-root.json +++ b/docs/reference/generated/popover-root.json @@ -15,6 +15,10 @@ "type": "(open, event, reason) => void", "description": "Event handler called when the popover is opened or closed." }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the popover is opened or closed." + }, "openOnHover": { "type": "boolean", "default": "false", diff --git a/docs/reference/generated/preview-card-root.json b/docs/reference/generated/preview-card-root.json index e99b8789d7..7ab48af6f4 100644 --- a/docs/reference/generated/preview-card-root.json +++ b/docs/reference/generated/preview-card-root.json @@ -15,6 +15,10 @@ "type": "(open, event, reason) => void", "description": "Event handler called when the preview card is opened or closed." }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the preview card is opened or closed." + }, "delay": { "type": "number", "default": "600", diff --git a/docs/reference/generated/select-root.json b/docs/reference/generated/select-root.json index 50fb8031c1..75d5f4a645 100644 --- a/docs/reference/generated/select-root.json +++ b/docs/reference/generated/select-root.json @@ -42,6 +42,10 @@ "default": "true", "description": "Whether the select should prevent outside clicks and lock page scroll when open." }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the select menu is opened or closed." + }, "disabled": { "type": "boolean", "default": "false", diff --git a/docs/reference/generated/tooltip-root.json b/docs/reference/generated/tooltip-root.json index 0e7602dee0..6c179fb4ef 100644 --- a/docs/reference/generated/tooltip-root.json +++ b/docs/reference/generated/tooltip-root.json @@ -15,6 +15,10 @@ "type": "(open, event, reason) => void", "description": "Event handler called when the tooltip is opened or closed." }, + "onOpenChangeComplete": { + "type": "(open) => void", + "description": "Event handler called after any animations complete when the tooltip is opened or closed." + }, "trackCursorAxis": { "type": "'none' | 'x' | 'y' | 'both'", "default": "'none'", diff --git a/docs/reference/overrides/common.json b/docs/reference/overrides/common.json index 0a31e1a0d8..b96bc5ee4b 100644 --- a/docs/reference/overrides/common.json +++ b/docs/reference/overrides/common.json @@ -27,6 +27,9 @@ "onClick": { "type": "(event) => void" }, + "onOpenChangeComplete": { + "type": "(open) => void" + }, "render": { "type": "React.ReactElement | (props, state) => React.ReactElement" }, diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx index e4059e66c3..b3ffc36c6d 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx @@ -16,6 +16,7 @@ import { transitionStatusMapping } from '../../utils/styleHookMapping'; import { AlertDialogPopupDataAttributes } from './AlertDialogPopupDataAttributes'; import { InternalBackdrop } from '../../utils/InternalBackdrop'; import { useAlertDialogPortalContext } from '../portal/AlertDialogPortalContext'; +import { useOpenChangeComplete } from '../../utils/useOpenChangeComplete'; const customStyleHookMapping: CustomStyleHookMapping = { ...baseMapping, @@ -53,11 +54,22 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( titleElementId, transitionStatus, modal, + onOpenChangeComplete, internalBackdropRef, } = useAlertDialogRootContext(); useAlertDialogPortalContext(); + useOpenChangeComplete({ + open, + ref: popupRef, + onComplete() { + if (open) { + onOpenChangeComplete?.(true); + } + }, + }); + const mergedRef = useForkRef(forwardedRef, popupRef); const { getRootProps, resolvedInitialFocus } = useDialogPopup({ diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx index ee85249772..02b6c96ecf 100644 --- a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx +++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; -import { screen } from '@mui/internal-test-utils'; +import { screen, waitFor } from '@mui/internal-test-utils'; import { AlertDialog } from '@base-ui-components/react/alert-dialog'; import { createRenderer, isJSDOM } from '#test-utils'; import { spy } from 'sinon'; @@ -8,6 +8,10 @@ import { spy } from 'sinon'; describe('', () => { const { render } = createRenderer(); + beforeEach(() => { + globalThis.BASE_UI_ANIMATIONS_DISABLED = true; + }); + it('ARIA attributes', async () => { const { queryByRole, getByText } = await render( @@ -128,4 +132,170 @@ describe('', () => { expect(screen.getByRole('presentation', { hidden: true })).not.to.equal(null); }); }); + + describe.skipIf(isJSDOM)('prop: onOpenChangeComplete', () => { + it('is called on close when there is no exit animation defined', async () => { + const onOpenChangeComplete = spy(); + + function Test() { + const [open, setOpen] = React.useState(true); + return ( +
+ + + + + + +
+ ); + } + + const { user } = await render(); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId('popup')).to.equal(null); + }); + + expect(onOpenChangeComplete.firstCall.args[0]).to.equal(true); + expect(onOpenChangeComplete.lastCall.args[0]).to.equal(false); + }); + + it('is called on close when the exit animation finishes', async () => { + globalThis.BASE_UI_ANIMATIONS_DISABLED = false; + + const onOpenChangeComplete = spy(); + + function Test() { + const style = ` + @keyframes test-anim { + to { + opacity: 0; + } + } + + .animation-test-indicator[data-ending-style] { + animation: test-anim 1ms; + } + `; + + const [open, setOpen] = React.useState(true); + + return ( +
+ {/* eslint-disable-next-line react/no-danger */} +