Skip to content

Commit 50fa677

Browse files
fix(react): Inline overlays can be conditionally rendered
Co-authored-by: liamdebeasi <liamdebeasi@users.noreply.github.com>
1 parent bbd6c9a commit 50fa677

File tree

9 files changed

+371
-24
lines changed

9 files changed

+371
-24
lines changed

packages/react/src/components/createInlineOverlayComponent.tsx

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OverlayEventDetail } from '@ionic/core/components'
1+
import { OverlayEventDetail } from '@ionic/core/components';
22
import React, { createElement } from 'react';
33

44
import {
@@ -12,18 +12,21 @@ import { createForwardRef } from './utils';
1212

1313
type InlineOverlayState = {
1414
isOpen: boolean;
15-
}
15+
};
1616

1717
interface IonicReactInternalProps<ElementType> extends React.HTMLAttributes<ElementType> {
1818
forwardedRef?: React.ForwardedRef<ElementType>;
1919
ref?: React.Ref<any>;
20+
key?: string;
2021
onDidDismiss?: (event: CustomEvent<OverlayEventDetail>) => void;
2122
onDidPresent?: (event: CustomEvent<OverlayEventDetail>) => void;
2223
onWillDismiss?: (event: CustomEvent<OverlayEventDetail>) => void;
2324
onWillPresent?: (event: CustomEvent<OverlayEventDetail>) => void;
2425
keepContentsMounted?: boolean;
2526
}
2627

