fix(react): overlays shown with useIonModal and useIonPopover no longer render outside of main react tree

closes #23516 and #23516
This commit is contained in:
Ely Lucas
2021-10-15 15:29:25 -06:00
committed by GitHub
parent 3451a34ad0
commit f3e492c897
13 changed files with 197 additions and 46 deletions

View File

@ -0,0 +1,63 @@
import { JSX as LocalJSX } from '@ionic/core/components';
import React from 'react';
import { IonContext, IonContextInterface } from '../contexts/IonContext';
import { ReactComponentOrElement } from '../models';
import { IonOverlayManager } from './IonOverlayManager';
import { IonicReactProps } from './IonicReactProps';
import { IonAppInner } from './inner-proxies';
type Props = LocalJSX.IonApp &
IonicReactProps & {
ref?: React.Ref<HTMLIonAppElement>;
};
export class IonApp extends React.Component<Props> {
addOverlayCallback?: (id: string, overlay: any, containerElement: HTMLDivElement) => void;
removeOverlayCallback?: (id: string) => void;
constructor(props: Props) {
super(props);
}
/*
Wire up methods to call into IonOverlayManager
*/
ionContext: IonContextInterface = {
addOverlay: (
id: string,
overlay: ReactComponentOrElement,
containerElement: HTMLDivElement
) => {
if (this.addOverlayCallback) {
this.addOverlayCallback(id, overlay, containerElement);
}
},
removeOverlay: (id: string) => {
if (this.removeOverlayCallback) {
this.removeOverlayCallback(id);
}
},
};
render() {
return (
<IonContext.Provider value={this.ionContext}>
<IonAppInner {...this.props}>{this.props.children}</IonAppInner>
<IonOverlayManager
onAddOverlay={(callback) => {
this.addOverlayCallback = callback;
}}
onRemoveOverlay={(callback) => {
this.removeOverlayCallback = callback;
}}
/>
</IonContext.Provider>
);
}
static get displayName() {
return 'IonApp';
}
}

View File

@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { ReactComponentOrElement } from '../models';
interface IonOverlayManagerProps {
onAddOverlay: (
callback: (
id: string,
component: ReactComponentOrElement,
containerElement: HTMLDivElement
) => void
) => void;
onRemoveOverlay: (callback: (id: string) => void) => void;
}
/**
* Manages overlays that are added via the useOverlay hook.
* This is a standalone component so changes to its children don't cause other descendant
* components to re-render when overlays are added. However, we need to communicate with the IonContext
* that is set up in <IonApp />, so we register callbacks so when overlays are added to IonContext,
* they ultimately added here.
*/
export const IonOverlayManager: React.FC<IonOverlayManagerProps> = ({
onAddOverlay,
onRemoveOverlay,
}) => {
const [overlays, setOverlays] = useState<{
[key: string]: {
component: any;
containerElement: HTMLDivElement;
};
}>({});
useEffect(() => {
/* Setup the callbacks that get called from <IonApp /> */
onAddOverlay(addOverlay);
onRemoveOverlay(removeOverlay);
}, []);
const addOverlay = (
id: string,
component: ReactComponentOrElement,
containerElement: HTMLDivElement
) => {
const newOverlays = { ...overlays };
newOverlays[id] = { component, containerElement };
setOverlays(newOverlays);
};
const removeOverlay = (id: string) => {
const newOverlays = { ...overlays };
delete newOverlays[id];
setOverlays(newOverlays);
};
const overlayKeys = Object.keys(overlays);
return (
<>
{overlayKeys.map((key) => {
const overlay = overlays[key];
return ReactDOM.createPortal(overlay.component, overlay.containerElement, `overlay-${key}`);
})}
</>
);
};

View File

@ -142,6 +142,7 @@ export { IonModal } from './IonModal';
export { IonPopover } from './IonPopover';
// Custom Components
export { IonApp } from './IonApp';
export { IonPage } from './IonPage';
export { IonTabsContext, IonTabsContextState } from './navigation/IonTabsContext';
export { IonTabs } from './navigation/IonTabs';

View File

