diff --git a/packages/react/src/components/navigation/IonTabBar.tsx b/packages/react/src/components/navigation/IonTabBar.tsx index f1a66440fc..92fde774dd 100644 --- a/packages/react/src/components/navigation/IonTabBar.tsx +++ b/packages/react/src/components/navigation/IonTabBar.tsx @@ -8,6 +8,8 @@ import { IonTabBarInner } from '../inner-proxies'; import { createForwardRef } from '../utils'; import { IonTabButton } from './IonTabButton'; +import { IonTabsContext } from './IonTabsContext'; +import type { IonTabsContextState } from './IonTabsContext'; type IonTabBarProps = LocalJSX.IonTabBar & IonicReactProps & { @@ -21,7 +23,7 @@ interface InternalProps extends IonTabBarProps { forwardedRef?: React.ForwardedRef; onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void; routeInfo: RouteInfo; - routerOutletRef?: React.RefObject | undefined; + tabsContext?: IonTabsContextState; } interface TabUrls { @@ -183,12 +185,14 @@ class IonTabBarUnwrapped extends React.PureComponent = React.memo(({ forwardedRef, ...props }) => { const context = useContext(NavContext); + const tabsContext = useContext(IonTabsContext); + const tabBarRef = forwardedRef || tabsContext.tabBarProps.ref; + const updatedTabBarProps = { + ...tabsContext.tabBarProps, + ref: tabBarRef, + }; + return ( {props.children} diff --git a/packages/react/src/components/navigation/IonTabs.tsx b/packages/react/src/components/navigation/IonTabs.tsx index e80e09ac15..a7a8a250bf 100644 --- a/packages/react/src/components/navigation/IonTabs.tsx +++ b/packages/react/src/components/navigation/IonTabs.tsx @@ -8,7 +8,6 @@ import { IonRouterOutlet } from '../IonRouterOutlet'; import { IonTabsInner } from '../inner-proxies'; import { IonTab } from '../proxies'; -import { IonTabBar } from './IonTabBar'; import type { IonTabsContextState } from './IonTabsContext'; import { IonTabsContext } from './IonTabsContext'; @@ -43,28 +42,15 @@ interface Props extends LocalJSX.IonTabs { children: ChildFunction | React.ReactNode; } -const hostStyles: React.CSSProperties = { - display: 'flex', - position: 'absolute', - top: '0', - left: '0', - right: '0', - bottom: '0', - flexDirection: 'column', - width: '100%', - height: '100%', - contain: 'layout size style', -}; - -const tabsInner: React.CSSProperties = { - position: 'relative', - flex: 1, - contain: 'layout size style', -}; - export const IonTabs = /*@__PURE__*/ (() => class extends React.Component { context!: React.ContextType; + /** + * `routerOutletRef` allows users to add a `ref` to `IonRouterOutlet`. + * Without this, `ref.current` will be `undefined` in the user's app, + * breaking their ability to access the `IonRouterOutlet` instance. + * Do not remove this ref. + */ routerOutletRef: React.Ref = React.createRef(); selectTabHandler?: (tag: string) => boolean; tabBarRef = React.createRef(); @@ -72,6 +58,14 @@ export const IonTabs = /*@__PURE__*/ (() => ionTabContextState: IonTabsContextState = { activeTab: undefined, selectTab: () => false, + hasRouterOutlet: false, + /** + * Tab bar can be used as a standalone component, + * so the props can not be passed directly to the + * tab bar component. Instead, props will be + * passed through the context. + */ + tabBarProps: { ref: this.tabBarRef }, }; constructor(props: Props) { @@ -90,9 +84,32 @@ export const IonTabs = /*@__PURE__*/ (() => } } + renderTabsInner(children: React.ReactNode, outlet: React.ReactElement<{}> | undefined) { + return ( + + {React.Children.map(children, (child: React.ReactNode) => { + if (React.isValidElement(child)) { + const isRouterOutlet = + child.type === IonRouterOutlet || + (child.type as any).isRouterOutlet || + (child.type === Fragment && child.props.children[0].type === IonRouterOutlet); + + if (isRouterOutlet) { + /** + * The modified outlet needs to be returned to include + * the ref. + */ + return outlet; + } + } + return child; + })} + + ); + } + render() { let outlet: React.ReactElement<{}> | undefined; - let tabBar: React.ReactElement | undefined; // Check if IonTabs has any IonTab children let hasTab = false; const { className, onIonTabsDidChange, onIonTabsWillChange, ...props } = this.props; @@ -102,19 +119,15 @@ export const IonTabs = /*@__PURE__*/ (() => ? (this.props.children as ChildFunction)(this.ionTabContextState) : this.props.children; - const outletProps = { - ref: this.routerOutletRef, - }; - React.Children.forEach(children, (child: any) => { // eslint-disable-next-line no-prototype-builtins if (child == null || typeof child !== 'object' || !child.hasOwnProperty('type')) { return; } if (child.type === IonRouterOutlet || child.type.isRouterOutlet) { - outlet = React.cloneElement(child, outletProps); + outlet = React.cloneElement(child); } else if (child.type === Fragment && child.props.children[0].type === IonRouterOutlet) { - outlet = React.cloneElement(child.props.children[0], outletProps); + outlet = React.cloneElement(child.props.children[0]); } else if (child.type === IonTab) { /** * This indicates that IonTabs will be using a basic tab-based navigation @@ -123,9 +136,10 @@ export const IonTabs = /*@__PURE__*/ (() => hasTab = true; } + this.ionTabContextState.hasRouterOutlet = !!outlet; + let childProps: any = { - ref: this.tabBarRef, - routerOutletRef: this.routerOutletRef, + ...this.ionTabContextState.tabBarProps, }; /** @@ -149,14 +163,7 @@ export const IonTabs = /*@__PURE__*/ (() => }; } - if (child.type === IonTabBar || child.type.isTabBar) { - tabBar = React.cloneElement(child, childProps); - } else if ( - child.type === Fragment && - (child.props.children[1].type === IonTabBar || child.props.children[1].type.isTabBar) - ) { - tabBar = React.cloneElement(child.props.children[1], childProps); - } + this.ionTabContextState.tabBarProps = childProps; }); if (!outlet && !hasTab) { @@ -186,46 +193,10 @@ export const IonTabs = /*@__PURE__*/ (() => {this.context.hasIonicRouter() ? ( - - {React.Children.map(children, (child: React.ReactNode) => { - if (React.isValidElement(child)) { - const isTabBar = - child.type === IonTabBar || - (child.type as any).isTabBar || - (child.type === Fragment && - (child.props.children[1].type === IonTabBar || child.props.children[1].type.isTabBar)); - const isRouterOutlet = - child.type === IonRouterOutlet || - (child.type as any).isRouterOutlet || - (child.type === Fragment && child.props.children[0].type === IonRouterOutlet); - - if (isTabBar) { - /** - * The modified tabBar needs to be returned to include - * the context and the overridden methods. - */ - return tabBar; - } - if (isRouterOutlet) { - /** - * The modified outlet needs to be returned to include - * the ref. - */ - return outlet; - } - } - return child; - })} - + {this.renderTabsInner(children, outlet)} ) : ( -
- {tabBar?.props.slot === 'top' ? tabBar : null} -
- {outlet} -
- {tabBar?.props.slot === 'bottom' ? tabBar : null} -
+ this.renderTabsInner(children, outlet) )}
); diff --git a/packages/react/src/components/navigation/IonTabsContext.tsx b/packages/react/src/components/navigation/IonTabsContext.tsx index e7f9ba2b10..12686bd2ad 100644 --- a/packages/react/src/components/navigation/IonTabsContext.tsx +++ b/packages/react/src/components/navigation/IonTabsContext.tsx @@ -3,9 +3,25 @@ import React from 'react'; export interface IonTabsContextState { activeTab: string | undefined; selectTab: (tab: string) => boolean; + hasRouterOutlet: boolean; + tabBarProps: TabBarProps; } +/** + * Tab bar can be used as a standalone component, + * so the props can not be passed directly to the + * tab bar component. Instead, props will be + * passed through the context. + */ +type TabBarProps = { + ref: React.RefObject; + onIonTabsWillChange?: (e: CustomEvent) => void; + onIonTabsDidChange?: (e: CustomEvent) => void; +}; + export const IonTabsContext = React.createContext({ activeTab: undefined, selectTab: () => false, + hasRouterOutlet: false, + tabBarProps: { ref: React.createRef() }, }); diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 24e8134540..714891c188 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -1,5 +1,5 @@ import { defineCustomElement } from "@ionic/core/components/ion-tab-bar.js"; -import type { VNode } from "vue"; +import type { VNode, Ref } from "vue"; import { h, defineComponent, getCurrentInstance, inject } from "vue"; // TODO(FW-2969): types @@ -16,6 +16,12 @@ interface Tab { ref: VNode; } +interface TabBarData { + hasRouterOutlet: boolean; + _tabsWillChange: Function; + _tabsDidChange: Function; +} + const isTabButton = (child: any) => child.type?.name === "IonTabButton"; const getTabs = (nodes: VNode[]) => { @@ -34,20 +40,23 @@ const getTabs = (nodes: VNode[]) => { export const IonTabBar = defineComponent({ name: "IonTabBar", - props: { - /* eslint-disable @typescript-eslint/no-empty-function */ - _tabsWillChange: { type: Function, default: () => {} }, - _tabsDidChange: { type: Function, default: () => {} }, - _hasRouterOutlet: { type: Boolean, default: false }, - /* eslint-enable @typescript-eslint/no-empty-function */ - }, data() { return { tabState: { activeTab: undefined, tabs: {}, + /** + * Passing this prop to each tab button + * lets it be aware of the presence of + * the router outlet. + */ + hasRouterOutlet: false, }, tabVnodes: [], + /* eslint-disable @typescript-eslint/no-empty-function */ + _tabsWillChange: { type: Function, default: () => {} }, + _tabsDidChange: { type: Function, default: () => {} }, + /* eslint-enable @typescript-eslint/no-empty-function */ }; }, updated() { @@ -55,7 +64,7 @@ export const IonTabBar = defineComponent({ }, methods: { setupTabState(ionRouter: any) { - const hasRouterOutlet = this.$props._hasRouterOutlet; + const hasRouterOutlet = this.$data.tabState.hasRouterOutlet; /** * For each tab, we need to keep track of its * base href as well as any child page that @@ -75,13 +84,6 @@ export const IonTabBar = defineComponent({ ref: child, }; - /** - * Passing this prop to each tab button - * lets it be aware of the presence of - * the router outlet. - */ - tabState.hasRouterOutlet = hasRouterOutlet; - /** * Passing this prop to each tab button * lets it be aware of the state that @@ -126,7 +128,7 @@ export const IonTabBar = defineComponent({ * @param ionRouter */ checkActiveTab(ionRouter: any) { - const hasRouterOutlet = this.$props._hasRouterOutlet; + const hasRouterOutlet = this.$data.tabState.hasRouterOutlet; const currentRoute = ionRouter?.getCurrentRouteInfo(); const childNodes = this.$data.tabVnodes; const { tabs, activeTab: prevActiveTab } = this.$data.tabState; @@ -216,7 +218,7 @@ export const IonTabBar = defineComponent({ this.tabSwitch(activeTab); }, tabSwitch(activeTab: string, ionRouter?: any) { - const hasRouterOutlet = this.$props._hasRouterOutlet; + const hasRouterOutlet = this.$data.tabState.hasRouterOutlet; const childNodes = this.$data.tabVnodes; const { activeTab: prevActiveTab } = this.$data.tabState; const tabState = this.$data.tabState; @@ -227,7 +229,7 @@ export const IonTabBar = defineComponent({ const tabDidChange = activeTab !== prevActiveTab; if (tabBar) { if (activeChild) { - tabDidChange && this.$props._tabsWillChange(activeTab); + tabDidChange && this.$data._tabsWillChange(activeTab); if (hasRouterOutlet && ionRouter !== null) { ionRouter.handleSetCurrentTab(activeTab); @@ -235,7 +237,7 @@ export const IonTabBar = defineComponent({ tabBar.selectedTab = tabState.activeTab = activeTab; - tabDidChange && this.$props._tabsDidChange(activeTab); + tabDidChange && this.$data._tabsDidChange(activeTab); } else { /** * When going to a tab that does @@ -250,6 +252,17 @@ export const IonTabBar = defineComponent({ }, mounted() { const ionRouter: any = inject("navManager", null); + /** + * Tab bar can be used as a standalone component, + * so it cannot be modified directly through + * IonTabs. Instead, data will be passed through + * the provide/inject. + */ + const tabBarData = inject>("tabBarData"); + + this.$data.tabState.hasRouterOutlet = tabBarData.value.hasRouterOutlet; + this.$data._tabsWillChange = tabBarData.value._tabsWillChange; + this.$data._tabsDidChange = tabBarData.value._tabsDidChange; this.setupTabState(ionRouter); diff --git a/packages/vue/src/components/IonTabs.ts b/packages/vue/src/components/IonTabs.ts index 4fde919bc8..5adde3a331 100644 --- a/packages/vue/src/components/IonTabs.ts +++ b/packages/vue/src/components/IonTabs.ts @@ -1,6 +1,13 @@ import { defineCustomElement } from "@ionic/core/components/ion-tabs.js"; import type { VNode } from "vue"; -import { h, defineComponent, Fragment, isVNode } from "vue"; +import { + h, + defineComponent, + Fragment, + isVNode, + provide, + shallowRef, +} from "vue"; import { IonTab } from "../proxies"; @@ -9,6 +16,12 @@ const DID_CHANGE = "ionTabsDidChange"; // TODO(FW-2969): types +interface TabBarData { + hasRouterOutlet: boolean; + _tabsWillChange: Function; + _tabsDidChange: Function; +} + /** * Vue 3.2.38 fixed an issue where Web Component * names are respected using kebab case instead of pascal case. @@ -24,13 +37,6 @@ const isRouterOutlet = (node: VNode) => { ); }; -const isTabBar = (node: VNode) => { - return ( - node.type && - ((node.type as any).name === "IonTabBar" || node.type === "ion-tab-bar") - ); -}; - const isTab = (node: VNode): boolean => { // The `ion-tab` component was created with the `v-for` directive. if (node.type === Fragment) { @@ -49,7 +55,43 @@ const isTab = (node: VNode): boolean => { export const IonTabs = /*@__PURE__*/ defineComponent({ name: "IonTabs", emits: [WILL_CHANGE, DID_CHANGE], + data() { + return { + hasRouterOutlet: false, + }; + }, setup(props, { slots, emit }) { + const slottedContent: VNode[] | undefined = + slots.default && slots.default(); + let routerOutlet: VNode | undefined = undefined; + + if (slottedContent && slottedContent.length > 0) { + /** + * Developers must pass an ion-router-outlet + * inside of ion-tabs if they want to use + * the history stack or URL updates associated + * with the router. + */ + routerOutlet = slottedContent.find((child: VNode) => + isRouterOutlet(child) + ); + } + + /** + * Tab bar can be used as a standalone component, + * so it cannot be modified directly through + * IonTabs. Instead, data will be passed through + * the provide/inject. + */ + provide( + "tabBarData", + shallowRef({ + hasRouterOutlet: !!routerOutlet, + _tabsWillChange: (tab: string) => emit(WILL_CHANGE, { tab }), + _tabsDidChange: (tab: string) => emit(DID_CHANGE, { tab }), + }) + ); + return { props, slots, @@ -68,9 +110,10 @@ export const IonTabs = /*@__PURE__*/ defineComponent({ defineCustomElement(); }, render() { - const { slots, emit, props } = this; - const slottedContent = slots.default && slots.default(); - let routerOutlet; + const { slots, props } = this; + const slottedContent: VNode[] | undefined = + slots.default && slots.default(); + let routerOutlet: VNode | undefined = undefined; let hasTab = false; if (slottedContent && slottedContent.length > 0) { @@ -78,7 +121,7 @@ export const IonTabs = /*@__PURE__*/ defineComponent({ * Developers must pass an ion-router-outlet * inside of ion-tabs if they want to use * the history stack or URL updates associated - * wit the router. + * with the router. */ routerOutlet = slottedContent.find((child: VNode) => isRouterOutlet(child) @@ -103,30 +146,6 @@ export const IonTabs = /*@__PURE__*/ defineComponent({ ); } - if (slottedContent && slottedContent.length > 0) { - const slottedTabBar = slottedContent.find((child: VNode) => - isTabBar(child) - ); - - if (slottedTabBar) { - if (!slottedTabBar.props) { - slottedTabBar.props = {}; - } - /** - * ionTabsWillChange and ionTabsDidChange are - * fired from `ion-tabs`, so we need to pass these down - * as props so they can fire when the active tab changes. - * TODO: We may want to move logic from the tab bar into here - * so we do not have code split across two components. - */ - slottedTabBar.props._tabsWillChange = (tab: string) => - emit(WILL_CHANGE, { tab }); - slottedTabBar.props._tabsDidChange = (tab: string) => - emit(DID_CHANGE, { tab }); - slottedTabBar.props._hasRouterOutlet = !!routerOutlet; - } - } - if (hasTab) { return h( "ion-tabs",