Skip to content

Commit 5dc657f

Browse files
ECHOES-891 Make toasts & modals compatible (#614)
1 parent e611e55 commit 5dc657f

File tree

11 files changed

+201
-73
lines changed

11 files changed

+201
-73
lines changed

src/common/components/Toast.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@ const toastGlobalStyles = css`
269269
[data-sonner-toast] {
270270
border-radius: ${cssVar('border-radius-400')};
271271
272+
/* This is important to allow toasts to be interacted with when a Modal is open */
273+
pointer-events: auto;
274+
272275
&:focus-visible {
273276
outline: ${cssVar('color-focus-default')} solid ${cssVar('focus-border-width-default')};
274277
outline-offset: ${cssVar('focus-border-offset-default')};

src/components/echoes-provider/EchoesProvider.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useIntl } from 'react-intl';
2424
import { Toaster as ToastContainer } from 'sonner';
2525
import { ToastGlobalStyles } from '~common/components/Toast';
2626
import { TooltipProvider, TooltipProviderProps, TypographyGlobalStyles } from '..';
27+
import { ModalPortal } from '../modals/ModalPortal';
2728
import { SelectGlobalStyles } from '../select/SelectCommons';
2829

2930
export interface EchoesProviderProps {
@@ -96,7 +97,9 @@ export function EchoesProvider(props: PropsWithChildren<EchoesProviderProps>) {
9697
<SelectGlobalStyles />
9798
<ToastGlobalStyles />
9899
<TooltipProvider delayDuration={tooltipsDelayDuration}>
99-
<HeadlessMantineProvider>{children}</HeadlessMantineProvider>
100+
<ModalPortal>
101+
<HeadlessMantineProvider>{children}</HeadlessMantineProvider>
102+
</ModalPortal>
100103
<ToastContainer
101104
containerAriaLabel={intl.formatMessage({
102105
id: 'toasts.keyboard_shortcut_aria_label',

src/components/icons/__tests__/__snapshots__/IconWrapper-test.tsx.snap

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ exports[`should render material icon correctly 1`] = `
1717
}
1818
1919
<div>
20-
<span
21-
aria-hidden="true"
22-
class="emotion-0 emotion-1"
23-
data-testid="icon"
24-
>
25-
26-
</span>
20+
<div>
21+
<span
22+
aria-hidden="true"
23+
class="emotion-0 emotion-1"
24+
data-testid="icon"
25+
>
26+
27+
</span>
28+
</div>
2729
<section
2830
aria-atomic="false"
2931
aria-label="Focus toasts messages with alt+T"

