Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
147 changes: 147 additions & 0 deletions app/src/docs/_examples/modals/NestedModals.example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React from 'react';
import { IModal, useUuiContext } from '@epam/uui-core';

import {
ModalBlocker,
ModalFooter,
ModalHeader,
ModalWindow,
FlexRow,
Panel,
ScrollBars,
Text,
Button,
WarningNotification,
} from '@epam/uui';
import css from './styles.module.scss';

function FirstModal(props: IModal<string>) {
const { uuiModals, uuiNotifications } = useUuiContext();

const showSecondModal = () => uuiModals
.show((secondModalProps) => <SecondModal { ...secondModalProps } />)
.catch(() => uuiNotifications.show((notificationProps) => (
<WarningNotification { ...notificationProps }>
<FlexRow alignItems="center">
<Text>Second modal was closed</Text>
</FlexRow>
</WarningNotification>
)).catch(() => null));

return (
<ModalBlocker
{ ...props }
abort={ () => {
uuiNotifications
.show((notificationProps) => (
<WarningNotification { ...notificationProps }>
<FlexRow alignItems="center">
<Text>First modal was closed by ESC</Text>
</FlexRow>
</WarningNotification>
))
.catch(() => null);
props.abort();
} }
>
<ModalWindow>
<Panel background="surface-main">
<ModalHeader title="First Modal" onClose={ props.abort } />
<ScrollBars hasTopShadow hasBottomShadow>
<FlexRow padding="24" vPadding="12">
<Text size="36">
This is the first modal. When you open a second modal from this one,
pressing ESC will only close the topmost (second) modal, not this one.
You'll see a notification showing which modal was closed.
</Text>
</FlexRow>
</ScrollBars>
<ModalFooter cx={ css.footer }>
<Button
color="primary"
caption="Open Second Modal"
onClick={ showSecondModal }
/>
<Button
color="secondary"
fill="outline"
caption="Close First Modal"
onClick={ props.abort }
/>
</ModalFooter>
</Panel>
</ModalWindow>
</ModalBlocker>
);
}

function SecondModal(props: IModal<string>) {
const { uuiNotifications } = useUuiContext();

return (
<ModalBlocker
{ ...props }
abort={ () => {
uuiNotifications
.show((notificationProps) => (
<WarningNotification { ...notificationProps }>
<FlexRow alignItems="center">
<Text>Second modal was closed by ESC</Text>
</FlexRow>
</WarningNotification>
))
.catch(() => null);
props.abort();
} }
>
<ModalWindow>
<Panel background="surface-main">
<ModalHeader title="Second Modal" onClose={ props.abort } />
<ScrollBars hasTopShadow hasBottomShadow>
<FlexRow padding="24" vPadding="12">
<Text size="36">
This is the second modal (topmost). When you press ESC,
only this modal will close, and the first modal will remain open.
You'll see a notification showing which modal was closed.
</Text>
</FlexRow>
</ScrollBars>
<ModalFooter cx={ css.footer }>
<Button
color="secondary"
fill="outline"
caption="Close Second Modal"
onClick={ props.abort }
/>
</ModalFooter>
</Panel>
</ModalWindow>
</ModalBlocker>
);
}

export default function NestedModalsExampleToggler() {
const { uuiModals, uuiNotifications } = useUuiContext();

const showFirstModal = (modalProps: IModal<unknown, any>) => (<FirstModal { ...modalProps } />);
const handleClose = () =>
uuiNotifications
.show((notificationProps) => (
<WarningNotification { ...notificationProps }>
<FlexRow alignItems="center">
<Text>First modal was closed</Text>
</FlexRow>
</WarningNotification>
))
.catch(() => null);

return (
<Button
caption="Open Nested Modals Example"
onClick={ () =>
uuiModals
.show(showFirstModal)
.catch(handleClose) }
/>
);
}
3 changes: 2 additions & 1 deletion app/src/docs/pages/components/modals.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{ "descriptionPath": "modals-descriptions" },
{ "name": "Basic", "componentPath": "modals/Basic.example.tsx" },
{ "name": "Modal with Form", "componentPath": "modals/ModalWithForm.example.tsx" },
{ "name": "Disabling close on click outside modal and modal header cross", "componentPath": "modals/DisableClickOutsideAndCross.example.tsx" }
{ "name": "Disabling close on click outside modal and modal header cross", "componentPath": "modals/DisableClickOutsideAndCross.example.tsx" },
{ "name": "Nested Modals", "componentPath": "modals/NestedModals.example.tsx" }
]
}
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

**What's Fixed**
* [useForm]: improved router block removal on discard and custom beforeLeave for close action. Rework useLock to unblock router immediately, rather than on next render
* [Modals]: fixed incorrect order of abort() calls when pressing ESC with nested modals - now only the topmost modal responds to ESC key

