mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
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:
63
packages/react/src/components/IonApp.tsx
Normal file
63
packages/react/src/components/IonApp.tsx
Normal 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';
|
||||
}
|
||||
}
|
67
packages/react/src/components/IonOverlayManager.tsx
Normal file
67
packages/react/src/components/IonOverlayManager.tsx
Normal 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}`);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
21
packages/react/src/contexts/IonContext.tsx
Normal file
21
packages/react/src/contexts/IonContext.tsx
Normal 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;
|
||||
},
|
||||
});
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}, []);
|
||||
|
3
packages/react/src/models/ReactComponentOrElement.ts
Normal file
3
packages/react/src/models/ReactComponentOrElement.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ReactComponentOrElement = React.ComponentClass<any, any> | React.FC<any> | JSX.Element;
|
@ -2,3 +2,4 @@ export * from './RouteAction';
|
||||
export * from './RouteInfo';
|
||||
export * from './RouterDirection';
|
||||
export * from './RouterOptions';
|
||||
export * from './ReactComponentOrElement';
|
||||
|
Reference in New Issue
Block a user