Skip to content

Commit 4025098

Browse files
committed
fix(tabs): select correct tab when routes have similar prefixes
1 parent 2ee52d7 commit 4025098

File tree

13 files changed

+363
-3
lines changed

13 files changed

+363
-3
lines changed

packages/react/src/components/navigation/IonTabBar.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ interface IonTabBarState {
4040

4141
// TODO(FW-2959): types
4242

43+
/**
44+
* Checks if pathname matches the tab's href using path segment matching.
45+
* Avoids false matches like /home2 matching /home by requiring exact match
46+
* or a path segment boundary (/).
47+
*/
48+
const matchesTab = (pathname: string, href: string | undefined): boolean => {
49+
if (href === undefined) {
50+
return false;
51+
}
52+
53+
const normalizedHref = href.endsWith('/') && href !== '/' ? href.slice(0, -1) : href;
54+
return pathname === normalizedHref || pathname.startsWith(normalizedHref + '/');
55+
};
56+
4357
class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarState> {
4458
context!: React.ContextType<typeof NavContext>;
4559

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

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

127141
// Check to see if the tab button href has changed, and if so, update it in the tabs state

packages/react/test/base/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Tabs from './pages/Tabs';
2828
import TabsBasic from './pages/TabsBasic';
2929
import NavComponent from './pages/navigation/NavComponent';
3030
import TabsDirectNavigation from './pages/TabsDirectNavigation';
31+
import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes';
3132
import IonModalConditional from './pages/overlay-components/IonModalConditional';
3233
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
3334
import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton';
@@ -67,6 +68,7 @@ const App: React.FC = () => (
6768
<Route path="/tabs" component={Tabs} />
6869
<Route path="/tabs-basic" component={TabsBasic} />
6970
<Route path="/tabs-direct-navigation" component={TabsDirectNavigation} />
71+
<Route path="/tabs-similar-prefixes" component={TabsSimilarPrefixes} />
7072
<Route path="/icons" component={Icons} />
7173
<Route path="/inputs" component={Inputs} />
7274
<Route path="/reorder-group" component={ReorderGroup} />

packages/react/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const Main: React.FC<MainProps> = () => {
4646
<IonItem routerLink="/tabs-direct-navigation">
4747
<IonLabel>Tabs with Direct Navigation</IonLabel>
4848
</IonItem>
49+
<IonItem routerLink="/tabs-similar-prefixes">
50+
<IonLabel>Tabs with Similar Route Prefixes</IonLabel>
51+
</IonItem>
4952
<IonItem routerLink="/icons">
5053
<IonLabel>Icons</IonLabel>
5154
</IonItem>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
IonContent,
3+
IonHeader,
4+
IonIcon,
5+
IonLabel,
6+
IonPage,
7+
IonRouterOutlet,
8+
IonTabBar,
9+
IonTabButton,
10+
IonTabs,
11+
IonTitle,
12+
IonToolbar,
13+
} from '@ionic/react';
14+
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
15+
import React from 'react';
16+
import { Route, Redirect } from 'react-router-dom';
17+
18+
const HomePage: React.FC = () => (
19+
<IonPage data-testid="home-page">
20+
<IonHeader>
21+
<IonToolbar>
22+
<IonTitle>Home</IonTitle>
23+
</IonToolbar>
24+
</IonHeader>
25+
<IonContent>
26+
<div data-testid="home-content">Home Content</div>
27+
</IonContent>
28+
</IonPage>
29+
);
30+
31+
const Home2Page: React.FC = () => (
32+
<IonPage data-testid="home2-page">
33+
<IonHeader>
34+
<IonToolbar>
35+
<IonTitle>Home 2</IonTitle>
36+
</IonToolbar>
37+
</IonHeader>
38+
<IonContent>
39+
<div data-testid="home2-content">Home 2 Content</div>
40+
</IonContent>
41+
</IonPage>
42+
);
43+
44+
const Home3Page: React.FC = () => (
45+
<IonPage data-testid="home3-page">
46+
<IonHeader>
47+
<IonToolbar>
48+
<IonTitle>Home 3</IonTitle>
49+
</IonToolbar>
50+
</IonHeader>
51+
<IonContent>
52+
<div data-testid="home3-content">Home 3 Content</div>
53+
</IonContent>
54+
</IonPage>
55+
);
56+
57+
const TabsSimilarPrefixes: React.FC = () => {
58+
return (
59+
<IonTabs data-testid="tabs-similar-prefixes">
60+
<IonRouterOutlet>
61+
<Redirect exact path="/tabs-similar-prefixes" to="/tabs-similar-prefixes/home" />
62+
<Route path="/tabs-similar-prefixes/home" render={() => <HomePage />} exact={true} />
63+
<Route path="/tabs-similar-prefixes/home2" render={() => <Home2Page />} exact={true} />
64+
<Route path="/tabs-similar-prefixes/home3" render={() => <Home3Page />} exact={true} />
65+
</IonRouterOutlet>
66+
67+
<IonTabBar slot="bottom" data-testid="tab-bar">
68+
<IonTabButton tab="home" href="/tabs-similar-prefixes/home" data-testid="home-tab">
69+
<IonIcon icon={homeOutline}></IonIcon>
70+
<IonLabel>Home</IonLabel>
71+
</IonTabButton>
72+
73+
<IonTabButton tab="home2" href="/tabs-similar-prefixes/home2" data-testid="home2-tab">
74+
<IonIcon icon={radioOutline}></IonIcon>
75+
<IonLabel>Home 2</IonLabel>
76+
</IonTabButton>
77+
78+
<IonTabButton tab="home3" href="/tabs-similar-prefixes/home3" data-testid="home3-tab">
79+
<IonIcon icon={libraryOutline}></IonIcon>
80+
<IonLabel>Home 3</IonLabel>
81+
</IonTabButton>
82+
</IonTabBar>
83+
</IonTabs>
84+
);
85+
};
86+
87+
export default TabsSimilarPrefixes;

packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
11
describe('IonTabs', () => {
2+
/**
3+
* Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3)
4+
* correctly select the matching tab instead of the first prefix match.
5+
*/
6+
describe('Similar Route Prefixes', () => {
7+
it('should select the correct tab when routes have similar prefixes', () => {
8+
cy.visit('/tabs-similar-prefixes/home2');
9+
10+
cy.get('[data-testid="home2-content"]').should('be.visible');
11+
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
12+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
13+
});
14+
15+
it('should select the correct tab when navigating via tab buttons', () => {
16+
cy.visit('/tabs-similar-prefixes/home');
17+
18+
cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected');
19+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
20+
21+
cy.get('[data-testid="home2-tab"]').click();
22+
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
23+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
24+
25+
cy.get('[data-testid="home3-tab"]').click();
26+
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
27+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
28+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
29+
});
30+
31+
it('should select the correct tab when directly navigating to home3', () => {
32+
cy.visit('/tabs-similar-prefixes/home3');
33+
34+
cy.get('[data-testid="home3-content"]').should('be.visible');
35+
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
36+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
37+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
38+
});
39+
});
40+
241
describe('With IonRouterOutlet', () => {
342
beforeEach(() => {
443
cy.visit('/tabs/tab1');

packages/vue/src/components/IonTabBar.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ interface TabBarData {
2424

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

27+
/**
28+
* Checks if pathname matches the tab's href using path segment matching.
29+
* Avoids false matches like /home2 matching /home by requiring exact match
30+
* or a path segment boundary (/).
31+
*/
32+
const matchesTab = (pathname: string, href: string | undefined): boolean => {
33+
if (href === undefined) {
34+
return false;
35+
}
36+
37+
const normalizedHref =
38+
href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href;
39+
return (
40+
pathname === normalizedHref || pathname.startsWith(normalizedHref + "/")
41+
);
42+
};
43+
2744
const getTabs = (nodes: VNode[]) => {
2845
let tabs: VNode[] = [];
2946
nodes.forEach((node: VNode) => {
@@ -135,7 +152,7 @@ export const IonTabBar = defineComponent({
135152
const tabKeys = Object.keys(tabs);
136153
let activeTab = tabKeys.find((key) => {
137154
const href = tabs[key].originalHref;
138-
return currentRoute?.pathname.startsWith(href);
155+
return currentRoute?.pathname && matchesTab(currentRoute.pathname, href);
139156
});
140157

141158
/**

packages/vue/test/base/src/router/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ const routes: Array<RouteRecordRaw> = [
165165
path: '/tabs-basic',
166166
component: () => import('@/views/TabsBasic.vue')
167167
},
168+
{
169+
path: '/tabs-similar-prefixes/',
170+
component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'),
171+
children: [
172+
{
173+
path: '',
174+
redirect: '/tabs-similar-prefixes/home'
175+
},
176+
{
177+
path: 'home',
178+
component: () => import('@/views/tabs-similar-prefixes/Home.vue'),
179+
},
180+
{
181+
path: 'home2',
182+
component: () => import('@/views/tabs-similar-prefixes/Home2.vue'),
183+
},
184+
{
185+
path: 'home3',
186+
component: () => import('@/views/tabs-similar-prefixes/Home3.vue'),
187+
}
188+
]
189+
},
168190
]
169191

170192
const router = createRouter({

packages/vue/test/base/src/views/Home.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
<ion-item router-link="/tabs-basic" id="tab-basic">
5151
<ion-label>Tabs with Basic Navigation</ion-label>
5252
</ion-item>
53+
<ion-item router-link="/tabs-similar-prefixes" id="tabs-similar-prefixes">
54+
<ion-label>Tabs with Similar Route Prefixes</ion-label>
55+
</ion-item>
5356
<ion-item router-link="/lifecycle" id="lifecycle">
5457
<ion-label>Lifecycle</ion-label>
5558
</ion-item>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<template>
2+
<ion-page data-pageid="home">
3+
<ion-header>
4+
<ion-toolbar>
5+
<ion-title>Home</ion-title>
6+
</ion-toolbar>
7+
</ion-header>
8+
<ion-content>
9+
<div data-testid="home-content">Home Content</div>
10+
</ion-content>
11+
</ion-page>
12+
</template>
13+
14+
<script lang="ts">
15+
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
16+
import { defineComponent } from 'vue';
17+
18+
export default defineComponent({
19+
components: { IonPage, IonHeader, IonToolbar, IonTitle, IonContent },
20+
});
21+
</script>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<template>
2+
<ion-page data-pageid="home2">
3+
<ion-header>
4+
<ion-toolbar>
5+
<ion-title>Home 2</ion-title>
6+
</ion-toolbar>
7+
</ion-header>
8+
<ion-content>
9+
<div data-testid="home2-content">Home 2 Content</div>
10+
</ion-content>
11+
</ion-page>
12+
</template>
13+
14+
<script lang="ts">
15+
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
16+
import { defineComponent } from 'vue';
17+
18+
export default defineComponent({
19+
components: { IonPage, IonHeader, IonToolbar, IonTitle, IonContent },
20+
});
21+
</script>

0 commit comments

Comments
 (0)