Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/react/src/components/navigation/IonTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ interface IonTabBarState {

// TODO(FW-2959): types

/**
* Checks if pathname matches the tab's href using path segment matching.
* Avoids false matches like /home2 matching /home by requiring exact match
* or a path segment boundary (/).
*/
const matchesTab = (pathname: string, href: string | undefined): boolean => {
if (href === undefined) {
return false;
}

const normalizedHref = href.endsWith('/') && href !== '/' ? href.slice(0, -1) : href;
return pathname === normalizedHref || pathname.startsWith(normalizedHref + '/');
};

class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarState> {
context!: React.ContextType<typeof NavContext>;

Expand Down Expand Up @@ -79,7 +93,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
const tabKeys = Object.keys(tabs);
const activeTab = tabKeys.find((key) => {
const href = tabs[key].originalHref;
return this.props.routeInfo!.pathname.startsWith(href);
return matchesTab(this.props.routeInfo!.pathname, href);
});

if (activeTab) {
Expand Down Expand Up @@ -121,7 +135,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
const tabKeys = Object.keys(state.tabs);
const activeTab = tabKeys.find((key) => {
const href = state.tabs[key].originalHref;
return props.routeInfo!.pathname.startsWith(href);
return matchesTab(props.routeInfo!.pathname, href);
});

// Check to see if the tab button href has changed, and if so, update it in the tabs state
Expand Down
2 changes: 2 additions & 0 deletions packages/react/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import Tabs from './pages/Tabs';
import TabsBasic from './pages/TabsBasic';
import NavComponent from './pages/navigation/NavComponent';
import TabsDirectNavigation from './pages/TabsDirectNavigation';
import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes';
import IonModalConditional from './pages/overlay-components/IonModalConditional';
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton';
Expand Down Expand Up @@ -67,6 +68,7 @@ const App: React.FC = () => (
<Route path="/tabs" component={Tabs} />
<Route path="/tabs-basic" component={TabsBasic} />
<Route path="/tabs-direct-navigation" component={TabsDirectNavigation} />
<Route path="/tabs-similar-prefixes" component={TabsSimilarPrefixes} />
<Route path="/icons" component={Icons} />
<Route path="/inputs" component={Inputs} />
<Route path="/reorder-group" component={ReorderGroup} />
Expand Down
3 changes: 3 additions & 0 deletions packages/react/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const Main: React.FC<MainProps> = () => {
<IonItem routerLink="/tabs-direct-navigation">
<IonLabel>Tabs with Direct Navigation</IonLabel>
</IonItem>
<IonItem routerLink="/tabs-similar-prefixes">
<IonLabel>Tabs with Similar Route Prefixes</IonLabel>
</IonItem>
<IonItem routerLink="/icons">
<IonLabel>Icons</IonLabel>
</IonItem>
Expand Down
87 changes: 87 additions & 0 deletions packages/react/test/base/src/pages/TabsSimilarPrefixes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
IonContent,
IonHeader,
IonIcon,
IonLabel,
IonPage,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
import React from 'react';
import { Route, Redirect } from 'react-router-dom';

const HomePage: React.FC = () => (
<IonPage data-testid="home-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home-content">Home Content</div>
</IonContent>
</IonPage>
);

const Home2Page: React.FC = () => (
<IonPage data-testid="home2-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home 2</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home2-content">Home 2 Content</div>
</IonContent>
</IonPage>
);

const Home3Page: React.FC = () => (
<IonPage data-testid="home3-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home 3</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home3-content">Home 3 Content</div>
</IonContent>
</IonPage>
);

const TabsSimilarPrefixes: React.FC = () => {
return (
<IonTabs data-testid="tabs-similar-prefixes">
<IonRouterOutlet>
<Redirect exact path="/tabs-similar-prefixes" to="/tabs-similar-prefixes/home" />
<Route path="/tabs-similar-prefixes/home" render={() => <HomePage />} exact={true} />
<Route path="/tabs-similar-prefixes/home2" render={() => <Home2Page />} exact={true} />
<Route path="/tabs-similar-prefixes/home3" render={() => <Home3Page />} exact={true} />
</IonRouterOutlet>

<IonTabBar slot="bottom" data-testid="tab-bar">
<IonTabButton tab="home" href="/tabs-similar-prefixes/home" data-testid="home-tab">
<IonIcon icon={homeOutline}></IonIcon>
<IonLabel>Home</IonLabel>
</IonTabButton>

<IonTabButton tab="home2" href="/tabs-similar-prefixes/home2" data-testid="home2-tab">
<IonIcon icon={radioOutline}></IonIcon>
<IonLabel>Home 2</IonLabel>
</IonTabButton>

<IonTabButton tab="home3" href="/tabs-similar-prefixes/home3" data-testid="home3-tab">
<IonIcon icon={libraryOutline}></IonIcon>
<IonLabel>Home 3</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
};

export default TabsSimilarPrefixes;
41 changes: 41 additions & 0 deletions packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,45 @@
describe('IonTabs', () => {
/**
* Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3)
* correctly select the matching tab instead of the first prefix match.
*
* @see https://github.com/ionic-team/ionic-framework/issues/30448
*/
describe('Similar Route Prefixes', () => {
it('should select the correct tab when routes have similar prefixes', () => {
cy.visit('/tabs-similar-prefixes/home2');

cy.get('[data-testid="home2-content"]').should('be.visible');
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
});

it('should select the correct tab when navigating via tab buttons', () => {
cy.visit('/tabs-similar-prefixes/home');

cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');

cy.get('[data-testid="home2-tab"]').click();
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');

cy.get('[data-testid="home3-tab"]').click();
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});

it('should select the correct tab when directly navigating to home3', () => {
cy.visit('/tabs-similar-prefixes/home3');

cy.get('[data-testid="home3-content"]').should('be.visible');
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});
});

describe('With IonRouterOutlet', () => {
beforeEach(() => {
cy.visit('/tabs/tab1');
Expand Down
21 changes: 20 additions & 1 deletion packages/vue/src/components/IonTabBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ interface TabBarData {

const isTabButton = (child: any) => child.type?.name === "IonTabButton";

/**
* Checks if pathname matches the tab's href using path segment matching.
* Avoids false matches like /home2 matching /home by requiring exact match
* or a path segment boundary (/).
*/
const matchesTab = (pathname: string, href: string | undefined): boolean => {
if (href === undefined) {
return false;
}

const normalizedHref =
href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href;
return (
pathname === normalizedHref || pathname.startsWith(normalizedHref + "/")
);
};

const getTabs = (nodes: VNode[]) => {
let tabs: VNode[] = [];
nodes.forEach((node: VNode) => {
Expand Down Expand Up @@ -135,7 +152,9 @@ export const IonTabBar = defineComponent({
const tabKeys = Object.keys(tabs);
let activeTab = tabKeys.find((key) => {
const href = tabs[key].originalHref;
return currentRoute?.pathname.startsWith(href);
return (
currentRoute?.pathname && matchesTab(currentRoute.pathname, href)
);
});

/**
Expand Down
22 changes: 22 additions & 0 deletions packages/vue/test/base/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ const routes: Array<RouteRecordRaw> = [
path: '/tabs-basic',
component: () => import('@/views/TabsBasic.vue')
},
{
path: '/tabs-similar-prefixes/',
component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'),
children: [
{
path: '',
redirect: '/tabs-similar-prefixes/home'
},
{
path: 'home',
component: () => import('@/views/tabs-similar-prefixes/Home.vue'),
},
{
path: 'home2',
component: () => import('@/views/tabs-similar-prefixes/Home2.vue'),
},
{
path: 'home3',
component: () => import('@/views/tabs-similar-prefixes/Home3.vue'),
}
]
},
]

const router = createRouter({
Expand Down
3 changes: 3 additions & 0 deletions packages/vue/test/base/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
<ion-item router-link="/tabs-basic" id="tab-basic">
<ion-label>Tabs with Basic Navigation</ion-label>
</ion-item>
<ion-item router-link="/tabs-similar-prefixes" id="tabs-similar-prefixes">
<ion-label>Tabs with Similar Route Prefixes</ion-label>
</ion-item>
<ion-item router-link="/lifecycle" id="lifecycle">
<ion-label>Lifecycle</ion-label>
</ion-item>
Expand Down
16 changes: 16 additions & 0 deletions packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home">
<ion-header>
<ion-toolbar>
<ion-title>Home</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home-content">Home Content</div>
</ion-content>
</ion-page>
</template>

<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home2">
<ion-header>
<ion-toolbar>
<ion-title>Home 2</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home2-content">Home 2 Content</div>
</ion-content>
</ion-page>
</template>

<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home3">
<ion-header>
<ion-toolbar>
<ion-title>Home 3</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home3-content">Home 3 Content</div>
</ion-content>
</ion-page>
</template>

<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<template>
<ion-page data-pageid="tabs-similar-prefixes">
<ion-content>
<ion-tabs id="tabs-similar-prefixes">
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar slot="bottom" data-testid="tab-bar">
<ion-tab-button
tab="home"
href="/tabs-similar-prefixes/home"
data-testid="home-tab"
id="tab-button-home"
>
<ion-icon :icon="homeOutline" />
<ion-label>Home</ion-label>
</ion-tab-button>

<ion-tab-button
tab="home2"
href="/tabs-similar-prefixes/home2"
data-testid="home2-tab"
id="tab-button-home2"
>
<ion-icon :icon="radioOutline" />
<ion-label>Home 2</ion-label>
</ion-tab-button>

<ion-tab-button
tab="home3"
href="/tabs-similar-prefixes/home3"
data-testid="home3-tab"
id="tab-button-home3"
>
<ion-icon :icon="libraryOutline" />
<ion-label>Home 3</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
</ion-content>
</ion-page>
</template>

<script setup lang="ts">
import {
IonContent,
IonIcon,
IonLabel,
IonPage,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/vue';
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
</script>
Loading
Loading