diff --git a/src/components/Dialog/__tests__/DialogManagerContext.test.tsx b/src/components/Dialog/__tests__/DialogManagerContext.test.tsx
index 66edd0f87..22d9d85b5 100644
--- a/src/components/Dialog/__tests__/DialogManagerContext.test.tsx
+++ b/src/components/Dialog/__tests__/DialogManagerContext.test.tsx
@@ -5,12 +5,13 @@ import {
useDialogManager,
} from '../../../context/DialogManagerContext';
-import { useDialogIsOpen, useOpenedDialogCount } from '../hooks';
+import { useDialogIsOpen, useDialogIsTopmost, useOpenedDialogCount } from '../hooks';
const TEST_IDS = {
CLOSE_DIALOG: 'close-dialog',
DIALOG_COUNT: 'dialog-count',
DIALOG_OPEN: 'dialog-open',
+ DIALOG_TOPMOST: 'dialog-topmost',
MANAGER_ID_DISPLAY: 'manager-id-display',
OPEN_DIALOG: 'open-dialog',
TEST_COMPONENT: 'test-component',
@@ -21,20 +22,34 @@ const SHARED_MANAGER_ID = 'shared-manager';
const MANAGER_1_ID = 'manager-1';
const MANAGER_2_ID = 'manager-2';
-const TestComponent = ({ dialogId, dialogManagerId, testId }: any) => {
+type TestComponentProps = {
+ dialogId?: string;
+ dialogManagerId?: string;
+ testId?: string;
+};
+
+const TestComponent = ({ dialogId, dialogManagerId, testId }: TestComponentProps) => {
const { dialogManager } = useDialogManager({ dialogId, dialogManagerId });
+ const selectorDialogId = dialogId ?? 'test-dialog';
const openDialogCount = useOpenedDialogCount({ dialogManagerId });
- const isOpen = useDialogIsOpen(dialogId, dialogManagerId);
+ const isOpen = useDialogIsOpen(selectorDialogId, dialogManagerId);
+ const isTopmost = useDialogIsTopmost(selectorDialogId, dialogManagerId);
return (
{dialogManager?.id}
{openDialogCount}
{isOpen ? 'true' : 'false'}
+ {isTopmost ? 'true' : 'false'}
);
};
-const DialogTestComponent = ({ dialogId, managerId }: any) => {
+type DialogTestComponentProps = {
+ dialogId: string;
+ managerId?: string;
+};
+
+const DialogTestComponent = ({ dialogId, managerId }: DialogTestComponentProps) => {
const { dialogManager } = useDialogManager({ dialogManagerId: managerId });
const handleOpenDialog = () => {
@@ -171,6 +186,61 @@ describe('DialogManagerContext', () => {
).toHaveTextContent('true');
});
+ it('updates topmost dialog state when the dialog stack changes', () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ const firstComponent = screen.getByTestId('first-component');
+ const secondComponent = screen.getByTestId('second-component');
+
+ act(() => {
+ fireEvent.click(screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[0]);
+ });
+
+ expect(
+ firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('true');
+ expect(
+ secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('false');
+
+ act(() => {
+ fireEvent.click(screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[1]);
+ });
+
+ expect(
+ firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('false');
+ expect(
+ secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('true');
+
+ act(() => {
+ fireEvent.click(screen.getAllByTestId(TEST_IDS.CLOSE_DIALOG)[1]);
+ });
+
+ expect(
+ firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('true');
+ expect(
+ secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
+ ).toHaveTextContent('false');
+ });
+
it('creates different managers for different IDs', () => {
render(
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.ts b/src/components/Dialog/__tests__/DialogsManager.test.ts
index 3dadd17f6..9a872d284 100644
--- a/src/components/Dialog/__tests__/DialogsManager.test.ts
+++ b/src/components/Dialog/__tests__/DialogsManager.test.ts
@@ -73,6 +73,40 @@ describe('DialogManager', () => {
expect(dialogManager.openDialogCount).toBe(1);
});
+ it('tracks opened dialogs in stack order', () => {
+ const dialogManager = new DialogManager();
+
+ dialogManager.open({ id: 'first-dialog' });
+ dialogManager.open({ id: 'second-dialog' });
+ dialogManager.open({ id: 'third-dialog' });
+
+ expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([
+ 'first-dialog',
+ 'second-dialog',
+ 'third-dialog',
+ ]);
+ expect(dialogManager.openDialogCount).toBe(3);
+
+ dialogManager.close('third-dialog');
+
+ expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([
+ 'first-dialog',
+ 'second-dialog',
+ ]);
+ expect(dialogManager.openDialogCount).toBe(2);
+ });
+
+ it('keeps only the target dialog in the stack when opening with closeRest', () => {
+ const dialogManager = new DialogManager();
+
+ dialogManager.open({ id: 'first-dialog' });
+ dialogManager.open({ id: 'second-dialog' });
+ dialogManager.open({ id: dialogId }, true);
+
+ expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([dialogId]);
+ expect(dialogManager.openDialogCount).toBe(1);
+ });
+
it('opens existing dialog', () => {
const dialogManager = new DialogManager();
dialogManager.getOrCreate({ id: dialogId });
@@ -154,6 +188,17 @@ describe('DialogManager', () => {
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
});
+ it('removes a dialog from the open stack', () => {
+ const dialogManager = new DialogManager();
+
+ dialogManager.open({ id: 'first-dialog' });
+ dialogManager.open({ id: dialogId });
+ dialogManager.remove('first-dialog');
+
+ expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([dialogId]);
+ expect(dialogManager.openDialogCount).toBe(1);
+ });
+
it('handles attempt to remove non-existent dialog', () => {
const dialogManager = new DialogManager();
dialogManager.getOrCreate({ id: dialogId });
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index 75a713af4..5e5ae403e 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -49,8 +49,8 @@ export const useDialogOnNearestManager = ({ id }: Pick) =
export const modalDialogId = 'modal-dialog' as const;
-export const useModalDialog = () =>
- useDialog({ dialogManagerId: modalDialogManagerId, id: modalDialogId });
+export const useModalDialog = (id: string = modalDialogId) =>
+ useDialog({ dialogManagerId: modalDialogManagerId, id });
export const useDialogIsOpen = (id: string, dialogManagerId?: string) => {
const { dialogManager } = useDialogManager({ dialogManagerId });
@@ -61,14 +61,25 @@ export const useDialogIsOpen = (id: string, dialogManagerId?: string) => {
return useStateStore(dialogManager.state, dialogIsOpenSelector).isOpen;
};
-export const useModalDialogIsOpen = () =>
- useDialogIsOpen(modalDialogId, modalDialogManagerId);
+export const useModalDialogIsOpen = (id: string = modalDialogId) =>
+ useDialogIsOpen(id, modalDialogManagerId);
+
+export const useDialogIsTopmost = (id: string, dialogManagerId?: string) => {
+ const { dialogManager } = useDialogManager({ dialogManagerId });
+ const dialogIsTopmostSelector = useCallback(
+ ({ openedDialogIds }: DialogManagerState) => ({
+ isTopmost: openedDialogIds[openedDialogIds.length - 1] === id,
+ }),
+ [id],
+ );
+ return useStateStore(dialogManager.state, dialogIsTopmostSelector).isTopmost;
+};
+
+export const useModalDialogIsTopmost = (id: string = modalDialogId) =>
+ useDialogIsTopmost(id, modalDialogManagerId);
const openedDialogCountSelector = (nextValue: DialogManagerState) => ({
- openedDialogCount: Object.values(nextValue.dialogsById).reduce((count, dialog) => {
- if (dialog.isOpen) return count + 1;
- return count;
- }, 0),
+ openedDialogCount: nextValue.openedDialogIds.length,
});
export const useOpenedDialogCount = ({
diff --git a/src/components/Dialog/service/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts
index 1f11a826c..0d984821d 100644
--- a/src/components/Dialog/service/DialogManager.ts
+++ b/src/components/Dialog/service/DialogManager.ts
@@ -35,6 +35,7 @@ type Dialogs = Record;
export type DialogManagerState = {
dialogsById: Dialogs;
+ openedDialogIds: DialogId[];
};
/**
@@ -53,6 +54,7 @@ export class DialogManager {
id: string;
state = new StateStore({
dialogsById: {},
+ openedDialogIds: [],
});
constructor({ closeOnClickOutside = true, id }: DialogManagerOptions = {}) {
@@ -61,13 +63,7 @@ export class DialogManager {
}
get openDialogCount() {
- return Object.values(this.state.getLatestValue().dialogsById).reduce(
- (count, dialog) => {
- if (dialog.isOpen) return count + 1;
- return count;
- },
- 0,
- );
+ return this.state.getLatestValue().openedDialogIds.length;
}
get(id: DialogId): Dialog | undefined {
@@ -131,6 +127,10 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } },
+ openedDialogIds: [
+ ...current.openedDialogIds.filter((dialogId) => dialogId !== dialog.id),
+ dialog.id,
+ ],
}));
}
@@ -140,6 +140,7 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } },
+ openedDialogIds: current.openedDialogIds.filter((dialogId) => dialogId !== id),
}));
}
@@ -171,6 +172,7 @@ export class DialogManager {
return {
...current,
dialogsById: newDialogs,
+ openedDialogIds: current.openedDialogIds.filter((dialogId) => dialogId !== id),
};
});
};
diff --git a/src/components/Dialog/styling/Alert.scss b/src/components/Dialog/styling/Alert.scss
index 008abaa4c..24ee97269 100644
--- a/src/components/Dialog/styling/Alert.scss
+++ b/src/components/Dialog/styling/Alert.scss
@@ -34,6 +34,7 @@
.str-chat__alert-header__description {
font: var(--str-chat__font-caption-default);
+ margin: 0;
}
}
}
diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx
index 8cea758c1..230999160 100644
--- a/src/components/Message/__tests__/MessageText.test.tsx
+++ b/src/components/Message/__tests__/MessageText.test.tsx
@@ -9,6 +9,7 @@ import {
ChatProvider,
ComponentProvider,
DialogManagerProvider,
+ MessageProvider,
TranslationProvider,
} from '../../../context';
import {
@@ -23,6 +24,7 @@ import {
mockChannelStateContext,
mockChatContext,
mockComponentContext,
+ mockMessageContext,
mockTranslationContextValue,
} from '../../../mock-builders';
@@ -89,6 +91,8 @@ async function renderMessageText({
const channelCapabilities = { 'send-reaction': true, ...channelCapabilitiesOverrides };
const channelConfig = channel['getConfig']();
const customDateTimeParser = vi.fn(() => ({ format: vi.fn() }));
+ const renderMessageTextDirectly =
+ 'customInnerClass' in customProps || 'customWrapperClass' in customProps;
return render(
@@ -119,9 +123,28 @@ async function renderMessageText({
})}
>
-
-
-
+ {renderMessageTextDirectly ? (
+
+
+
+ ) : (
+
+
+
+ )}
@@ -418,26 +441,39 @@ describe('', () => {
it('should render with a custom wrapper class when one is set', async () => {
const customWrapperClass = 'custom-wrapper';
const message = generateMessage({ text: 'hello world' });
- const { container } = await renderMessageText({
- customProps: { customWrapperClass, message },
+ const { getByText } = await renderMessageText({
+ customProps: {
+ customWrapperClass,
+ message,
+ },
});
- expect(container).toMatchSnapshot();
+
+ expect(getByText('hello world').closest(`.${customWrapperClass}`)).toHaveClass(
+ customWrapperClass,
+ );
});
it('should render with a custom inner class when one is set', async () => {
const customInnerClass = 'custom-inner';
const message = generateMessage({ text: 'hi mate' });
- const { container } = await renderMessageText({
- customProps: { customInnerClass, message },
+ const { getByTestId } = await renderMessageText({
+ customProps: {
+ customInnerClass,
+ message,
+ },
});
- expect(container).toMatchSnapshot();
+
+ expect(getByTestId(messageTextTestId)).toHaveClass(customInnerClass);
});
- it('should render with custom theme identifier in generated css classes when theme is set', async () => {
+ it('should render with the default wrapper class when no custom wrapper class is set', async () => {
const message = generateMessage({ text: 'whatup?!' });
- const { container } = await renderMessageText({
- customProps: { message, theme: 'custom' },
+ const { getByText } = await renderMessageText({
+ customProps: { message },
});
- expect(container).toMatchSnapshot();
+
+ expect(getByText('whatup?!').closest('.str-chat__message-text')).toHaveClass(
+ 'str-chat__message-text',
+ );
});
});
diff --git a/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap b/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap
deleted file mode 100644
index 235f1a55c..000000000
--- a/src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap
+++ /dev/null
@@ -1,220 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[` > should render with a custom inner class when one is set 1`] = `
-
-`;
-
-exports[` > should render with a custom wrapper class when one is set 1`] = `
-
-`;
-
-exports[` > should render with custom theme identifier in generated css classes when theme is set 1`] = `
-
-`;
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx
index 658fa109d..9c8c19172 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.tsx
+++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx
@@ -344,11 +344,16 @@ describe('', () => {
});
const dialog = screen.getByRole('alertdialog', { name: 'Delete message' });
- expect(dialog).toHaveAttribute('aria-labelledby', 'modal-dialog-title');
- expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
- expect(
- screen.getByText('Are you sure you want to delete this message?'),
- ).toHaveAttribute('id', 'modal-dialog-description');
+ const labelledBy = dialog.getAttribute('aria-labelledby');
+ const describedBy = dialog.getAttribute('aria-describedby');
+ expect(labelledBy).toBeTruthy();
+ expect(describedBy).toBeTruthy();
+ expect(document.getElementById(labelledBy ?? '')).toHaveTextContent(
+ 'Delete message',
+ );
+ expect(document.getElementById(describedBy ?? '')).toHaveTextContent(
+ 'Are you sure you want to delete this message?',
+ );
});
it('should include Edit in dropdown actions when user has edit capability', async () => {
diff --git a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
index 4088bd157..cae686953 100644
--- a/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
+++ b/src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
@@ -47,8 +47,9 @@ const SIMPLE_ATTACHMENT_SELECTOR_TEST_ID = 'invoke-attachment-selector-button';
const UPLOAD_INPUT_TEST_ID = 'file-input';
const translationContext = fromPartial({
- t: (v: any) => v,
- tDateTimeParser: (v: any) => v.toString(),
+ t: ((value: string) => value) as TranslationContextValue['t'],
+ tDateTimeParser: ((value: string) =>
+ value.toString()) as TranslationContextValue['tDateTimeParser'],
});
const defaultChannelData = {
@@ -488,13 +489,14 @@ describe('AttachmentSelector', () => {
});
const dialog = screen.getByRole('dialog', { name: 'Create poll' });
- expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
- expect(document.getElementById('modal-dialog-description')).toHaveTextContent(
+ const descriptionId = dialog.getAttribute('aria-describedby');
+ expect(descriptionId).toBeTruthy();
+ expect(document.getElementById(descriptionId ?? '')).toHaveTextContent(
'Create a question, add options, and configure poll settings',
);
expect(screen.getByPlaceholderText(/Ask a question/i)).toHaveAttribute(
'aria-describedby',
- expect.stringContaining('modal-dialog-description'),
+ expect.stringContaining(descriptionId ?? ''),
);
const invokeButtonFocusSpy = vi.spyOn(invokeButton, 'focus');
@@ -528,17 +530,15 @@ describe('AttachmentSelector', () => {
});
const dialog = screen.getByRole('dialog', { name: /share location/i });
- expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
- expect(document.getElementById('modal-dialog-description')).toHaveTextContent(
+ const descriptionId = dialog.getAttribute('aria-describedby');
+ expect(descriptionId).toBeTruthy();
+ expect(document.getElementById(descriptionId ?? '')).toHaveTextContent(
'Select your current location and optionally enable live location sharing',
);
const closePromptButton = document.querySelector(
'.str-chat__prompt__header__close-button',
) as HTMLButtonElement | null;
- expect(closePromptButton).toHaveAttribute(
- 'aria-describedby',
- 'modal-dialog-description',
- );
+ expect(closePromptButton).toHaveAttribute('aria-describedby', descriptionId);
const invokeButtonFocusSpy = vi.spyOn(invokeButton, 'focus');
fireEvent.keyDown(dialog, { key: 'Escape' });
diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx
index 2f5123212..df5af0f33 100644
--- a/src/components/Modal/GlobalModal.tsx
+++ b/src/components/Modal/GlobalModal.tsx
@@ -22,8 +22,10 @@ import {
modalDialogId,
useModalDialog,
useModalDialogIsOpen,
+ useModalDialogIsTopmost,
} from '../Dialog';
import { useResolvedModalAriaProps } from '../../a11y/hooks/useResolvedModalAriaProps';
+import { useStableId } from '../UtilityComponents/useStableId';
export type ModalCloseEvent =
| KeyboardEvent
@@ -37,6 +39,8 @@ export type ModalProps = {
open: boolean;
/** Custom class to be applied to the modal root div */
className?: string;
+ /** Optional stable id for this modal instance. Generated automatically when omitted. */
+ dialogId?: string;
/** Accessible label for the modal dialog. Ignored when aria-labelledby is provided. */
'aria-label'?: string;
/** ID of the element that labels the modal dialog. */
@@ -60,13 +64,17 @@ export const GlobalModal = ({
children,
className,
CloseButtonOnOverlay,
+ dialogId,
onClose,
onCloseAttempt,
open,
role = 'dialog',
}: PropsWithChildren) => {
- const dialog = useModalDialog();
- const isOpen = useModalDialogIsOpen();
+ const generatedDialogId = useStableId();
+ const resolvedDialogId = dialogId ?? `${modalDialogId}-${generatedDialogId}`;
+ const dialog = useModalDialog(resolvedDialogId);
+ const isOpen = useModalDialogIsOpen(resolvedDialogId);
+ const isTopmost = useModalDialogIsTopmost(resolvedDialogId);
const overlayRef = useRef(null);
const closeButtonRef = useRef(null);
const closingRef = useRef(false);
@@ -101,6 +109,7 @@ export const GlobalModal = ({
);
const handleOverlayClick = (event: React.MouseEvent) => {
+ if (!isTopmost) return;
const target = event.target as HTMLDivElement;
if (overlayRef.current === target) {
maybeClose('overlay', event);
@@ -108,11 +117,12 @@ export const GlobalModal = ({
};
const handleCloseButtonClick = (event: React.MouseEvent) => {
+ if (!isTopmost) return;
maybeClose('button', event);
};
const handleDialogKeyDown = (event: React.KeyboardEvent) => {
- if (event.defaultPrevented || event.key !== 'Escape') return;
+ if (event.defaultPrevented || event.key !== 'Escape' || !isTopmost) return;
maybeClose('escape', event);
};
@@ -130,7 +140,7 @@ export const GlobalModal = ({
if (!open || !isOpen) return null;
return (
-
+
-
+
= () => null;
const renderComponent = ({
components,
@@ -27,7 +28,7 @@ const renderComponent = ({
value={{ NotificationList: NoopNotificationList, ...components }}
>
-
+
,
@@ -47,6 +48,33 @@ const ModalContent = ({
);
+const renderStackedModals = ({
+ childOnClose = vi.fn(),
+ parentOnClose = vi.fn(),
+}: {
+ childOnClose?: () => void;
+ parentOnClose?: () => void;
+} = {}) =>
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
const OverlayCloseButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'>
@@ -298,15 +326,16 @@ describe('GlobalModal', () => {
props: {
children: (
- Autogenerated title target
+ Autogenerated title target
),
+ dialogId: 'custom-modal',
open: true,
},
});
const dialog = screen.getByRole('dialog', { name: 'Autogenerated title target' });
- expect(dialog).toHaveAttribute('aria-labelledby', 'modal-dialog-title');
+ expect(dialog).toHaveAttribute('aria-labelledby', 'custom-modal-title');
expect(dialog).not.toHaveAttribute('aria-label');
});
@@ -315,17 +344,18 @@ describe('GlobalModal', () => {
props: {
children: (
- Create poll
- Create poll description
+ Create poll
+ Create poll description
),
+ dialogId: 'custom-modal',
open: true,
},
});
const dialog = screen.getByRole('dialog', { name: 'Create poll' });
- expect(dialog).toHaveAttribute('aria-labelledby', 'modal-dialog-title');
- expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
+ expect(dialog).toHaveAttribute('aria-labelledby', 'custom-modal-title');
+ expect(dialog).toHaveAttribute('aria-describedby', 'custom-modal-description');
});
it('autowires aria-describedby for alertdialog when not explicitly provided', () => {
@@ -333,18 +363,54 @@ describe('GlobalModal', () => {
props: {
children: (
- Delete message
- Are you sure?
+ Delete message
+ Are you sure?
),
+ dialogId: 'delete-message-alert',
open: true,
role: 'alertdialog',
},
});
const dialog = screen.getByRole('alertdialog', { name: 'Delete message' });
- expect(dialog).toHaveAttribute('aria-labelledby', 'modal-dialog-title');
- expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
+ expect(dialog).toHaveAttribute('aria-labelledby', 'delete-message-alert-title');
+ expect(dialog).toHaveAttribute(
+ 'aria-describedby',
+ 'delete-message-alert-description',
+ );
+ });
+
+ it('supports rendering an alertdialog above an existing modal', () => {
+ renderStackedModals();
+
+ const parentModal = screen.getByRole('dialog', { name: 'Parent modal' });
+ const childModal = screen.getByRole('alertdialog', { name: 'Child modal' });
+
+ expect(parentModal).toBeInTheDocument();
+ expect(parentModal).not.toHaveAttribute('aria-modal');
+ expect(parentModal).toHaveAttribute('inert');
+ expect(childModal).toBeInTheDocument();
+ expect(childModal).toHaveAttribute('aria-modal', 'true');
+ expect(childModal).not.toHaveAttribute('inert');
+ });
+
+ it('only closes the topmost modal on Escape', () => {
+ const childOnClose = vi.fn();
+ const parentOnClose = vi.fn();
+
+ renderStackedModals({ childOnClose, parentOnClose });
+
+ fireEvent.keyDown(screen.getByRole('dialog', { name: 'Parent modal' }), {
+ key: 'Escape',
+ });
+ expect(parentOnClose).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(screen.getByRole('alertdialog', { name: 'Child modal' }), {
+ key: 'Escape',
+ });
+ expect(childOnClose).toHaveBeenCalledTimes(1);
+ expect(parentOnClose).not.toHaveBeenCalled();
});
it('forwards alertdialog role when explicitly provided', () => {