@ -1,4 +1,5 @@
import { JSX } from '@ionic/core/components';
import { IonApp as IonAppCmp } from '@ionic/core/components/ion-app.js';
import { IonBackButton as IonBackButtonCmp } from '@ionic/core/components/ion-back-button.js';
import { IonRouterOutlet as IonRouterOutletCmp } from '@ionic/core/components/ion-router-outlet.js';
import { IonTabBar as IonTabBarCmp } from '@ionic/core/components/ion-tab-bar.js';
@ -28,6 +29,13 @@ export const IonRouterOutletInner = /*@__PURE__*/ createReactComponent<
HTMLIonRouterOutletElement
>('ion-router-outlet', undefined, undefined, IonRouterOutletCmp);
export const IonAppInner = /*@__PURE__*/ createReactComponent<JSX.IonApp, HTMLIonAppElement>(
'ion-app',
undefined,
undefined,
IonAppCmp
);
// ionicons
export const IonIconInner = /*@__PURE__*/ createReactComponent<
IoniconsJSX.IonIcon,

View File

@ -7,7 +7,6 @@ import type { JSX } from '@ionic/core/components';
import { IonAccordion as IonAccordionCmp } from '@ionic/core/components/ion-accordion.js';
import { IonAccordionGroup as IonAccordionGroupCmp } from '@ionic/core/components/ion-accordion-group.js';
import { IonApp as IonAppCmp } from '@ionic/core/components/ion-app.js';
import { IonAvatar as IonAvatarCmp } from '@ionic/core/components/ion-avatar.js';
import { IonBackdrop as IonBackdropCmp } from '@ionic/core/components/ion-backdrop.js';
import { IonBadge as IonBadgeCmp } from '@ionic/core/components/ion-badge.js';
@ -76,7 +75,6 @@ import { IonVirtualScroll as IonVirtualScrollCmp } from '@ionic/core/components/
export const IonAccordion = /*@__PURE__*/createReactComponent<JSX.IonAccordion, HTMLIonAccordionElement>('ion-accordion', undefined, undefined, IonAccordionCmp);
export const IonAccordionGroup = /*@__PURE__*/createReactComponent<JSX.IonAccordionGroup, HTMLIonAccordionGroupElement>('ion-accordion-group', undefined, undefined, IonAccordionGroupCmp);
export const IonApp = /*@__PURE__*/createReactComponent<JSX.IonApp, HTMLIonAppElement>('ion-app', undefined, undefined, IonAppCmp);
export const IonAvatar = /*@__PURE__*/createReactComponent<JSX.IonAvatar, HTMLIonAvatarElement>('ion-avatar', undefined, undefined, IonAvatarCmp);
export const IonBackdrop = /*@__PURE__*/createReactComponent<JSX.IonBackdrop, HTMLIonBackdropElement>('ion-backdrop', undefined, undefined, IonBackdropCmp);
export const IonBadge = /*@__PURE__*/createReactComponent<JSX.IonBadge, HTMLIonBadgeElement>('ion-badge', undefined, undefined, IonBadgeCmp);

View File

@ -0,0 +1,21 @@
import React from 'react';
import { ReactComponentOrElement } from '../models';
export interface IonContextInterface {
addOverlay: (
id: string,
overlay: ReactComponentOrElement,
containerElement: HTMLDivElement
) => void;
removeOverlay: (id: string) => void;
}
export const IonContext = React.createContext<IonContextInterface>({
addOverlay: () => {
return;
},
removeOverlay: () => {
return;
},
});

View File

