diff --git a/docs/reference/generated/alert-dialog-root.json b/docs/reference/generated/alert-dialog-root.json index a9618f802c..ec62ec629b 100644 --- a/docs/reference/generated/alert-dialog-root.json +++ b/docs/reference/generated/alert-dialog-root.json @@ -8,7 +8,7 @@ "description": "Determines whether the dialog is initally open.\nThis is an uncontrolled equivalent of the `open` prop." }, "onOpenChange": { - "type": "(open, event) => void", + "type": "(open, event, reason) => void", "description": "Callback invoked when the dialog is being opened or closed." }, "open": { diff --git a/docs/reference/generated/dialog-root.json b/docs/reference/generated/dialog-root.json index 51d4b36113..681232d72c 100644 --- a/docs/reference/generated/dialog-root.json +++ b/docs/reference/generated/dialog-root.json @@ -18,7 +18,7 @@ "description": "Determines whether the dialog is modal." }, "onOpenChange": { - "type": "(open, event) => void", + "type": "(open, event, reason) => void", "description": "Callback invoked when the dialog is being opened or closed." }, "open": { diff --git a/docs/reference/overrides/alert-dialog-root.json b/docs/reference/overrides/alert-dialog-root.json index a6fdcbdbca..895baa7019 100644 --- a/docs/reference/overrides/alert-dialog-root.json +++ b/docs/reference/overrides/alert-dialog-root.json @@ -2,7 +2,7 @@ "name": "AlertDialogRoot", "props": { "onOpenChange": { - "type": "(open, event) => void" + "type": "(open, event, reason) => void" } } } diff --git a/docs/reference/overrides/dialog-root.json b/docs/reference/overrides/dialog-root.json index fdfdd27366..043e652352 100644 --- a/docs/reference/overrides/dialog-root.json +++ b/docs/reference/overrides/dialog-root.json @@ -2,7 +2,7 @@ "name": "DialogRoot", "props": { "onOpenChange": { - "type": "(open, event) => void" + "type": "(open, event, reason) => void" } } } diff --git a/packages/react/src/alert-dialog/close/AlertDialogClose.tsx b/packages/react/src/alert-dialog/close/AlertDialogClose.tsx index 97b2bb64ca..6c269eb8a8 100644 --- a/packages/react/src/alert-dialog/close/AlertDialogClose.tsx +++ b/packages/react/src/alert-dialog/close/AlertDialogClose.tsx @@ -23,8 +23,8 @@ const AlertDialogClose = React.forwardRef(function AlertDialogClose( forwardedRef: React.ForwardedRef, ) { const { render, className, ...other } = props; - const { open, onOpenChange } = useAlertDialogRootContext(); - const { getRootProps } = useDialogClose({ open, onOpenChange }); + const { open, setOpen } = useAlertDialogRootContext(); + const { getRootProps } = useDialogClose({ open, setOpen }); const { renderElement } = useComponentRenderer({ render: render ?? 'button', diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx index 127b1918b8..d2468b6a2b 100644 --- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx +++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx @@ -58,7 +58,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( getPopupProps, mounted, nestedOpenDialogCount, - onOpenChange, + setOpen, open, openMethod, popupRef, @@ -78,7 +78,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup( initialFocus, modal: true, mounted, - onOpenChange, + setOpen, open, openMethod, ref: mergedRef, diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx index f97386d680..38c643668a 100644 --- a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx +++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx @@ -3,12 +3,85 @@ import { expect } from 'chai'; import { describeSkipIf, screen, waitFor } from '@mui/internal-test-utils'; import { AlertDialog } from '@base-ui-components/react/alert-dialog'; import { createRenderer } from '#test-utils'; +import { spy } from 'sinon'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); describe('', () => { const { render } = createRenderer(); + describe('prop: onOpenChange', () => { + it('calls onOpenChange with the new open state', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + expect(handleOpenChange.callCount).to.equal(0); + + const openButton = screen.getByText('Open'); + await user.click(openButton); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[0]).to.equal(true); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(handleOpenChange.callCount).to.equal(2); + expect(handleOpenChange.secondCall.args[0]).to.equal(false); + }); + + it('calls onOpenChange with the reason for change when clicked on trigger and close button', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + const openButton = screen.getByText('Open'); + await user.click(openButton); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[2]).to.equal('click'); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(handleOpenChange.callCount).to.equal(2); + expect(handleOpenChange.secondCall.args[2]).to.equal('click'); + }); + + it('calls onOpenChange with the reason for change when pressed Esc while the dialog is open', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + await user.keyboard('[Escape]'); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[2]).to.equal('escape-key'); + }); + }); + describeSkipIf(isJSDOM)('modality', () => { it('makes other interactive elements on the page inert when a modal dialog is open and restores them after the dialog is closed', async () => { const { user } = await render( diff --git a/packages/react/src/dialog/close/DialogClose.tsx b/packages/react/src/dialog/close/DialogClose.tsx index ddbff26579..157e03cbd6 100644 --- a/packages/react/src/dialog/close/DialogClose.tsx +++ b/packages/react/src/dialog/close/DialogClose.tsx @@ -23,8 +23,8 @@ const DialogClose = React.forwardRef(function DialogClose( forwardedRef: React.ForwardedRef, ) { const { render, className, ...other } = props; - const { open, onOpenChange } = useDialogRootContext(); - const { getRootProps } = useDialogClose({ open, onOpenChange }); + const { open, setOpen } = useDialogRootContext(); + const { getRootProps } = useDialogClose({ open, setOpen }); const { renderElement } = useComponentRenderer({ render: render ?? 'button', diff --git a/packages/react/src/dialog/close/useDialogClose.ts b/packages/react/src/dialog/close/useDialogClose.ts index 45af0d84f6..a7ad0da976 100644 --- a/packages/react/src/dialog/close/useDialogClose.ts +++ b/packages/react/src/dialog/close/useDialogClose.ts @@ -1,14 +1,18 @@ 'use client'; import * as React from 'react'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { OpenChangeReason } from '../../utils/translateOpenChangeReason'; export function useDialogClose(params: useDialogClose.Parameters): useDialogClose.ReturnValue { - const { open, onOpenChange } = params; - const handleClick = React.useCallback(() => { - if (open) { - onOpenChange?.(false); - } - }, [open, onOpenChange]); + const { open, setOpen } = params; + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + if (open) { + setOpen(false, event.nativeEvent, 'click'); + } + }, + [open, setOpen], + ); const getRootProps = (externalProps: React.HTMLAttributes) => mergeReactProps(externalProps, { onClick: handleClick }); @@ -27,7 +31,11 @@ export namespace useDialogClose { /** * Callback invoked when the dialog is being opened or closed. */ - onOpenChange: (open: boolean) => void; + setOpen: ( + open: boolean, + event: Event | undefined, + reason: OpenChangeReason | undefined, + ) => void; } export interface ReturnValue { diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx index df8cd430a4..66ad03e47e 100644 --- a/packages/react/src/dialog/popup/DialogPopup.tsx +++ b/packages/react/src/dialog/popup/DialogPopup.tsx @@ -62,7 +62,7 @@ const DialogPopup = React.forwardRef(function DialogPopup( modal, mounted, nestedOpenDialogCount, - onOpenChange, + setOpen, open, openMethod, popupRef, @@ -82,7 +82,7 @@ const DialogPopup = React.forwardRef(function DialogPopup( initialFocus, modal, mounted, - onOpenChange, + setOpen, open, openMethod, ref: mergedRef, @@ -117,12 +117,12 @@ const DialogPopup = React.forwardRef(function DialogPopup( {renderElement()} diff --git a/packages/react/src/dialog/popup/useDialogPopup.tsx b/packages/react/src/dialog/popup/useDialogPopup.tsx index 9d2c74adc6..922add433f 100644 --- a/packages/react/src/dialog/popup/useDialogPopup.tsx +++ b/packages/react/src/dialog/popup/useDialogPopup.tsx @@ -1,6 +1,11 @@ 'use client'; import * as React from 'react'; -import { type FloatingRootContext, useFloating, type FloatingContext } from '@floating-ui/react'; +import { + type FloatingRootContext, + useFloating, + type FloatingContext, + type OpenChangeReason as FloatingUIOpenChangeReason, +} from '@floating-ui/react'; import { useBaseUiId } from '../../utils/useBaseUiId'; import { useForkRef } from '../../utils/useForkRef'; import { mergeReactProps } from '../../utils/mergeReactProps'; @@ -8,6 +13,10 @@ import { useScrollLock } from '../../utils/useScrollLock'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { type InteractionType } from '../../utils/useEnhancedClickHandler'; import { GenericHTMLProps } from '../../utils/types'; +import { + translateOpenChangeReason, + type OpenChangeReason, +} from '../../utils/translateOpenChangeReason'; export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialogPopup.ReturnValue { const { @@ -18,7 +27,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog initialFocus, modal, mounted, - onOpenChange, + setOpen, open, openMethod, ref, @@ -27,9 +36,16 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog titleElementId, } = parameters; + const handleFloatingUIOpenChange = React.useCallback( + (isOpen: boolean, event: Event | undefined, reason: FloatingUIOpenChangeReason | undefined) => { + setOpen(isOpen, event, translateOpenChangeReason(reason)); + }, + [setOpen], + ); + const { context, elements } = useFloating({ open, - onOpenChange, + onOpenChange: handleFloatingUIOpenChange, rootContext: floatingRootContext, }); @@ -112,7 +128,11 @@ export namespace useDialogPopup { /** * Callback fired when the dialog is requested to be opened or closed. */ - onOpenChange: (open: boolean, event?: Event) => void; + setOpen: ( + open: boolean, + event: Event | undefined, + reason: OpenChangeReason | undefined, + ) => void; /** * The id of the title element associated with the dialog. */ diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx index b9dec9b7a5..43839668a0 100644 --- a/packages/react/src/dialog/root/DialogRoot.test.tsx +++ b/packages/react/src/dialog/root/DialogRoot.test.tsx @@ -140,6 +140,96 @@ describe('', () => { }); }); + describe('prop: onOpenChange', () => { + it('calls onOpenChange with the new open state', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + expect(handleOpenChange.callCount).to.equal(0); + + const openButton = screen.getByText('Open'); + await user.click(openButton); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[0]).to.equal(true); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(handleOpenChange.callCount).to.equal(2); + expect(handleOpenChange.secondCall.args[0]).to.equal(false); + }); + + it('calls onOpenChange with the reason for change when clicked on trigger and close button', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + const openButton = screen.getByText('Open'); + await user.click(openButton); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[2]).to.equal('click'); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(handleOpenChange.callCount).to.equal(2); + expect(handleOpenChange.secondCall.args[2]).to.equal('click'); + }); + + it('calls onOpenChange with the reason for change when pressed Esc while the dialog is open', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + await user.keyboard('[Escape]'); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[2]).to.equal('escape-key'); + }); + + it('calls onOpenChange with the reason for change when user clicks outside while the dialog is open', async () => { + const handleOpenChange = spy(); + + const { user } = await render( + + Open + + Close + + , + ); + + await user.click(document.body); + + expect(handleOpenChange.callCount).to.equal(1); + expect(handleOpenChange.firstCall.args[2]).to.equal('outside-press'); + }); + }); + describeSkipIf(isJSDOM)('prop: modal', () => { it('makes other interactive elements on the page inert when a modal dialog is open and restores them after the dialog is closed', async () => { const { user } = await render( diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts index 3407c45eac..cdfa975db2 100644 --- a/packages/react/src/dialog/root/useDialogRoot.ts +++ b/packages/react/src/dialog/root/useDialogRoot.ts @@ -6,6 +6,7 @@ import { useDismiss, useFloatingRootContext, useInteractions, + type OpenChangeReason as FloatingUIOpenChangeReason, } from '@floating-ui/react'; import { useControlled } from '../../utils/useControlled'; import { useEventCallback } from '../../utils/useEventCallback'; @@ -15,6 +16,10 @@ import type { RequiredExcept, GenericHTMLProps } from '../../utils/types'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { useAfterExitAnimation } from '../../utils/useAfterExitAnimation'; +import { + type OpenChangeReason, + translateOpenChangeReason, +} from '../../utils/translateOpenChangeReason'; export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRoot.ReturnValue { const { @@ -23,7 +28,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo modal, onNestedDialogClose, onNestedDialogOpen, - onOpenChange, + onOpenChange: onOpenChangeParameter, open: openParam, } = parameters; @@ -46,10 +51,12 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); - const setOpen = useEventCallback((nextOpen: boolean, event?: Event) => { - onOpenChange?.(nextOpen, event); - setOpenUnwrapped(nextOpen); - }); + const setOpen = useEventCallback( + (nextOpen: boolean, event: Event | undefined, reason: OpenChangeReason | undefined) => { + onOpenChangeParameter?.(nextOpen, event, reason); + setOpenUnwrapped(nextOpen); + }, + ); useAfterExitAnimation({ open, @@ -57,10 +64,21 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo onFinished: () => setMounted(false), }); + const handleFloatingUIOpenChange = React.useCallback( + ( + nextOpen: boolean, + event: Event | undefined, + reason: FloatingUIOpenChangeReason | undefined, + ) => { + setOpen(nextOpen, event, translateOpenChangeReason(reason)); + }, + [setOpen], + ); + const context = useFloatingRootContext({ elements: { reference: triggerElement, floating: popupElement }, open, - onOpenChange: setOpen, + onOpenChange: handleFloatingUIOpenChange, }); const [ownNestedOpenDialogs, setOwnNestedOpenDialogs] = React.useState(0); const isTopmost = ownNestedOpenDialogs === 0; @@ -107,7 +125,7 @@ export function useDialogRoot(parameters: useDialogRoot.Parameters): useDialogRo return React.useMemo(() => { return { modal, - onOpenChange: setOpen, + setOpen, open, titleElementId, setTitleElementId, @@ -172,7 +190,11 @@ export interface CommonParameters { /** * Callback invoked when the dialog is being opened or closed. */ - onOpenChange?: (open: boolean, event?: Event) => void; + onOpenChange?: ( + open: boolean, + event: Event | undefined, + reason: OpenChangeReason | undefined, + ) => void; /** * Determines whether the dialog should close when clicking outside of it. * @default true @@ -216,7 +238,11 @@ export namespace useDialogRoot { /** * Callback to fire when the dialog is requested to be opened or closed. */ - onOpenChange: (open: boolean, event?: Event) => void; + setOpen: ( + open: boolean, + event: Event | undefined, + reason: OpenChangeReason | undefined, + ) => void; /** * Determines if the dialog is open. */