Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Dialog] Modality changes #977

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/generated/alert-dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/overrides/alert-dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "AlertDialogRoot",
"props": {
"onOpenChange": {
"type": "(open, event) => void"
"type": "(open, event, reason) => void"
}
}
}
2 changes: 1 addition & 1 deletion docs/reference/overrides/dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "DialogRoot",
"props": {
"onOpenChange": {
"type": "(open, event) => void"
"type": "(open, event, reason) => void"
}
}
}
4 changes: 2 additions & 2 deletions packages/react/src/alert-dialog/close/AlertDialogClose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const AlertDialogClose = React.forwardRef(function AlertDialogClose(
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
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',
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
getPopupProps,
mounted,
nestedOpenDialogCount,
onOpenChange,
setOpen,
open,
openMethod,
popupRef,
Expand All @@ -78,7 +78,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
initialFocus,
modal: true,
mounted,
onOpenChange,
setOpen,
open,
openMethod,
ref: mergedRef,
Expand Down
73 changes: 73 additions & 0 deletions packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('<AlertDialog.Root />', () => {
const { render } = createRenderer();

describe('prop: onOpenChange', () => {
it('calls onOpenChange with the new open state', async () => {
const handleOpenChange = spy();

const { user } = await render(
<AlertDialog.Root onOpenChange={handleOpenChange}>
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Popup>
<AlertDialog.Close>Close</AlertDialog.Close>
</AlertDialog.Popup>
</AlertDialog.Root>,
);

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(
<AlertDialog.Root onOpenChange={handleOpenChange}>
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Popup>
<AlertDialog.Close>Close</AlertDialog.Close>
</AlertDialog.Popup>
</AlertDialog.Root>,
);

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(
<AlertDialog.Root defaultOpen onOpenChange={handleOpenChange}>
<AlertDialog.Trigger>Open</AlertDialog.Trigger>
<AlertDialog.Popup>
<AlertDialog.Close>Close</AlertDialog.Close>
</AlertDialog.Popup>
</AlertDialog.Root>,
);

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(
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/dialog/close/DialogClose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const DialogClose = React.forwardRef(function DialogClose(
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
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',
Expand Down
17 changes: 12 additions & 5 deletions packages/react/src/dialog/close/useDialogClose.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
'use client';
import * as React from 'react';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { OpenChangeReason } from '../../utils/translateOpenChangeReason';
import { useEventCallback } from '../../utils/useEventCallback';

export function useDialogClose(params: useDialogClose.Parameters): useDialogClose.ReturnValue {
const { open, onOpenChange } = params;
const handleClick = React.useCallback(() => {
const { open, setOpen } = params;

const handleClick = useEventCallback((event: React.MouseEvent) => {
if (open) {
onOpenChange?.(false);
setOpen(false, event.nativeEvent, 'click');
}
}, [open, onOpenChange]);
});

const getRootProps = (externalProps: React.HTMLAttributes<any>) =>
mergeReactProps(externalProps, { onClick: handleClick });
Expand All @@ -27,7 +30,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 {
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/dialog/popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
modal,
mounted,
nestedOpenDialogCount,
onOpenChange,
setOpen,
open,
openMethod,
popupRef,
Expand All @@ -82,7 +82,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
initialFocus,
modal,
mounted,
onOpenChange,
setOpen,
open,
openMethod,
ref: mergedRef,
Expand Down Expand Up @@ -117,12 +117,12 @@ const DialogPopup = React.forwardRef(function DialogPopup(
<FloatingPortal root={container}>
<FloatingFocusManager
context={floatingContext}
modal={modal}
modal
disabled={!mounted}
closeOnFocusOut={dismissible}
initialFocus={resolvedInitialFocus}
returnFocus={finalFocus}
outsideElementsInert
outsideElementsInert={modal}
>
{renderElement()}
</FloatingFocusManager>
Expand Down
28 changes: 24 additions & 4 deletions packages/react/src/dialog/popup/useDialogPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
'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';
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';
import { useEventCallback } from '../../utils/useEventCallback';

export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialogPopup.ReturnValue {
const {
Expand All @@ -18,7 +28,7 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
initialFocus,
modal,
mounted,
onOpenChange,
setOpen,
open,
openMethod,
ref,
Expand All @@ -27,9 +37,15 @@ export function useDialogPopup(parameters: useDialogPopup.Parameters): useDialog
titleElementId,
} = parameters;

const handleFloatingUIOpenChange = useEventCallback(
(isOpen: boolean, event: Event | undefined, reason: FloatingUIOpenChangeReason | undefined) => {
setOpen(isOpen, event, translateOpenChangeReason(reason));
},
);

const { context, elements } = useFloating({
open,
onOpenChange,
onOpenChange: handleFloatingUIOpenChange,
rootContext: floatingRootContext,
});

Expand Down Expand Up @@ -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.
*/
Expand Down
90 changes: 90 additions & 0 deletions packages/react/src/dialog/root/DialogRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,96 @@ describe('<Dialog.Root />', () => {
});
});

describe('prop: onOpenChange', () => {
it('calls onOpenChange with the new open state', async () => {
const handleOpenChange = spy();

const { user } = await render(
<Dialog.Root onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>,
);

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(
<Dialog.Root onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>,
);

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(
<Dialog.Root defaultOpen onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>,
);

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(
<Dialog.Root defaultOpen onOpenChange={handleOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>,
);

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(
Expand Down
Loading
Loading