@ -1,8 +1,10 @@
import { ModalOptions, modalController } from '@ionic/core/components';
import { useCallback } from 'react';
import { ReactComponentOrElement } from '../models/ReactComponentOrElement';
import { HookOverlayOptions } from './HookOverlayOptions';
import { ReactComponentOrElement, useOverlay } from './useOverlay';
import { useOverlay } from './useOverlay';
/**
* A hook for presenting/dismissing an IonModal component

View File

@ -1,8 +1,10 @@
import { PopoverOptions, popoverController } from '@ionic/core/components';
import { useCallback } from 'react';
import { ReactComponentOrElement } from '../models/ReactComponentOrElement';
import { HookOverlayOptions } from './HookOverlayOptions';
import { ReactComponentOrElement, useOverlay } from './useOverlay';
import { useOverlay } from './useOverlay';
/**
* A hook for presenting/dismissing an IonPicker component

View File

@ -1,53 +1,41 @@
import { OverlayEventDetail } from '@ionic/core/components';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { attachProps } from '../components/react-component-lib/utils';
import { IonContext } from '../contexts/IonContext';
import { ReactComponentOrElement } from '../models/ReactComponentOrElement';
import { generateId } from '../utils/generateId';
import { HookOverlayOptions } from './HookOverlayOptions';
export type ReactComponentOrElement = React.ComponentClass<any, any> | React.FC<any> | JSX.Element;
interface OverlayBase extends HTMLElement {
present: () => Promise<void>;
dismiss: (data?: any, role?: string | undefined) => Promise<boolean>;
}
export function useOverlay<
OptionsType,
OverlayType extends OverlayBase
>(
export function useOverlay<OptionsType, OverlayType extends OverlayBase>(
displayName: string,
controller: { create: (options: OptionsType) => Promise<OverlayType>; },
controller: { create: (options: OptionsType) => Promise<OverlayType> },
component: ReactComponentOrElement,
componentProps?: any
) {
const overlayRef = useRef<OverlayType>();
const containerElRef = useRef<HTMLDivElement>();
const didDismissEventName = useMemo(
() => `on${displayName}DidDismiss`,
[displayName]
);
const didPresentEventName = useMemo(
() => `on${displayName}DidPresent`,
[displayName]
);
const willDismissEventName = useMemo(
() => `on${displayName}WillDismiss`,
[displayName]
);
const willPresentEventName = useMemo(
() => `on${displayName}WillPresent`,
[displayName]
);
const didDismissEventName = useMemo(() => `on${displayName}DidDismiss`, [displayName]);
const didPresentEventName = useMemo(() => `on${displayName}DidPresent`, [displayName]);
const willDismissEventName = useMemo(() => `on${displayName}WillDismiss`, [displayName]);
const willPresentEventName = useMemo(() => `on${displayName}WillPresent`, [displayName]);
const [isOpen, setIsOpen] = useState(false);
const ionContext = useContext(IonContext);
const [overlayId] = useState(generateId('overlay'));
useEffect(() => {
if (isOpen && component && containerElRef.current) {
if (React.isValidElement(component)) {
ReactDOM.render(component, containerElRef.current);
ionContext.addOverlay(overlayId, component, containerElRef.current!);
} else {
ReactDOM.render(React.createElement(component as React.ComponentClass, componentProps), containerElRef.current);
const element = React.createElement(component as React.ComponentClass, componentProps);
ionContext.addOverlay(overlayId, element, containerElRef.current!);
}
}
}, [component, containerElRef.current, isOpen, componentProps]);
@ -57,13 +45,7 @@ export function useOverlay<
return;
}
const {
onDidDismiss,
onWillDismiss,
onDidPresent,
onWillPresent,
...rest
} = options;
const { onDidDismiss, onWillDismiss, onDidPresent, onWillPresent, ...rest } = options;
if (typeof document !== 'undefined') {
containerElRef.current = document.createElement('div');
@ -71,17 +53,14 @@ export function useOverlay<
overlayRef.current = await controller.create({
...(rest as any),
component: containerElRef.current
component: containerElRef.current,
});
attachProps(overlayRef.current, {
[didDismissEventName]: handleDismiss,
[didPresentEventName]: (e: CustomEvent) =>
onDidPresent && onDidPresent(e),
[willDismissEventName]: (e: CustomEvent) =>
onWillDismiss && onWillDismiss(e),
[willPresentEventName]: (e: CustomEvent) =>
onWillPresent && onWillPresent(e),
[didPresentEventName]: (e: CustomEvent) => onDidPresent && onDidPresent(e),
[willDismissEventName]: (e: CustomEvent) => onWillDismiss && onWillDismiss(e),
[willPresentEventName]: (e: CustomEvent) => onWillPresent && onWillPresent(e),
});
overlayRef.current.present();
@ -95,11 +74,12 @@ export function useOverlay<
overlayRef.current = undefined;
containerElRef.current = undefined;
setIsOpen(false);
ionContext.removeOverlay(overlayId);
}
}, []);
const dismiss = useCallback(async () => {
overlayRef.current && await overlayRef.current.dismiss();
overlayRef.current && (await overlayRef.current.dismiss());
overlayRef.current = undefined;
containerElRef.current = undefined;
}, []);

View File

@ -0,0 +1,3 @@
import React from 'react';
export type ReactComponentOrElement = React.ComponentClass<any, any> | React.FC<any> | JSX.Element;

View File

@ -2,3 +2,4 @@ export * from './RouteAction';
export * from './RouteInfo';
export * from './RouterDirection';
export * from './RouterOptions';
export * from './ReactComponentOrElement';