Skip to content
Open
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
27 changes: 26 additions & 1 deletion core/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -205,7 +206,7 @@ export class Tabs implements NavOutlet {
}

private onTabClicked = (ev: CustomEvent<TabButtonClickEventDetail>) => {
const { href, tab } = ev.detail;
const { href, tab, selected } = ev.detail;
if (this.useRouter && href !== undefined) {
const router = document.querySelector('ion-router');
if (router) {
Expand All @@ -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 (
<Host onIonTabButtonClick={this.onTabClicked}>
Expand Down
85 changes: 85 additions & 0 deletions core/src/components/tabs/test/tab-tap/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Tabs - Tab Tap</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-tabs>
<ion-tab tab="tab-one">
<ion-nav root="page-one"></ion-nav>
</ion-tab>

<ion-tab tab="tab-two">
<ion-header>
<ion-toolbar>
<ion-title>Tab Two</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div style="height: 2000px">Tab Two</div>
</ion-content>
</ion-tab>

<ion-tab-bar slot="bottom">
<ion-tab-button tab="tab-one">
<ion-label>Tab One</ion-label>
</ion-tab-button>

<ion-tab-button tab="tab-two">
<ion-label>Tab Two</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
</ion-app>

<script>
class PageOne extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Page One</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-button id="push-page-two" onclick="document.querySelector('ion-nav').push('page-two')">
Push Page Two
</ion-button>
<div style="height: 2000px">Page One</div>
</ion-content>
`;
}
}

class PageTwo extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Page Two</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div style="height: 2000px">Page Two</div>
</ion-content>
`;
}
}

customElements.define('page-one', PageOne);
customElements.define('page-two', PageTwo);
</script>
</body>
</html>
72 changes: 72 additions & 0 deletions core/src/components/tabs/test/tab-tap/tabs.e2e.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
7 changes: 7 additions & 0 deletions core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ion-back-button>` components.
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/angular/src/common/directives/navigation/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
ElementRef,
EventEmitter,
HostListener,
inject,
Output,
ViewChild,
AfterViewInit,
QueryList,
} 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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down
93 changes: 93 additions & 0 deletions packages/angular/test/base/e2e/src/standalone/tab-tap.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@
Tab Lifecycle Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/tab-tap">
<ion-label>
Tab Tap Test
</ion-label>
</ion-item>
</ion-list>

<ion-list>
Expand Down
Loading
Loading