src/components/layout/__tests__/Layout-test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ beforeEach(() => {
4343
it('should render correctly', () => {
4444
const { container } = render(<Layout>content</Layout>);
4545

46-
expect(container.childNodes[0]).toHaveStyle({ height: '100vh', width: '100vw' });
46+
expect(container.childNodes[0].childNodes[0]).toHaveStyle({ height: '100vh', width: '100vw' });
4747
expect(screen.getByText('content')).toHaveStyle({ display: 'grid' });
4848
});
4949

src/components/modals/Modal.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ButtonGroup, ButtonIcon, ButtonSize, ButtonVariety } from '../buttons';
2828
import { isDropdownMenuItemComponent } from '../dropdown-menu/DropdownMenuItemBase';
2929
import { IconX } from '../icons';
3030
import { ModalBody } from './ModalBody';
31+
import { useModalPortalRef } from './ModalPortal';
3132
import {
3233
ModalContent,
3334
ModalFooter,
@@ -68,6 +69,8 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
6869

6970
const intl = useIntl();
7071

72+
const modalPortalRef = useModalPortalRef();
73+
7174
const hasActionButtons = isDefined(primaryButton) || isDefined(secondaryButton);
7275
const hasFooter = hasActionButtons || isDefined(footerLink);
7376
const isControlled = isDefined(isOpen) && isDefined(onOpenChange);
@@ -86,17 +89,29 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
8689
[onClose, onOpenChange],
8790
);
8891

92+
/**
93+
* This allows Modals to stay open when the user interacts with toast messages
94+
*/
95+
const clickHandler = useCallback((e: CustomEvent) => {
96+
const isToastItem = (e.target as Element)?.closest('[data-sonner-toaster]');
97+
if (isToastItem) {
98+
e.preventDefault();
99+
}
100+
}, []);
101+
89102
return (
90103
<RadixDialog.Root defaultOpen={isDefaultOpen} onOpenChange={handleOpenChange} open={isOpen}>
91104
<RadixDialog.Trigger
92105
asChild
93106
{...(isDropdownMenuItemComponent(children) && { onSelect: handleSelectForDropdownMenu })}>
94107
{children}
95108
</RadixDialog.Trigger>
96-
<RadixDialog.Portal>
109+
<RadixDialog.Portal container={modalPortalRef}>
97110
<ModalOverlay />
98111
<ModalWrapper
99112
{...(!isDefined(description) && { 'aria-describedby': undefined })}
113+
onInteractOutside={clickHandler}
114+
onPointerDownOutside={clickHandler}
100115
ref={ref}
101116
size={size}
102117
{...radixProps}>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import { createContext, PropsWithChildren, useContext, useMemo, useState } from 'react';
22+
23+
interface ModalPortalContextValue {
24+
portalRef: HTMLDivElement | null;
25+
}
26+
27+
const ModalPortalRefContext = createContext<ModalPortalContextValue | null>(null);
28+
29+
/**
30+
* This component:
31+
* - creates a node that modals can attach their portal to
32+
* - provides the context containing the node's ref
33+
*
34+
* Modals can use the custom hook `useModalPortalRef` to consume it
35+
*/
36+
export function ModalPortal({ children }: Readonly<PropsWithChildren>) {
37+
const [portalNode, setPortalNode] = useState<HTMLDivElement | null>(null);
38+
39+
const value = useMemo(() => ({ portalRef: portalNode }), [portalNode]);
40+
41+
return (
42+
<div ref={setPortalNode}>
43+
<ModalPortalRefContext.Provider value={value}>{children}</ModalPortalRefContext.Provider>
44+
</div>
45+
);
46+
}
47+
48+
/**
49+
* Returns the node ref created and provided by `ModalPortal`
50+
*/
51+
export function useModalPortalRef() {
52+
const context = useContext(ModalPortalRefContext);
53+
return context?.portalRef;
54+
}

src/components/modals/__tests__/Modal-test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ it('should be triggered by DropdownMenu Items', async () => {
108108
});
109109

110110
it("shouldn't have any a11y violation", async () => {
111-
const { container } = renderModal({ isDefaultOpen: true });
111+
const { container } = renderModal({ isDefaultOpen: true, title: 'title' });
112112

113113
await expect(container).toHaveNoA11yViolations();
114114
});

src/components/typography/__tests__/__snapshots__/Heading-test.tsx.snap

Lines changed: 72 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ exports[`renders a Heading as an h1 with no bottom margin 1`] = `
1010
}
1111
1212
<div>
13-
<h1
14-
class="emotion-0 emotion-1"
15-
>
16-
This is a heading rendered as "h1" with no bottom margin
17-
</h1>
13+
<div>
14+
<h1
15+
class="emotion-0 emotion-1"
16+
>
17+
This is a heading rendered as "h1" with no bottom margin
18+
</h1>
19+
</div>
1820
<section
1921
aria-atomic="false"
2022
aria-label="Focus toasts messages with alt+T"
@@ -35,11 +37,13 @@ exports[`renders a Heading as an h2 with no bottom margin 1`] = `
3537
}
3638
3739
<div>
38-
<h2
39-
class="emotion-0 emotion-1"
40-
>
41-
This is a heading rendered as "h2" with no bottom margin
42-
</h2>
40+
<div>
41+
<h2
42+
class="emotion-0 emotion-1"
43+
>
44+
This is a heading rendered as "h2" with no bottom margin
45+
</h2>
46+
</div>
4347
<section
4448
aria-atomic="false"
4549
aria-label="Focus toasts messages with alt+T"
@@ -60,11 +64,13 @@ exports[`renders a Heading as an h3 with no bottom margin 1`] = `
6064
}
6165
6266
<div>
63-
<h3
64-
class="emotion-0 emotion-1"
65-
>
66-
This is a heading rendered as "h3" with no bottom margin
67-
</h3>
67+
<div>
68+
<h3
69+
class="emotion-0 emotion-1"
70+
>
71+
This is a heading rendered as "h3" with no bottom margin
72+
</h3>
73+
</div>
6874
<section
6975
aria-atomic="false"
7076
aria-label="Focus toasts messages with alt+T"
@@ -85,11 +91,13 @@ exports[`renders a Heading as an h4 with no bottom margin 1`] = `
8591
}
8692
8793
<div>
88-
<h4
89-
class="emotion-0 emotion-1"
90-
>
91-
This is a heading rendered as "h4" with no bottom margin
92-
</h4>
94+
<div>
95+
<h4
96+
class="emotion-0 emotion-1"
97+
>
98+
This is a heading rendered as "h4" with no bottom margin
99+
</h4>
100+
</div>
93101
<section
94102
aria-atomic="false"
95103
aria-label="Focus toasts messages with alt+T"
@@ -110,11 +118,13 @@ exports[`renders a Heading as an h5 with no bottom margin 1`] = `
110118
}
111119
112120
<div>
113-
<h5
114-
class="emotion-0 emotion-1"
115-
>
116-
This is a heading rendered as "h5" with no bottom margin
117-
</h5>
121+
<div>
122+
<h5
123+
class="emotion-0 emotion-1"
124+
>
125+
This is a heading rendered as "h5" with no bottom margin
126+
</h5>
127+
</div>
118128
<section
119129
aria-atomic="false"
120130
aria-label="Focus toasts messages with alt+T"
@@ -136,10 +146,12 @@ exports[`renders a Heading with size large and a bottom margin 1`] = `
136146
}
137147
138148
<div>
139-
<div
140-
class="emotion-0 emotion-1"
141-
>
142-
This is a heading with size "large" with a bottom margin
149+
<div>
150+
<div
151+
class="emotion-0 emotion-1"
152+
>
153+
This is a heading with size "large" with a bottom margin
154+
</div>
143155
</div>
144156
<section
145157
aria-atomic="false"
@@ -162,10 +174,12 @@ exports[`renders a Heading with size medium and a bottom margin 1`] = `
162174
}
163175
164176
<div>
165-
<div
166-
class="emotion-0 emotion-1"
167-
>
168-
This is a heading with size "medium" with a bottom margin
177+
<div>
178+
<div
179+
class="emotion-0 emotion-1"
180+
>
181+
This is a heading with size "medium" with a bottom margin
182+
</div>
169183
</div>
170184
<section
171185
aria-atomic="false"
@@ -188,10 +202,12 @@ exports[`renders a Heading with size small and a bottom margin 1`] = `
188202
}
189203
190204
<div>
191-
<div
192-
class="emotion-0 emotion-1"
193-
>
194-
This is a heading with size "small" with a bottom margin
205+
<div>
206+
<div
207+
class="emotion-0 emotion-1"
208+
>
209+
This is a heading with size "small" with a bottom margin
210+
</div>
195211
</div>
196212
<section
197213
aria-atomic="false"
@@ -214,10 +230,12 @@ exports[`renders a Heading with size xlarge and a bottom margin 1`] = `
214230
}
215231
216232
<div>
217-
<div
218-
class="emotion-0 emotion-1"
219-
>
220-
This is a heading with size "xlarge" with a bottom margin
233+
<div>
234+
<div
235+
class="emotion-0 emotion-1"
236+
>
237+
This is a heading with size "xlarge" with a bottom margin
238+
</div>
221239
</div>
222240
<section
223241
aria-atomic="false"
@@ -240,10 +258,12 @@ exports[`renders a Heading with size xsmall and a bottom margin 1`] = `
240258
}
241259
242260
<div>
243-
<div
244-
class="emotion-0 emotion-1"
245-
>
246-
This is a heading with size "xsmall" with a bottom margin
261+
<div>
262+
<div
263+
class="emotion-0 emotion-1"
264+
>
265+
This is a heading with size "xsmall" with a bottom margin
266+
</div>
247267
</div>
248268
<section
249269
aria-atomic="false"
@@ -265,11 +285,13 @@ exports[`uses the default size 1`] = `
265285
}
266286
267287
<div>
268-
<h1
269-
class="emotion-0 emotion-1"
270-
>
271-
Text goes here
272-
</h1>
288+
<div>
289+
<h1
290+
class="emotion-0 emotion-1"
291+
>
292+
Text goes here
293+
</h1>
294+
</div>
273295
<section
274296
aria-atomic="false"
275297
aria-label="Focus toasts messages with alt+T"

src/components/typography/__tests__/__snapshots__/HelperText-test.tsx.snap

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ exports[`renders correctly 1`] = `
88
}
99
1010
<div>
11-
<div
12-
class="emotion-0 emotion-1"
13-
>
14-
The helper text
11+
<div>
12+
<div
13+
class="emotion-0 emotion-1"
14+
>
15+
The helper text
16+
</div>
1517
</div>
1618
<section
1719
aria-atomic="false"

0 commit comments

Comments
 (0)