28+
let overlayId = 0;
29+
2730
export const createInlineOverlayComponent = <PropType, ElementType>(
2831
tagName: string,
2932
defineCustomElement?: () => void
@@ -32,17 +35,23 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
3235
defineCustomElement();
3336
}
3437
const displayName = dashToPascalCase(tagName);
35-
const ReactComponent = class extends React.Component<IonicReactInternalProps<PropType>, InlineOverlayState> {
38+
const ReactComponent = class extends React.Component<
39+
IonicReactInternalProps<PropType>,
40+
InlineOverlayState
41+
> {
3642
ref: React.RefObject<HTMLElement>;
3743
wrapperRef: React.RefObject<HTMLElement>;
38-
stableMergedRefs: React.RefCallback<HTMLElement>
44+
stableMergedRefs: React.RefCallback<HTMLElement>;
45+
overlayEndRef: React.RefObject<HTMLTemplateElement> = React.createRef();
46+
47+
trackByKey = `${++overlayId}`;
3948

4049
constructor(props: IonicReactInternalProps<PropType>) {
4150
super(props);
4251
// Create a local ref to to attach props to the wrapped element.
4352
this.ref = React.createRef();
4453
// React refs must be stable (not created inline).
45-
this.stableMergedRefs = mergeRefs(this.ref, this.props.forwardedRef)
54+
this.stableMergedRefs = mergeRefs(this.ref, this.props.forwardedRef);
4655
// Component is hidden by default
4756
this.state = { isOpen: false };
4857
// Create a local ref to the inner child element.
@@ -102,8 +111,23 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
102111
attachProps(node, this.props, prevProps);
103112
}
104113

114+
componentWillUnmount() {
115+
const BaseComponent = this.ref.current;
116+
const Reference = this.overlayEndRef.current;
117+
118+
if (BaseComponent && Reference) {
119+
/**
120+
* Inserts the overlay component back into the original
121+
* location in the DOM. This is necessary so that React
122+
* unmounts the component properly.
123+
*/
124+
Reference.parentNode?.insertBefore(BaseComponent, Reference);
125+
}
126+
}
127+
105128
render() {
106129
const { children, forwardedRef, style, className, ref, ...cProps } = this.props;
130+
const { trackByKey } = this;
107131

108132
const propsToPass = Object.keys(cProps).reduce((acc, name) => {
109133
if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
@@ -121,26 +145,48 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
121145
...propsToPass,
122146
ref: this.stableMergedRefs,
123147
style,
148+
key: trackByKey,
124149
};
125150

126-
/**
127-
* We only want the inner component
128-
* to be mounted if the overlay is open,
129-
* so conditionally render the component
130-
* based on the isOpen state.
131-
*/
132-
return createElement(tagName, newProps, (this.state.isOpen || this.props.keepContentsMounted) ?
133-
createElement('div', {
134-
id: 'ion-react-wrapper',
135-
ref: this.wrapperRef,
136-
style: {
137-
display: 'flex',
138-
flexDirection: 'column',
139-
height: '100%'
140-
}
141-
}, children) :
142-
null
143-
);
151+
return [
152+
/**
153+
* React will unmount the overlay component when conditional content is
154+
* rendered before or after the overlay in the DOM. To work around this
155+
* we create a buffer element before and after the overlay component,
156+
* so that even if React unmounts those elements, the overlay will still
157+
* be in the correct position in the DOM.
158+
*/
159+
createElement('template', { key: `overlay-start-${trackByKey}` }),
160+
createElement(
161+
tagName,
162+
newProps,
163+
/**
164+
* We only want the inner component
165+
* to be mounted if the overlay is open,
166+
* so conditionally render the component
167+
* based on the isOpen state.
168+
*/
169+
this.state.isOpen || this.props.keepContentsMounted
170+
? createElement(
171+
'div',
172+
{
173+
id: 'ion-react-wrapper',
174+
ref: this.wrapperRef,
175+
style: {
176+
display: 'flex',
177+
flexDirection: 'column',
178+
height: '100%',
179+
},
180+
},
181+
children
182+
)
183+
: null
184+
),
185+
createElement('template', {
186+
key: `overlay-end-${trackByKey}`,
187+
ref: this.overlayEndRef,
188+
}),
189+
];
144190
}
145191

146192
static get displayName() {

packages/react/test-app/src/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import OverlayComponents from './pages/overlay-components/OverlayComponents';
2727
import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted';
2828
import Tabs from './pages/Tabs';
2929
import NavComponent from './pages/navigation/NavComponent';
30+
import IonModalConditionalSibling from './pages/issues/IonModalConditionalSibling';
31+
import IonModalConditional from './pages/issues/IonModalConditional';
32+
import IonModalDatetimeButton from './pages/issues/IonModalDatetimeButton';
33+
import IonPopoverNested from './pages/overlay-components/IonPopoverNested';
3034

3135
setupIonicReact();
3236

@@ -37,6 +41,16 @@ const App: React.FC = () => (
3741
<Route path="/" component={Main} />
3842
<Route path="/overlay-hooks" component={OverlayHooks} />
3943
<Route path="/overlay-components" component={OverlayComponents} />
44+
<Route path="/overlay-components/nested-popover" component={IonPopoverNested} />
45+
<Route
46+
path="/overlay-components/modal-conditional-sibling"
47+
component={IonModalConditionalSibling}
48+
/>
49+
<Route path="/overlay-components/modal-conditional" component={IonModalConditional} />
50+
<Route
51+
path="/overlay-components/modal-datetime-button"
52+
component={IonModalDatetimeButton}
53+
/>
4054
<Route path="/keep-contents-mounted" component={KeepContentsMounted} />
4155
<Route path="/navigation" component={NavComponent} />
4256
<Route path="/tabs" component={Tabs} />
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { IonButton, IonContent, IonModal } from '@ionic/react';
2+
import { useRef } from 'react';
3+
import { useState } from 'react';
4+
5+
/**
6+
* Issue: https://github.com/ionic-team/ionic-framework/issues/25590
7+
*
8+
* Exception is thrown when IonModal is conditionally rendered inline.
9+
*/
10+
const IonModalConditional = () => {
11+
const [showIonModal, setShowIonModal] = useState(false);
12+
const [isOpen, setIsOpen] = useState(true);
13+
14+
const modal = useRef<HTMLIonModalElement>(null);
15+
16+
return (
17+
<IonContent>
18+
<IonButton
19+
id="renderModalBtn"
20+
onClick={() => {
21+
setShowIonModal(true);
22+
setIsOpen(true);
23+
}}
24+
>
25+
Render Modal
26+
</IonButton>
27+
{showIonModal && (
28+
<IonModal
29+
ref={modal}
30+
isOpen={isOpen}
31+
onDidDismiss={() => {
32+
setIsOpen(false);
33+
setShowIonModal(false);
34+
}}
35+
>
36+
<IonContent>
37+
Modal Content
38+
<IonButton id="dismissModalBtn" onClick={() => modal.current!.dismiss()}>
39+
Close
40+
</IonButton>
41+
</IonContent>
42+
</IonModal>
43+
)}
44+
</IonContent>
45+
);
46+
};
47+
48+
export default IonModalConditional;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { IonButton, IonCard, IonContent, IonModal } from '@ionic/react';
2+
import { useRef } from 'react';
3+
import { useState } from 'react';
4+
5+
/**
6+
* Issue: https://github.com/ionic-team/ionic-framework/issues/25590
7+
*
8+
* Exception is thrown when adding/removing nodes that are siblings of IonModal,
9+
* while the modal is being dismissed.
10+
*/
11+
const IonModalConditionalSibling = () => {
12+
const [items, setItems] = useState<string[]>(['Item 1']);
13+
const [isOpen, setIsOpen] = useState(true);
14+
15+
const modal = useRef<HTMLIonModalElement>(null);
16+
17+
return (
18+
<IonContent>
19+
{items && items.map((item) => <IonCard key={item}>Before {item}</IonCard>)}
20+
<IonModal
21+
ref={modal}
22+
isOpen={isOpen}
23+
onWillDismiss={() => {
24+
setItems([...items, `Item ${items.length + 1}`]);
25+
}}
26+
onDidDismiss={() => setIsOpen(false)}
27+
>
28+
<IonContent>
29+
Modal Content
30+
<IonButton onClick={() => modal.current!.dismiss()}>Close</IonButton>
31+
</IonContent>
32+
</IonModal>
33+
{items && items.map((item) => <IonCard key={item}>After {item}</IonCard>)}
34+
</IonContent>
35+
);
36+
};
37+
38+
export default IonModalConditionalSibling;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { IonButton, IonContent, IonDatetime, IonDatetimeButton, IonModal } from '@ionic/react';
2+
import { useRef } from 'react';
3+
import { useState } from 'react';
4+
5+
const IonModalDatetimeButton = () => {
6+
const [showIonModal, setShowIonModal] = useState(false);
7+
const [isOpen, setIsOpen] = useState(true);
8+
9+
const modal = useRef<HTMLIonModalElement>(null);
10+
11+
return (
12+
<IonContent>
13+
<IonButton
14+
id="renderModalBtn"
15+
onClick={() => {
16+
setShowIonModal(true);
17+
setIsOpen(true);
18+
}}
19+
>
20+
Render Modal
21+
</IonButton>
22+
{showIonModal && (
23+
<IonModal
24+
ref={modal}
25+
isOpen={isOpen}
26+
onDidDismiss={() => {
27+
setIsOpen(false);
28+
setShowIonModal(false);
29+
}}
30+
>
31+
<IonContent>
32+
Modal Content
33+
<IonDatetimeButton datetime="startDate" />
34+
<IonModal id="datetimeModal" keepContentsMounted={true}>
35+
<IonDatetime
36+
id="startDate"
37+
preferWheel
38+
presentation="date"
39+
name="startDate"
40+
showDefaultButtons
41+
color="primary"
42+
/>
43+
</IonModal>
44+
</IonContent>
45+
</IonModal>
46+
)}
47+
</IonContent>
48+
);
49+
};
50+
51+
export default IonModalDatetimeButton;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
IonButton,
3+
IonContent,
4+
IonIcon,
5+
IonItem,
6+
IonList,
7+
IonListHeader,
8+
IonPage,
9+
IonPopover,
10+
IonHeader,
11+
IonTitle,
12+
IonToolbar,
13+
} from '@ionic/react';
14+
import { arrowForward } from 'ionicons/icons';
15+
import { useRef } from 'react';
16+
17+
const IonPopoverNested = () => {
18+
const menuPopover = useRef<HTMLIonPopoverElement>(null);
19+
const submenuPopover = useRef<HTMLIonPopoverElement>(null);
20+
21+
return (
22+
<IonPage>
23+
<IonHeader>
24+
<IonToolbar>
25+
<IonTitle>Nested Popover</IonTitle>
26+
</IonToolbar>
27+
</IonHeader>
28+
<IonContent className="ion-padding">
29+
<IonButton id="open">Show Popover</IonButton>
30+
<IonPopover ref={menuPopover} id="menu-popover" trigger="open">
31+
<IonList>
32+
<IonListHeader>Menu Items</IonListHeader>
33+
<IonItem>Item 1</IonItem>
34+
<IonItem>Item 2</IonItem>
35+
<IonItem>Item 3</IonItem>
36+
<IonItem button id="item-4">
37+
More
38+
<IonIcon icon={arrowForward} slot="end" />
39+
</IonItem>
40+
<IonItem button id="close-menu-popover" onClick={() => menuPopover.current!.dismiss()}>
41+
Close
42+
</IonItem>
43+
</IonList>
44+
<IonPopover ref={submenuPopover} id="submenu-popover" trigger="item-4" side="right">
45+
<IonList>
46+
<IonListHeader>Submenu Items</IonListHeader>
47+
<IonItem>Item 1</IonItem>
48+
<IonItem>Item 2</IonItem>
49+
<IonItem>Item 3</IonItem>
50+
<IonItem
51+
id="close-submenu-popover"
52+
button
53+
onClick={() => submenuPopover.current!.dismiss()}
54+
>
55+
Close
56+
</IonItem>
57+
</IonList>
58+
</IonPopover>
59+
</IonPopover>
60+
</IonContent>
61+
</IonPage>
62+
);
63+
};
64+
65+
export default IonPopoverNested;

packages/react/test-app/src/pages/overlay-components/KeepContentsMounted.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const KeepContentsMounted: React.FC = () => {
1717
<IonButton id="open-modal" onClick={() => setShowModal(true)}>Open Modal</IonButton>
1818
<IonButton id="open-popover" onClick={() => setShowPopover(true)}>Open Popover</IonButton>
1919

20-
<IonModal keepContentsMounted={true} id="default-modal" isOpen={showModal} onDidDismiss={() => setShowPopover(false)}>
20+
<IonModal keepContentsMounted={true} id="default-modal" isOpen={showModal} onDidDismiss={() => setShowModal(false)}>
2121
<IonContent>
2222
<IonButton onClick={() => setShowModal(false)}>Dismiss</IonButton>
2323
Modal Content

0 commit comments

Comments
 (0)