# 6.2.0 - 05.08.2025
**What's New**
Expand Down
73 changes: 73 additions & 0 deletions public/docs/content/modals-NestedModals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
[
{
"type": "paragraph",
"data": {},
"children": [
{
"text": "This example demonstrates the correct behavior of nested modals with ESC key handling. When you have multiple modals open, pressing ESC will only close the topmost (most recently opened) modal, not the underlying ones."
}
]
},
{
"type": "paragraph",
"data": {},
"children": [
{
"text": "To test this behavior:"
}
]
},
{
"type": "list",
"data": {
"styleType": "ordered"
},
"children": [
{
"type": "list-item",
"data": {},
"children": [
{
"text": "Click 'Open Nested Modals Example' to open the first modal"
}
]
},
{
"type": "list-item",
"data": {},
"children": [
{
"text": "Click 'Open Second Modal' to open a second modal on top of the first"
}
]
},
{
"type": "list-item",
"data": {},
"children": [
{
"text": "Press ESC - only the second (topmost) modal will close"
}
]
},
{
"type": "list-item",
"data": {},
"children": [
{
"text": "Press ESC again - the first modal will close"
}
]
}
]
},
{
"type": "paragraph",
"data": {},
"children": [
{
"text": "This behavior ensures that users can close modals in the expected order, with the most recently opened modal being the first to close."
}
]
}
]
4 changes: 2 additions & 2 deletions uui-components/src/overlays/ModalBlocker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export const ModalBlocker = React.forwardRef<HTMLDivElement, ModalBlockerProps>(
unsubscribeFromRouter();
}
};
}, [props.abort]);
}, [props.abort, props.isActive]);

const urlChangeHandler = () => {
!props.disableCloseOnRouterChange && context.uuiModals.closeAll();
};

const keydownHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && props.isActive) {
props.abort();
}
};
Expand Down
74 changes: 74 additions & 0 deletions uui/components/overlays/__tests__/Modals.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { IModal, ModalBlockerProps, useArrayDataSource, useUuiContext } from '@e
import { Button } from '../../buttons';
import { Modals } from '@epam/uui-components';
import { PickerInput } from '../../pickers';
import { Panel } from '../../layout';
import { Text } from '../../typography';

function TestElement(props: ModalBlockerProps) {
return (
Expand Down Expand Up @@ -301,5 +303,77 @@ describe('Modals', () => {
expect(abortMock).toBeCalled();
});

it('should only close the topmost modal when ESC is pressed with nested modals', async () => {
const { wrapper, testUuiCtx } = getDefaultUUiContextWrapper();

function FirstModal(props: IModal<string>) {
const openSecondModal = async () => {
try {
await testUuiCtx.uuiModals.show((secondModalProps) => (
<SecondModal { ...secondModalProps } />
));
} catch {}
};

return (
<ModalBlocker { ...props }>
<ModalWindow>
<Panel>
<Text>First Modal</Text>
<Button caption="Open 2 Modal" onClick={ openSecondModal } />
</Panel>
</ModalWindow>
</ModalBlocker>
);
}

function SecondModal(props: IModal<string>) {
return (
<ModalBlocker { ...props }>
<ModalWindow>
<Panel>
<Text>Second Modal</Text>
</Panel>
</ModalWindow>
</ModalBlocker>
);
}

function TestComponent(): ReactNode {
const handleOpenFirstModal = testUuiCtx.uuiModals.show((modalProps) => (
<FirstModal { ...modalProps } />
));

return (
<>
<Button caption="Open 1 Modal" onClick={ () => handleOpenFirstModal } />
<Modals />
</>
);
}

await renderWithContextAsync(<TestComponent />, { wrapper });

// Open first modal
const openFirstModalButton = await screen.findByRole('button', { name: /open 1 modal/i });
await userEvent.click(openFirstModalButton);

expect(await screen.findByText('First Modal')).toBeInTheDocument();

// Open second modal from first modal
const openSecondModalButton = await screen.findByRole('button', { name: /open 2 modal/i });
await userEvent.click(openSecondModalButton);

expect(await screen.findByText('Second Modal')).toBeInTheDocument();
expect(await screen.findByText('First Modal')).toBeInTheDocument();

// Press ESC - should only close the second (topmost) modal
await userEvent.keyboard('{Escape}');

// The second modal should be closed, first modal should remain
expect(screen.queryByText('Second Modal')).not.toBeInTheDocument();
expect(screen.getByText('First Modal')).toBeInTheDocument();
});

// TODO: create test for 'disableCloseOnRouterChange' when our 'setupComponentForTest' be able listen routes
});