From 420f9bbebd41f3eab6def795bcdd1933d5c5a47a Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Wed, 6 Jul 2022 13:45:30 -0400 Subject: [PATCH] fix(react): IonNav works with react (#25565) Resolves #24002 Co-authored-by: Liam DeBeasi --- packages/react/src/components/index.ts | 1 + .../components/navigation/IonBackButton.tsx | 8 ++ .../src/components/navigation/IonNav.tsx | 30 +++++++ packages/react/src/framework-delegate.tsx | 38 +++++++++ .../integration/navigation/IonNav.spec.ts | 28 +++++++ packages/react/test-app/src/App.tsx | 2 + packages/react/test-app/src/pages/Main.tsx | 5 ++ .../src/pages/navigation/NavComponent.tsx | 84 +++++++++++++++++++ .../src/pages/overlay-hooks/ModalHook.tsx | 2 +- 9 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/navigation/IonNav.tsx create mode 100644 packages/react/src/framework-delegate.tsx create mode 100644 packages/react/test-app/cypress/integration/navigation/IonNav.spec.ts create mode 100644 packages/react/test-app/src/pages/navigation/NavComponent.tsx diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 84c34f0a843..99c47be63ce 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -132,6 +132,7 @@ export { IonPopover } from './IonPopover'; // Custom Components export { IonApp } from './IonApp'; export { IonPage } from './IonPage'; +export { IonNav } from './navigation/IonNav'; export { IonTabsContext, IonTabsContextState } from './navigation/IonTabsContext'; export { IonTabs } from './navigation/IonTabs'; export { IonTabBar } from './navigation/IonTabBar'; diff --git a/packages/react/src/components/navigation/IonBackButton.tsx b/packages/react/src/components/navigation/IonBackButton.tsx index a620678cf9a..bc61660f12d 100644 --- a/packages/react/src/components/navigation/IonBackButton.tsx +++ b/packages/react/src/components/navigation/IonBackButton.tsx @@ -21,7 +21,15 @@ export const IonBackButton = /*@__PURE__*/ (() => context!: React.ContextType; clickButton = (e: React.MouseEvent) => { + /** + * If ion-back-button is being used inside + * of ion-nav then we should not interact with + * the router. + */ + if (e.target && (e.target as HTMLElement).closest('ion-nav') !== null) { return; } + const { defaultHref, routerAnimation } = this.props; + if (this.context.hasIonicRouter()) { e.stopPropagation(); this.context.goBack(defaultHref, routerAnimation); diff --git a/packages/react/src/components/navigation/IonNav.tsx b/packages/react/src/components/navigation/IonNav.tsx new file mode 100644 index 00000000000..a59b4a57e3c --- /dev/null +++ b/packages/react/src/components/navigation/IonNav.tsx @@ -0,0 +1,30 @@ +import type { FrameworkDelegate, JSX } from '@ionic/core/components'; +import { defineCustomElement } from '@ionic/core/components/ion-nav.js'; +import React, { useState } from 'react'; + +import { ReactDelegate } from '../../framework-delegate'; +import { createReactComponent } from '../react-component-lib'; + +const IonNavInner = createReactComponent< + JSX.IonNav & { delegate: FrameworkDelegate }, + HTMLIonNavElement +>('ion-nav', undefined, undefined, defineCustomElement); + +export const IonNav: React.FC = ({ children, ...restOfProps }) => { + const [views, setViews] = useState([]); + + /** + * Allows us to create React components that are rendered within + * the context of the IonNav component. + */ + const addView = (view: React.ReactPortal) => setViews([...views, view]); + const removeView = (view: React.ReactPortal) => setViews(views.filter((v) => v !== view)); + + const delegate = ReactDelegate(addView, removeView); + + return ( + + {views} + + ); +}; diff --git a/packages/react/src/framework-delegate.tsx b/packages/react/src/framework-delegate.tsx new file mode 100644 index 00000000000..e2370fee184 --- /dev/null +++ b/packages/react/src/framework-delegate.tsx @@ -0,0 +1,38 @@ +import { FrameworkDelegate } from '@ionic/core/components'; +import { createPortal } from 'react-dom'; + +export const ReactDelegate = ( + addView: (view: React.ReactPortal) => void, + removeView: (view: React.ReactPortal) => void +): FrameworkDelegate => { + let Component: React.ReactPortal; + + const attachViewToDom = async ( + parentElement: HTMLElement, + component: () => JSX.Element, + propsOrDataObj?: any, + cssClasses?: string[] + ): Promise => { + const div = document.createElement('div'); + cssClasses && div.classList.add(...cssClasses); + parentElement.appendChild(div); + + Component = createPortal(component(), div); + + Component.props = propsOrDataObj; + + addView(Component); + + return Promise.resolve(div); + }; + + const removeViewFromDom = (): Promise => { + Component && removeView(Component); + return Promise.resolve(); + }; + + return { + attachViewToDom, + removeViewFromDom, + }; +}; diff --git a/packages/react/test-app/cypress/integration/navigation/IonNav.spec.ts b/packages/react/test-app/cypress/integration/navigation/IonNav.spec.ts new file mode 100644 index 00000000000..c9d466bcad6 --- /dev/null +++ b/packages/react/test-app/cypress/integration/navigation/IonNav.spec.ts @@ -0,0 +1,28 @@ +describe('IonNav', () => { + beforeEach(() => { + cy.visit('/navigation'); + }); + + it('should render the root page', () => { + cy.get('ion-nav').contains('Page one content'); + }); + + it('should push a page', () => { + cy.get('ion-button').contains('Go to Page Two').click(); + cy.get('#pageTwoContent').should('be.visible'); + cy.get('ion-nav').contains('Page two content'); + }); + + it('should pop a page', () => { + cy.get('ion-button').contains('Go to Page Two').click(); + + cy.get('#pageTwoContent').should('be.visible'); + cy.get('ion-nav').contains('Page two content'); + + cy.get('.ion-page.can-go-back ion-back-button').click(); + + cy.get('#pageOneContent').should('be.visible'); + cy.get('ion-nav').contains('Page one content'); + }); + +}); diff --git a/packages/react/test-app/src/App.tsx b/packages/react/test-app/src/App.tsx index 11cb79fba13..dcefa51c3d2 100644 --- a/packages/react/test-app/src/App.tsx +++ b/packages/react/test-app/src/App.tsx @@ -25,6 +25,7 @@ import Main from './pages/Main'; import OverlayHooks from './pages/overlay-hooks/OverlayHooks'; import OverlayComponents from './pages/overlay-components/OverlayComponents'; import Tabs from './pages/Tabs'; +import NavComponent from './pages/navigation/NavComponent'; setupIonicReact(); @@ -35,6 +36,7 @@ const App: React.FC = () => ( + diff --git a/packages/react/test-app/src/pages/Main.tsx b/packages/react/test-app/src/pages/Main.tsx index 944ac25419c..419589385c5 100644 --- a/packages/react/test-app/src/pages/Main.tsx +++ b/packages/react/test-app/src/pages/Main.tsx @@ -31,6 +31,11 @@ const Main: React.FC = () => { Overlay Components + + + Navigation + + Tabs diff --git a/packages/react/test-app/src/pages/navigation/NavComponent.tsx b/packages/react/test-app/src/pages/navigation/NavComponent.tsx new file mode 100644 index 00000000000..a40bb7c3ed4 --- /dev/null +++ b/packages/react/test-app/src/pages/navigation/NavComponent.tsx @@ -0,0 +1,84 @@ +import { + IonButton, + IonContent, + IonHeader, + IonLabel, + IonNav, + IonNavLink, + IonTitle, + IonToolbar, + IonButtons, + IonBackButton, + IonPage, +} from '@ionic/react'; +import React from 'react'; + +const NavComponent: React.FC = () => { + return ( + + { + return ( + <> + + + Page One + + + + + + + Page one content + { + return ( + <> + + + Page Two + + + + + + + Page two content + ( + <> + + + Page Three + + + + + + + Page three content + + + )} + > + Go to Page Three + + + + ); + }} + > + Go to Page Two + + + + ); + }} + > + + ); +}; + +export default NavComponent; diff --git a/packages/react/test-app/src/pages/overlay-hooks/ModalHook.tsx b/packages/react/test-app/src/pages/overlay-hooks/ModalHook.tsx index 72af4051544..9712f5a2265 100644 --- a/packages/react/test-app/src/pages/overlay-hooks/ModalHook.tsx +++ b/packages/react/test-app/src/pages/overlay-hooks/ModalHook.tsx @@ -49,7 +49,7 @@ const ModalHook: React.FC = () => { setCount(count + 1); }, [count, setCount]); - const handleDismissWithComponent = useCallback((data, role) => { + const handleDismissWithComponent = useCallback((data: any, role: string) => { dismissWithComponent(data, role); // eslint-disable-next-line react-hooks/exhaustive-deps }, []);