Skip to content

Commit

Permalink
fix(tabs, tab-bar): use standalone tab bar in Vue, React (#29940)
Browse files Browse the repository at this point in the history
Issue number: resolves #29885, resolves #29924

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

React and Vue:

Tab bar could be a standalone element within `IonTabs` and would
navigate without issues with a router outlet before v8.3:

```tsx
<IonTabs>
  <IonRouterOutlet></IonRouterOutlet>

  <IonTabBar></IonTabBar>
</IonTabs>
```

It would work as if it was written as:

```tsx
<IonTabs>
  <IonRouterOutlet></IonRouterOutlet>

  <IonTabBar slot="bottom">
    <!-- Buttons -->
  </IonTabBar>
</IonTabs>
```

After v8.3, any `ion-tab-bar` that was not a direct child of `ion-tabs`
would lose it's expected behavior when used with a router outlet. If a
user clicked on a tab button, then the content would not be redirected
to that expected view.

React only:

Users can no longer add a `ref` to the `IonRouterOutlet`, it always
returns undefined.

```
<IonTabs>
      <IonRouterOutlet ref={ref}>

     <IonTabBar slot="bottom">
    <!-- Buttons -->
  </IonTabBar>
</IonTabs>
```

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

The fixes were already reviewed through PR
#29925 and PR
#29927. I split them
to make it easier to review.

React and Vue:

The React tabs has been updated to pass data to the tab bar through
context instead of passing it through a ref. By using a context, the
data will be available for the tab bar to use regardless of its level.

React only:

Reverted the logic for `routerOutletRef` and added a comment of the
importance of it.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

N/A
  • Loading branch information
thetaPC authored Oct 16, 2024
1 parent cdb4456 commit b7b383b
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 135 deletions.
29 changes: 25 additions & 4 deletions packages/react/src/components/navigation/IonTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -21,7 +23,7 @@ interface InternalProps extends IonTabBarProps {
forwardedRef?: React.ForwardedRef<HTMLIonIconElement>;
onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void;
routeInfo: RouteInfo;
routerOutletRef?: React.RefObject<HTMLIonRouterOutletElement> | undefined;
tabsContext?: IonTabsContextState;
}

interface TabUrls {
Expand Down Expand Up @@ -183,12 +185,14 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
) {
const tappedTab = this.state.tabs[e.detail.tab];
const originalHref = tappedTab.originalHref;
const hasRouterOutlet = this.props.tabsContext?.hasRouterOutlet;

/**
* If the router outlet is not defined, then the tabs is being used
* as a basic tab navigation without the router. In this case, we
* don't want to update the href else the URL will change.
*/
const currentHref = this.props.routerOutletRef?.current ? e.detail.href : '';
const currentHref = hasRouterOutlet ? e.detail.href : '';
const { activeTab: prevActiveTab } = this.state;

if (onClickFn) {
Expand All @@ -212,7 +216,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
if (this.props.onIonTabsDidChange) {
this.props.onIonTabsDidChange(new CustomEvent('ionTabDidChange', { detail: { tab: e.detail.tab } }));
}
if (this.props.routerOutletRef?.current) {
if (hasRouterOutlet) {
this.setActiveTabOnContext(e.detail.tab);
this.context.changeTab(e.detail.tab, currentHref, e.detail.routeOptions);
}
Expand Down Expand Up @@ -262,12 +266,29 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta

const IonTabBarContainer: React.FC<InternalProps> = React.memo<InternalProps>(({ forwardedRef, ...props }) => {
const context = useContext(NavContext);
const tabsContext = useContext(IonTabsContext);
const tabBarRef = forwardedRef || tabsContext.tabBarProps.ref;
const updatedTabBarProps = {
...tabsContext.tabBarProps,
ref: tabBarRef,
};

return (
<IonTabBarUnwrapped
ref={forwardedRef}
ref={tabBarRef}
{...(props as any)}
routeInfo={props.routeInfo || context.routeInfo || { pathname: window.location.pathname }}
onSetCurrentTab={context.setCurrentTab}
/**
* Tab bar can be used as a standalone component,
* so it cannot be modified directly through
* IonTabs. Instead, props will be passed through
* the context.
*/
tabsContext={{
...tabsContext,
tabBarProps: updatedTabBarProps,
}}
>
{props.children}
</IonTabBarUnwrapped>
Expand Down
121 changes: 46 additions & 75 deletions packages/react/src/components/navigation/IonTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -43,35 +42,30 @@ 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<Props> {
context!: React.ContextType<typeof NavContext>;
/**
* `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<HTMLIonRouterOutletElement> = React.createRef();
selectTabHandler?: (tag: string) => boolean;
tabBarRef = React.createRef<any>();

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) {
Expand All @@ -90,9 +84,32 @@ export const IonTabs = /*@__PURE__*/ (() =>
}
}

renderTabsInner(children: React.ReactNode, outlet: React.ReactElement<{}> | undefined) {
return (
<IonTabsInner {...this.props}>
{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;
})}
</IonTabsInner>
);
}

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;
Expand All @@ -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
Expand All @@ -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,
};

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -186,46 +193,10 @@ export const IonTabs = /*@__PURE__*/ (() =>
<IonTabsContext.Provider value={this.ionTabContextState}>
{this.context.hasIonicRouter() ? (
<PageManager className={className ? `${className}` : ''} routeInfo={this.context.routeInfo} {...props}>
<IonTabsInner {...this.props}>
{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;
})}
</IonTabsInner>
{this.renderTabsInner(children, outlet)}
</PageManager>
) : (
<div className={className ? `${className}` : 'ion-tabs'} {...props} style={hostStyles}>
{tabBar?.props.slot === 'top' ? tabBar : null}
<div style={tabsInner} className="tabs-inner">
{outlet}
</div>
{tabBar?.props.slot === 'bottom' ? tabBar : null}
</div>
this.renderTabsInner(children, outlet)
)}
</IonTabsContext.Provider>
);
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/components/navigation/IonTabsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
onIonTabsWillChange?: (e: CustomEvent) => void;
onIonTabsDidChange?: (e: CustomEvent) => void;
};

export const IonTabsContext = React.createContext<IonTabsContextState>({
activeTab: undefined,
selectTab: () => false,
hasRouterOutlet: false,
tabBarProps: { ref: React.createRef() },
});
Loading

0 comments on commit b7b383b

Please sign in to comment.