From aab7f0143baabffc098b57299aeffff077ebf9a7 Mon Sep 17 00:00:00 2001 From: Dimitri Kreik Date: Thu, 15 Oct 2026 16:51:15 +0200 Subject: [PATCH] feat(tabs): add tabTap config Add a `tabTap` config option that scrolls the content of a tab to the top when its already selected tab button is pressed. The content only scrolls when the tab is at its root page. If the tab is on a child page, the existing behavior is kept. Supported in ion-tabs, Angular, React, and Vue. References #22558 --- core/src/components/tabs/tabs.tsx | 27 ++++- .../components/tabs/test/tab-tap/index.html | 85 ++++++++++++++ .../components/tabs/test/tab-tap/tabs.e2e.ts | 72 ++++++++++++ core/src/utils/config.ts | 7 ++ .../src/common/directives/navigation/tabs.ts | 7 ++ .../base/e2e/src/standalone/tab-tap.spec.ts | 93 +++++++++++++++ .../standalone/app-standalone/app.routes.ts | 10 ++ .../home-page/home-page.component.html | 5 + .../tab-tap/tab-tap-child.component.ts | 22 ++++ .../tab-tap/tab-tap-home.component.ts | 21 ++++ .../tab-tap/tab-tap-settings.component.ts | 17 +++ .../standalone/tab-tap/tab-tap.component.ts | 27 +++++ .../src/components/navigation/IonTabBar.tsx | 14 +++ packages/react/test/base/src/App.tsx | 2 + .../react/test/base/src/pages/TabsTabTap.tsx | 69 +++++++++++ .../test/base/tests/e2e/specs/tabs/tabs.cy.ts | 57 +++++++++ packages/vue/src/components/IonTabButton.ts | 13 ++ packages/vue/test/base/src/router/index.ts | 22 ++++ .../test/base/src/views/tab-tap/TabTap.vue | 46 ++++++++ .../base/src/views/tab-tap/TabTapChild.vue | 43 +++++++ .../base/src/views/tab-tap/TabTapHome.vue | 46 ++++++++ .../base/src/views/tab-tap/TabTapOther.vue | 43 +++++++ .../base/tests/e2e/playwright/tab-tap.spec.ts | 111 ++++++++++++++++++ 23 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 core/src/components/tabs/test/tab-tap/index.html create mode 100644 core/src/components/tabs/test/tab-tap/tabs.e2e.ts create mode 100644 packages/angular/test/base/e2e/src/standalone/tab-tap.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-child.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-home.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-settings.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tab-tap/tab-tap.component.ts create mode 100644 packages/react/test/base/src/pages/TabsTabTap.tsx create mode 100644 packages/vue/test/base/src/views/tab-tap/TabTap.vue create mode 100644 packages/vue/test/base/src/views/tab-tap/TabTapChild.vue create mode 100644 packages/vue/test/base/src/views/tab-tap/TabTapHome.vue create mode 100644 packages/vue/test/base/src/views/tab-tap/TabTapOther.vue create mode 100644 packages/vue/test/base/tests/e2e/playwright/tab-tap.spec.ts diff --git a/core/src/components/tabs/tabs.tsx b/core/src/components/tabs/tabs.tsx index 20f4d84d136..1f320eb9dfd 100644 --- a/core/src/components/tabs/tabs.tsx +++ b/core/src/components/tabs/tabs.tsx @@ -2,6 +2,7 @@ import type { EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core'; import { printIonError } from '@utils/logging'; +import { config } from '../../global/config'; import type { NavOutlet, RouteID, RouteWrite } from '../router/utils/interface'; import type { TabButtonClickEventDetail } from '../tab-bar/tab-bar-interface'; @@ -205,7 +206,7 @@ export class Tabs implements NavOutlet { } private onTabClicked = (ev: CustomEvent) => { - const { href, tab } = ev.detail; + const { href, tab, selected } = ev.detail; if (this.useRouter && href !== undefined) { const router = document.querySelector('ion-router'); if (router) { @@ -214,8 +215,32 @@ export class Tabs implements NavOutlet { } else { this.select(tab); } + + if (selected && config.getBoolean('tabTap', false)) { + this.scrollSelectedTabToTop(); + } }; + /** + * Scrolls the content of the selected tab to the top. + * If the tab contains an `ion-nav` that is not at its + * root page, the content is not scrolled. + */ + private async scrollSelectedTabToTop() { + const { selectedTab } = this; + if (selectedTab === undefined) { + return; + } + + const nav = selectedTab.querySelector('ion-nav'); + if (nav !== null && (await nav.getLength()) > 1) { + return; + } + + const page = nav !== null ? (await nav.getActive())?.element : selectedTab; + page?.querySelector('ion-content')?.scrollToTop(300); + } + render() { return ( diff --git a/core/src/components/tabs/test/tab-tap/index.html b/core/src/components/tabs/test/tab-tap/index.html new file mode 100644 index 00000000000..506e6ed15cb --- /dev/null +++ b/core/src/components/tabs/test/tab-tap/index.html @@ -0,0 +1,85 @@ + + + + + Tabs - Tab Tap + + + + + + + + + + + + + + + + + + + Tab Two + + + +
Tab Two
+
+
+ + + + Tab One + + + + Tab Two + + +
+
+ + + + diff --git a/core/src/components/tabs/test/tab-tap/tabs.e2e.ts b/core/src/components/tabs/test/tab-tap/tabs.e2e.ts new file mode 100644 index 00000000000..db92f58b207 --- /dev/null +++ b/core/src/components/tabs/test/tab-tap/tabs.e2e.ts @@ -0,0 +1,72 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +const getScrollTop = (content: Locator) => { + return content.evaluate(async (el: HTMLIonContentElement) => (await el.getScrollElement()).scrollTop); +}; + +const scrollContent = async (content: Locator) => { + await content.evaluate((el: HTMLIonContentElement) => el.scrollToPoint(0, 500)); + await expect.poll(() => getScrollTop(content)).toBe(500); +}; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tabs: tabTap'), () => { + test('should scroll the content to the top when pressing the selected tab button', async ({ page }) => { + await page.goto('/src/components/tabs/test/tab-tap?ionic:tabTap=true', config); + + const content = page.locator('page-one ion-content'); + await scrollContent(content); + + await page.click('ion-tab-button[tab="tab-one"]'); + + await expect.poll(() => getScrollTop(content)).toBe(0); + }); + + test('should scroll the content of a selected tab without a nav to the top', async ({ page }) => { + await page.goto('/src/components/tabs/test/tab-tap?ionic:tabTap=true', config); + + await page.click('ion-tab-button[tab="tab-two"]'); + + const content = page.locator('ion-tab[tab="tab-two"] ion-content'); + await expect(content).toBeVisible(); + await scrollContent(content); + + await page.click('ion-tab-button[tab="tab-two"]'); + + await expect.poll(() => getScrollTop(content)).toBe(0); + }); + + test('should not scroll the content when the nav is not at its root page', async ({ page }) => { + await page.goto('/src/components/tabs/test/tab-tap?ionic:tabTap=true', config); + + await page.click('#push-page-two'); + + const content = page.locator('page-two ion-content'); + await expect(content).toBeVisible(); + await scrollContent(content); + + await page.click('ion-tab-button[tab="tab-one"]'); + + // Wait longer than the scroll animation so that a scroll would have finished + await page.waitForTimeout(500); + + expect(await getScrollTop(content)).toBe(500); + }); + + test('should not scroll the content when tabTap is disabled', async ({ page }) => { + await page.goto('/src/components/tabs/test/tab-tap', config); + + const content = page.locator('page-one ion-content'); + await scrollContent(content); + + await page.click('ion-tab-button[tab="tab-one"]'); + + // Wait longer than the scroll animation so that a scroll would have finished + await page.waitForTimeout(500); + + expect(await getScrollTop(content)).toBe(500); + }); + }); +}); diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index 6a841a3fcf2..14a3795c174 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -35,6 +35,13 @@ export interface IonicConfig { */ statusTap?: boolean; + /** + * Whether pressing the already selected tab button should scroll the content + * of the tab to the top. The content only scrolls when the tab is at its root page. + * Defaults to `false`. + */ + tabTap?: boolean; + /** * Overrides the default icon in all `` components. */ diff --git a/packages/angular/src/common/directives/navigation/tabs.ts b/packages/angular/src/common/directives/navigation/tabs.ts index 6f04e927913..1ea5026c6be 100644 --- a/packages/angular/src/common/directives/navigation/tabs.ts +++ b/packages/angular/src/common/directives/navigation/tabs.ts @@ -5,6 +5,7 @@ import { ElementRef, EventEmitter, HostListener, + inject, Output, ViewChild, AfterViewInit, @@ -12,6 +13,7 @@ import { } from '@angular/core'; import type { Params } from '@angular/router'; +import { Config } from '../../providers/config'; import { NavController } from '../../providers/nav-controller'; import { StackDidChangeEvent, StackWillChangeEvent } from './stack-utils'; @@ -88,6 +90,8 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC private selectedTab?: { tab: string }; private leavingTab?: any; + private config = inject(Config); + constructor(private navCtrl: NavController) {} ngAfterViewInit(): void { @@ -214,6 +218,9 @@ export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterC // If on root tab, do not navigate to root tab again if (activeView?.url === tabRootUrl) { + if (this.config.getBoolean('tabTap', false)) { + activeView.element.querySelector('ion-content')?.scrollToTop(300); + } return; } diff --git a/packages/angular/test/base/e2e/src/standalone/tab-tap.spec.ts b/packages/angular/test/base/e2e/src/standalone/tab-tap.spec.ts new file mode 100644 index 00000000000..9d64222a769 --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/tab-tap.spec.ts @@ -0,0 +1,93 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { ionPageVisible } from '../../utils/test-utils'; + +const getScrollTop = (page: Page, selector: string) => + page.evaluate(async (selector) => { + const content = document.querySelector(selector) as HTMLIonContentElement; + return (await content.getScrollElement()).scrollTop; + }, selector); + +const scrollDown = async (page: Page, selector: string) => { + await page.evaluate( + (selector) => (document.querySelector(selector) as HTMLIonContentElement).scrollToPoint(0, 500), + selector + ); + await expect.poll(() => getScrollTop(page, selector)).toBe(500); +}; + +/** + * Counts the calls to `ion-content`'s `scrollToTop` so that tests can verify + * whether a scroll was requested, even if the scrolled page is removed afterwards. + */ +const trackScrollToTop = (page: Page) => + page.evaluate(() => { + const win = window as any; + const proto = customElements.get('ion-content')!.prototype; + const scrollToTop = proto.scrollToTop; + + win.scrollToTopCalls = 0; + proto.scrollToTop = function (...args: any[]) { + win.scrollToTopCalls++; + return scrollToTop.apply(this, args); + }; + }); + +const getScrollToTopCalls = (page: Page) => page.evaluate(() => (window as any).scrollToTopCalls); + +const enableTabTap = (page: Page) => page.evaluate(() => (window as any).Ionic.config.set('tabTap', true)); + +test.describe('Tabs: tabTap', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/tab-tap/home'); + await ionPageVisible(page, 'app-tab-tap-home'); + }); + + test('should scroll to the top when pressing the active tab button at the tab root', async ({ page }) => { + await enableTabTap(page); + await scrollDown(page, 'app-tab-tap-home ion-content'); + + await page.locator('ion-tab-button[tab="home"]').click(); + + await expect.poll(() => getScrollTop(page, 'app-tab-tap-home ion-content')).toBe(0); + }); + + test('should go back to the tab root without scrolling when pressing the active tab button on a child page', async ({ + page, + }) => { + await enableTabTap(page); + await trackScrollToTop(page); + await page.locator('#go-child').click(); + await ionPageVisible(page, 'app-tab-tap-child'); + + await page.locator('ion-tab-button[tab="home"]').click(); + + await ionPageVisible(page, 'app-tab-tap-home'); + await expect(page).toHaveURL(/\/standalone\/tab-tap\/home$/); + expect(await getScrollToTopCalls(page)).toBe(0); + }); + + test('should not scroll when pressing a different tab button', async ({ page }) => { + await enableTabTap(page); + await scrollDown(page, 'app-tab-tap-home ion-content'); + + await page.locator('ion-tab-button[tab="settings"]').click(); + await ionPageVisible(page, 'app-tab-tap-settings'); + await page.locator('ion-tab-button[tab="home"]').click(); + await ionPageVisible(page, 'app-tab-tap-home'); + + expect(await getScrollTop(page, 'app-tab-tap-home ion-content')).toBe(500); + }); + + test('should not scroll when tabTap is not enabled', async ({ page }) => { + await trackScrollToTop(page); + await scrollDown(page, 'app-tab-tap-home ion-content'); + + await page.locator('ion-tab-button[tab="home"]').click(); + + // The page is already at the tab root, so there is no navigation to wait for + await page.waitForTimeout(500); + expect(await getScrollToTopCalls(page)).toBe(0); + expect(await getScrollTop(page, 'app-tab-tap-home ion-content')).toBe(500); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 1e9349293fc..ac1bdf1e4f1 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -100,6 +100,16 @@ export const routes: Routes = [ } ] }, + { path: 'tab-tap', redirectTo: '/standalone/tab-tap/home', pathMatch: 'full' }, + { + path: 'tab-tap', + loadComponent: () => import('../tab-tap/tab-tap.component').then(c => c.TabTapComponent), + children: [ + { path: 'home', loadComponent: () => import('../tab-tap/tab-tap-home.component').then(c => c.TabTapHomeComponent) }, + { path: 'home/child', loadComponent: () => import('../tab-tap/tab-tap-child.component').then(c => c.TabTapChildComponent) }, + { path: 'settings', loadComponent: () => import('../tab-tap/tab-tap-settings.component').then(c => c.TabTapSettingsComponent) } + ] + }, { path: 'tab-lifecycle-outside', loadComponent: () => import('../tab-lifecycle/tab-lifecycle-outside.component').then(c => c.TabLifecycleOutsideComponent) }, { path: 'swipe-gesture-disabled', diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 738a3f335ca..736afd21d84 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -114,6 +114,11 @@ Tab Lifecycle Test + + + Tab Tap Test + + diff --git a/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-child.component.ts b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-child.component.ts new file mode 100644 index 00000000000..cf670a9fe29 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-child.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { IonBackButton, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-tap-child', + template: ` + + + + + + Child Page + + + +
+
+ `, + standalone: true, + imports: [IonBackButton, IonButtons, IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class TabTapChildComponent {} diff --git a/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-home.component.ts b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-home.component.ts new file mode 100644 index 00000000000..97beb6ccabf --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-home.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { IonButton, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-tap-home', + template: ` + + + Home Tab + + + + Go to Child +
+
+ `, + standalone: true, + imports: [RouterLink, IonButton, IonContent, IonHeader, IonRouterLink, IonTitle, IonToolbar], +}) +export class TabTapHomeComponent {} diff --git a/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-settings.component.ts b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-settings.component.ts new file mode 100644 index 00000000000..43dfb336690 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap-settings.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { IonContent, IonHeader, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-tab-tap-settings', + template: ` + + + Settings Tab + + + + `, + standalone: true, + imports: [IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class TabTapSettingsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap.component.ts b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap.component.ts new file mode 100644 index 00000000000..239a392fd1e --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tab-tap/tab-tap.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs } from '@ionic/angular'; +import { addIcons } from 'ionicons'; +import { square, triangle } from 'ionicons/icons'; + +addIcons({ square, triangle }); + +@Component({ + selector: 'app-tab-tap', + template: ` + + + + + Home + + + + Settings + + + + `, + standalone: true, + imports: [IonIcon, IonLabel, IonTabBar, IonTabButton, IonTabs], +}) +export class TabTapComponent {} diff --git a/packages/react/src/components/navigation/IonTabBar.tsx b/packages/react/src/components/navigation/IonTabBar.tsx index 8c78f8cfc84..984d23ac182 100644 --- a/packages/react/src/components/navigation/IonTabBar.tsx +++ b/packages/react/src/components/navigation/IonTabBar.tsx @@ -3,6 +3,7 @@ import React, { useContext } from 'react'; import { NavContext } from '../../contexts/NavContext'; import type { RouteInfo } from '../../models'; +import { getConfig } from '../../utils/config'; import type { IonicReactProps } from '../IonicReactProps'; import { IonTabBarInner } from '../inner-proxies'; import { createForwardRef } from '../utils'; @@ -225,6 +226,8 @@ class IonTabBarUnwrapped extends React.PureComponent .ion-page:not(.ion-page-hidden)'); + + page?.querySelector('ion-content')?.scrollToTop(300); + } + private renderTabButton(activeTab: string | null | undefined) { return ( child: diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx index ab265036f7a..64852ccf396 100644 --- a/packages/react/test/base/src/App.tsx +++ b/packages/react/test/base/src/App.tsx @@ -30,6 +30,7 @@ import NavComponent from './pages/navigation/NavComponent'; import NavModalComponent from './pages/navigation/NavModalComponent'; import TabsDirectNavigation from './pages/TabsDirectNavigation'; import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes'; +import TabsTabTap from './pages/TabsTabTap'; import IonModalConditional from './pages/overlay-components/IonModalConditional'; import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling'; import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton'; @@ -72,6 +73,7 @@ const App: React.FC = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/react/test/base/src/pages/TabsTabTap.tsx b/packages/react/test/base/src/pages/TabsTabTap.tsx new file mode 100644 index 00000000000..a9f85c1b714 --- /dev/null +++ b/packages/react/test/base/src/pages/TabsTabTap.tsx @@ -0,0 +1,69 @@ +import { + IonBackButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonLabel, + IonPage, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonTabs, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import React from 'react'; +import { Navigate, Route } from 'react-router'; + +const HomePage: React.FC = () => ( + + + + Home + + + + + Detail + +
Home Content
+
+
+); + +const HomeDetailPage: React.FC = () => ( + + + + + + + Detail + + + +
Detail Content
+
+
+); + +const TabsTabTap: React.FC = () => { + return ( + + + } /> + } /> + } /> + + + + + Home + + + + ); +}; + +export default TabsTabTap; diff --git a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts index 2d8b4302cd0..05e98f18e70 100644 --- a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts @@ -95,4 +95,61 @@ describe('IonTabs', () => { cy.get('@consoleLog').should('be.calledWith', 'onIonTabsDidChange:', 'tab2'); }); }); + + /** + * Verifies that pressing the active tab button scrolls the content + * to the top when the `tabTap` config is enabled. + * + * @see https://github.com/ionic-team/ionic-framework/issues/22558 + */ + describe('tabTap', () => { + const getScrollTop = ($content: JQuery) => + $content[0].shadowRoot!.querySelector('.inner-scroll')!.scrollTop; + + const scrollContentDown = (selector: string) => { + cy.get(selector).then(($content) => ($content[0] as HTMLIonContentElement).scrollToPoint(0, 500)); + cy.get(selector).should(($content) => expect(getScrollTop($content)).to.be.greaterThan(0)); + }; + + const spyOnScrollToTop = (selector: string) => { + cy.get(selector).then(($content) => cy.spy($content[0], 'scrollToTop').as('scrollToTop')); + }; + + const setTabTap = (value: boolean) => { + cy.window().then((win) => (win as any).Ionic.config.set('tabTap', value)); + }; + + beforeEach(() => { + cy.visit('/tabs-tab-tap/home'); + }); + + it('should scroll to the top when pressing the active tab button at the root of the tab', () => { + setTabTap(true); + scrollContentDown('[data-testid="home-page"] ion-content'); + + cy.get('[data-testid="home-tab"]').click(); + + cy.get('[data-testid="home-page"] ion-content').should(($content) => expect(getScrollTop($content)).to.equal(0)); + }); + + it('should not scroll to the top when tabTap is not enabled', () => { + spyOnScrollToTop('[data-testid="home-page"] ion-content'); + + cy.get('[data-testid="home-tab"]').click(); + + cy.get('@scrollToTop').should('not.have.been.called'); + }); + + it('should navigate to the root of the tab without scrolling the child page', () => { + setTabTap(true); + cy.get('[data-testid="detail-link"]').click(); + cy.get('[data-testid="home-detail-page"]').should('be.visible'); + spyOnScrollToTop('[data-testid="home-detail-page"] ion-content'); + + cy.get('[data-testid="home-tab"]').click(); + + cy.location('pathname').should('eq', '/tabs-tab-tap/home'); + cy.get('@scrollToTop').should('not.have.been.called'); + }); + }); }); diff --git a/packages/vue/src/components/IonTabButton.ts b/packages/vue/src/components/IonTabButton.ts index 78eee3f29b8..fc18984a7ac 100644 --- a/packages/vue/src/components/IonTabButton.ts +++ b/packages/vue/src/components/IonTabButton.ts @@ -1,6 +1,8 @@ import { defineCustomElement } from "@ionic/core/components/ion-tab-button.js"; import { h, defineComponent, inject } from "vue"; +import { getConfig } from "../utils"; + export const IonTabButton = /*@__PURE__*/ defineComponent({ name: "IonTabButton", props: { @@ -76,6 +78,17 @@ export const IonTabButton = /*@__PURE__*/ defineComponent({ if (prevActiveTab === tab) { if (originalHref !== currentHref) { ionRouter.resetTab(tab, originalHref); + } else if ( + hasRouterOutlet && + getConfig()?.getBoolean("tabTap", false) + ) { + const page = (ev.currentTarget as HTMLElement) + .closest("ion-tabs") + ?.querySelector( + "ion-router-outlet > .ion-page:not(.ion-page-hidden)" + ); + + page?.querySelector("ion-content")?.scrollToTop(300); } } else { ionRouter.changeTab(tab, currentHref); diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index ce5e63de52e..e65e14d6f32 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -258,6 +258,28 @@ const routes: Array = [ path: '/tab-lifecycle-outside', component: () => import('@/views/tab-lifecycle/TabLifecycleOutside.vue') }, + { + path: '/tab-tap/', + component: () => import('@/views/tab-tap/TabTap.vue'), + children: [ + { + path: '', + redirect: '/tab-tap/home' + }, + { + path: 'home', + component: () => import('@/views/tab-tap/TabTapHome.vue') + }, + { + path: 'home/child', + component: () => import('@/views/tab-tap/TabTapChild.vue') + }, + { + path: 'other', + component: () => import('@/views/tab-tap/TabTapOther.vue') + } + ] + }, { path: '/tabs-similar-prefixes/', component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'), diff --git a/packages/vue/test/base/src/views/tab-tap/TabTap.vue b/packages/vue/test/base/src/views/tab-tap/TabTap.vue new file mode 100644 index 00000000000..b7da3253804 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-tap/TabTap.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-tap/TabTapChild.vue b/packages/vue/test/base/src/views/tab-tap/TabTapChild.vue new file mode 100644 index 00000000000..2e83a6a2529 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-tap/TabTapChild.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-tap/TabTapHome.vue b/packages/vue/test/base/src/views/tab-tap/TabTapHome.vue new file mode 100644 index 00000000000..7c33ebf8c59 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-tap/TabTapHome.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/vue/test/base/src/views/tab-tap/TabTapOther.vue b/packages/vue/test/base/src/views/tab-tap/TabTapOther.vue new file mode 100644 index 00000000000..9827e807c26 --- /dev/null +++ b/packages/vue/test/base/src/views/tab-tap/TabTapOther.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/playwright/tab-tap.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tab-tap.spec.ts new file mode 100644 index 00000000000..7e96fef1119 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tab-tap.spec.ts @@ -0,0 +1,111 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./utils/test-base"; +import { ionPageHidden, ionPageVisible, tabClick } from "./utils/test-utils"; + +const enableTabTap = (page: Page) => + page.evaluate(() => (window as any).Ionic.config.set("tabTap", true)); + +const scrollContent = async (page: Page, pageId: string) => { + const content = page.locator( + `.ion-page[data-pageid="${pageId}"] ion-content` + ); + await content.evaluate(async (el: HTMLIonContentElement) => { + const scrollEl = await el.getScrollElement(); + scrollEl.scrollTop = 500; + }); + await expect.poll(() => getScrollTop(page, pageId)).toBeGreaterThan(0); +}; + +const getScrollTop = (page: Page, pageId: string) => + page + .locator(`.ion-page[data-pageid="${pageId}"] ion-content`) + .evaluate( + async (el: HTMLIonContentElement) => + (await el.getScrollElement()).scrollTop + ); + +/** + * Counts the calls to `ion-content`'s `scrollToTop` so that tests can verify + * whether a scroll was requested, even if the scrolled page is removed afterwards. + */ +const trackScrollToTop = (page: Page) => + page.evaluate(() => { + const win = window as any; + const proto = customElements.get("ion-content")!.prototype; + const scrollToTop = proto.scrollToTop; + + win.scrollToTopCalls = 0; + proto.scrollToTop = function (...args: any[]) { + win.scrollToTopCalls++; + return scrollToTop.apply(this, args); + }; + }); + +const getScrollToTopCalls = (page: Page) => + page.evaluate(() => (window as any).scrollToTopCalls); + +test.describe("Tabs: tabTap", () => { + test("should scroll to the top when pressing the active tab button at the root of the tab", async ({ + page, + }) => { + await page.goto("/tab-tap/home"); + await ionPageVisible(page, "tab-tap-home"); + await enableTabTap(page); + + await scrollContent(page, "tab-tap-home"); + await tabClick(page, "home"); + + await expect.poll(() => getScrollTop(page, "tab-tap-home")).toBe(0); + }); + + test("should not scroll when switching to another tab", async ({ page }) => { + await page.goto("/tab-tap/home"); + await ionPageVisible(page, "tab-tap-home"); + await enableTabTap(page); + + await scrollContent(page, "tab-tap-home"); + + await tabClick(page, "other"); + await ionPageVisible(page, "tab-tap-other"); + await ionPageHidden(page, "tab-tap-home"); + + await tabClick(page, "home"); + await ionPageVisible(page, "tab-tap-home"); + await ionPageHidden(page, "tab-tap-other"); + + await expect + .poll(() => getScrollTop(page, "tab-tap-home")) + .toBeGreaterThan(0); + }); + + test("should go back to the tab root without scrolling the child page", async ({ + page, + }) => { + await page.goto("/tab-tap/home"); + await ionPageVisible(page, "tab-tap-home"); + await enableTabTap(page); + await trackScrollToTop(page); + + await page + .locator("div.ion-page[data-pageid=tab-tap-home] #go-to-child") + .click(); + await ionPageVisible(page, "tab-tap-child"); + + await tabClick(page, "home"); + await ionPageVisible(page, "tab-tap-home"); + + await expect(page).toHaveURL(/\/tab-tap\/home(\?|$)/); + expect(await getScrollToTopCalls(page)).toBe(0); + }); + + test("should not scroll when tabTap is not enabled", async ({ page }) => { + await page.goto("/tab-tap/home"); + await ionPageVisible(page, "tab-tap-home"); + + await scrollContent(page, "tab-tap-home"); + await tabClick(page, "home"); + await page.waitForTimeout(500); + + expect(await getScrollTop(page, "tab-tap-home")).toBeGreaterThan(0); + }); +});