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
120 changes: 104 additions & 16 deletions packages/react/src/components/createInlineOverlayComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { HTMLIonOverlayElement, OverlayEventDetail } from '@ionic/core/components';
import { componentOnReady } from '@ionic/core/components';
import React, { createElement } from 'react';
import { createPortal } from 'react-dom';

import {
attachProps,
Expand All @@ -17,6 +19,15 @@ type InlineOverlayState = {
isOpen: boolean;
};

/**
* Set to `true` when rendering inside another inline overlay. Nested
* overlays render at their JSX position (no portal) so that core's
* `el.closest('ion-popover')`-style nesting detection keeps working,
* and the outer overlay's portal already gives the subtree the correct
* React event-delegation root.
*/
const NestedOverlayContext = React.createContext(false);

interface IonicReactInternalProps<ElementType> extends React.HTMLAttributes<ElementType> {
forwardedRef?: React.ForwardedRef<ElementType>;
ref?: React.Ref<any>;
Expand All @@ -36,12 +47,18 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
defineCustomElement();
}
const displayName = dashToPascalCase(tagName);
const ReactComponent = class extends React.Component<IonicReactInternalProps<PropType>, InlineOverlayState> {

type InternalProps = IonicReactInternalProps<PropType> & { isNested?: boolean };

const ReactComponent = class extends React.Component<InternalProps, InlineOverlayState> {
ref: React.RefObject<HTMLIonOverlayElement>;
wrapperRef: React.RefObject<HTMLElement>;
markerRef: React.RefObject<HTMLTemplateElement>;
stableMergedRefs: React.RefCallback<HTMLElement>;
portalTarget: HTMLElement | null;
isUnmounted = false;

constructor(props: IonicReactInternalProps<PropType>) {
constructor(props: InternalProps) {
super(props);
// Create a local ref to to attach props to the wrapped element.
this.ref = React.createRef();
Expand All @@ -51,29 +68,64 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
this.state = { isOpen: false };
// Create a local ref to the inner child element.
this.wrapperRef = React.createRef();
// Marker stays at the JSX location so we can recover the immediate
// JSX parent after the overlay has been portaled to ion-app.
this.markerRef = React.createRef();
/**
* Resolve the portal target to the same container CoreDelegate
* teleports overlays into. Portaling here keeps the overlay inside
* React's tree so React's synthetic events still dispatch to its
* children, even after CoreDelegate moves the DOM node out of the
* declared JSX parent.
*/
this.portalTarget = typeof document !== 'undefined' ? document.querySelector('ion-app') || document.body : null;
}

componentDidMount() {
// Reset for React 18 StrictMode: the dev-mode unmount/remount cycle
// re-uses this instance and leaves the flag set from the prior
// componentWillUnmount.
this.isUnmounted = false;

this.componentDidUpdate(this.props);

this.ref.current?.addEventListener('ionMount', this.handleIonMount);
this.ref.current?.addEventListener('willPresent', this.handleWillPresent);
this.ref.current?.addEventListener('didDismiss', this.handleDidDismiss);

/**
* The overlay is portaled to `portalTarget`, so Stencil caches that
* container as `cachedOriginalParent`. Modal features (sheet
* child-route passthrough, parent-removal auto-dismiss) walk up
* from `cachedOriginalParent` to find the enclosing `.ion-page`,
* so we redirect it at the marker's JSX parent.
*/
const overlay = this.ref.current;
if (overlay) {
componentOnReady(overlay as HTMLElement, () => {
if (this.isUnmounted) return;
const markerParent = this.markerRef.current?.parentElement ?? null;
if (markerParent && markerParent !== this.portalTarget) {
(overlay as any).cachedOriginalParent = markerParent;
}
});
}
}

componentDidUpdate(prevProps: IonicReactInternalProps<PropType>) {
componentDidUpdate(prevProps: InternalProps) {
const node = this.ref.current! as HTMLElement;
/**
* onDidDismiss and onWillPresent have manual implementations that
* will invoke the original handler. We need to filter those out
* so they don't get attached twice and called twice.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onDidDismiss, onWillPresent, ...cProps } = this.props;
const { onDidDismiss, onWillPresent, isNested, ...cProps } = this.props;
attachProps(node, cProps, prevProps);
}

componentWillUnmount() {
this.isUnmounted = true;
const node = this.ref.current;
/**
* If the overlay is being unmounted, but is still
Expand All @@ -97,14 +149,28 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
* avoid memory leaks.
*/
node.removeEventListener('didDismiss', this.handleDidDismiss);
node.remove();
if (this.props.isNested) {
/**
* Nested overlays render inline (no portal). CoreDelegate may
* have moved the node out of its React parent, so React's
* unmount won't reach it. Remove it directly.
*/
node.remove();
} else if (node.isConnected && this.portalTarget && node.parentNode !== this.portalTarget) {
/**
* Portaled path: move the overlay back into `portalTarget` so
* React's portal removeChild can find it. CoreDelegate (or user
* code in onWillPresent) may have moved it elsewhere while open.
*/
this.portalTarget.appendChild(node);
}
detachProps(node, this.props);
}
}

render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, forwardedRef, style, className, ref, ...cProps } = this.props;
const { children, forwardedRef, style, className, ref, isNested, ...cProps } = this.props;

const propsToPass = Object.keys(cProps).reduce((acc, name) => {
if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
Expand Down Expand Up @@ -136,17 +202,16 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
return DELEGATE_HOST;
};

return createElement(
'template',
{},
const overlayElement = createElement(
tagName,
newProps,
// Children, not the overlay host, observe `isNested = true`.
createElement(
tagName,
newProps,
NestedOverlayContext.Provider,
{ value: true },
/**
* We only want the inner component
* to be mounted if the overlay is open,
* so conditionally render the component
* based on the isOpen state.
* We only want the inner component to be mounted if the overlay
* is open, so conditionally render based on `isOpen` state.
*/
this.state.isOpen || this.props.keepContentsMounted
? createElement(
Expand All @@ -160,6 +225,21 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
: null
)
);

// Top-level overlays portal into `portalTarget` with a marker
// `<template>` at the JSX location to recover the immediate JSX
// parent after CoreDelegate teleports. Nested overlays and SSR
// fall back to a `<template>` wrapper.
if (!isNested && this.portalTarget) {
return createElement(
React.Fragment,
null,
createElement('template', { ref: this.markerRef }),
createPortal(overlayElement, this.portalTarget)
);
}

return createElement('template', {}, overlayElement);
}

static get displayName() {
Expand Down Expand Up @@ -206,7 +286,15 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
this.props.onDidDismiss && this.props.onDidDismiss(evt);
};
};
return createForwardRef<PropType, ElementType>(ReactComponent, displayName);

// Forward the nesting context as a prop to avoid contextType on the class.
const ReactComponentWithNesting: React.FC<IonicReactInternalProps<PropType>> = (props) =>
createElement(NestedOverlayContext.Consumer, null, (isNested: boolean) =>
createElement(ReactComponent, { ...(props as InternalProps), isNested })
);
ReactComponentWithNesting.displayName = displayName;

return createForwardRef<PropType, ElementType>(ReactComponentWithNesting, displayName);
};

const DELEGATE_HOST = 'ion-delegate-host';
2 changes: 2 additions & 0 deletions packages/react/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import Main from './pages/Main';
import Tabs from './pages/Tabs';
import TabsBasic from './pages/TabsBasic';
import NavComponent from './pages/navigation/NavComponent';
import NavModalComponent from './pages/navigation/NavModalComponent';
import TabsDirectNavigation from './pages/TabsDirectNavigation';
import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes';
import IonModalConditional from './pages/overlay-components/IonModalConditional';
Expand Down Expand Up @@ -66,6 +67,7 @@ const App: React.FC = () => (
/>
<Route path="/keep-contents-mounted" component={KeepContentsMounted} />
<Route path="/navigation" component={NavComponent} />
<Route path="/navigation-modal" component={NavModalComponent} />
<Route path="/tabs" component={Tabs} />
<Route path="/tabs-basic" component={TabsBasic} />
<Route path="/tabs-direct-navigation" component={TabsDirectNavigation} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
IonButton,
IonContent,
IonHeader,
IonModal,
IonNav,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react';
import React, { useState } from 'react';

const ModalPage: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [clickCount, setClickCount] = useState(0);
const [inputValue, setInputValue] = useState('');

return (
<>
<IonHeader>
<IonToolbar>
<IonTitle>Nav Modal Page</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonButton id="open-modal" onClick={() => setIsOpen(true)}>
Open Modal
</IonButton>
<div id="page-click-count">{clickCount}</div>
<div id="page-input-value">{inputValue}</div>

<IonModal isOpen={isOpen} onDidDismiss={() => setIsOpen(false)}>
<IonContent>
<IonButton id="increment-button" onClick={() => setClickCount((c) => c + 1)}>
Increment
</IonButton>
<input
id="text-input"
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<IonButton id="close-modal" onClick={() => setIsOpen(false)}>
Close
</IonButton>
</IonContent>
</IonModal>
</IonContent>
</>
);
};

const NavModalComponent: React.FC = () => {
return (
<IonPage>
<IonNav root={() => <ModalPage />} />
</IonPage>
);
};

export default NavModalComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
describe('IonNav with IonModal', () => {
beforeEach(() => {
cy.visit('/navigation-modal');
});

// @see https://github.com/ionic-team/ionic-framework/issues/27843
it('should fire click handlers inside a modal that lives in IonNav', () => {
cy.get('#open-modal').click();
cy.get('ion-modal').should('be.visible');

cy.get('#increment-button').click();
cy.get('#increment-button').click();

cy.get('#page-click-count').should('have.text', '2');
});

// @see https://github.com/ionic-team/ionic-framework/issues/27843
it('should fire input change handlers inside a modal that lives in IonNav', () => {
cy.get('#open-modal').click();
cy.get('ion-modal').should('be.visible');

cy.get('#text-input').type('hello');

cy.get('#page-input-value').should('have.text', 'hello');
});

// @see https://github.com/ionic-team/ionic-framework/issues/27843
it('should dismiss the modal via a click handler inside it', () => {
cy.get('#open-modal').click();
cy.get('ion-modal').should('be.visible');

cy.get('#close-modal').click();
cy.get('ion-modal').should('not.be.visible');
});
});
Loading