From 867066b4eba369de025f3c7c8ef33e7089cdf3bc Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 26 Aug 2024 08:13:20 -0700 Subject: [PATCH] feat(react, vue, angular): use tabs without router (#29794) Issue number: resolves #25184 --------- Co-authored-by: Brandy Carney Co-authored-by: Sean Perkins <13732623+sean-perkins@users.noreply.github.com> --- core/src/components/tabs/tabs.tsx | 12 +- core/stencil.config.ts | 2 - .../common/src/directives/navigation/tabs.ts | 80 +- packages/angular/src/app-initialize.ts | 2 +- .../src/directives/navigation/ion-tabs.ts | 5 +- .../angular/src/directives/proxies-list.ts | 1 + packages/angular/src/directives/proxies.ts | 23 + .../standalone/src/directives/proxies.ts | 26 + .../angular/standalone/src/navigation/tabs.ts | 8 +- .../test/base/e2e/src/lazy/tabs.spec.ts | 846 ++++++------ .../test/base/e2e/src/standalone/tabs.spec.ts | 54 +- .../base/src/app/lazy/app-lazy/app.module.ts | 4 +- .../base/src/app/lazy/app-lazy/app.routes.ts | 2 + .../lazy/home-page/home-page.component.html | 5 + .../lazy/tabs-basic/tabs-basic.component.css | 5 + .../lazy/tabs-basic/tabs-basic.component.html | 53 + .../lazy/tabs-basic/tabs-basic.component.ts | 35 + .../standalone/app-standalone/app.routes.ts | 1 + .../tabs-basic/tabs-basic.component.css | 5 + .../tabs-basic/tabs-basic.component.html | 53 + .../tabs-basic/tabs-basic.component.ts | 39 + .../react/src/components/inner-proxies.ts | 7 + .../src/components/navigation/IonTabBar.tsx | 8 +- .../src/components/navigation/IonTabs.tsx | 80 +- packages/react/test/base/src/App.tsx | 2 + packages/react/test/base/src/pages/Main.tsx | 3 + .../react/test/base/src/pages/TabsBasic.tsx | 35 + .../test/base/tests/e2e/specs/tabs/tabs.cy.ts | 47 +- packages/vue/src/components/IonTabBar.ts | 81 +- packages/vue/src/components/IonTabButton.ts | 24 +- packages/vue/src/components/IonTabs.ts | 123 +- packages/vue/src/proxies.ts | 9 + packages/vue/test/base/src/router/index.ts | 4 + packages/vue/test/base/src/views/Home.vue | 3 + packages/vue/test/base/src/views/Tabs.vue | 12 +- .../vue/test/base/src/views/TabsBasic.vue | 55 + .../vue/test/base/tests/e2e/specs/tabs.cy.js | 1224 +++++++++-------- .../vue/test/base/tests/unit/tab-bar.spec.ts | 107 -- 38 files changed, 1875 insertions(+), 1210 deletions(-) create mode 100644 packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.css create mode 100644 packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.html create mode 100644 packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.css create mode 100644 packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.html create mode 100644 packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.ts create mode 100644 packages/react/test/base/src/pages/TabsBasic.tsx create mode 100644 packages/vue/test/base/src/views/TabsBasic.vue diff --git a/core/src/components/tabs/tabs.tsx b/core/src/components/tabs/tabs.tsx index 5b744db1ad9..3eebb46d15b 100644 --- a/core/src/components/tabs/tabs.tsx +++ b/core/src/components/tabs/tabs.tsx @@ -43,7 +43,17 @@ export class Tabs implements NavOutlet { async componentWillLoad() { if (!this.useRouter) { - this.useRouter = !!document.querySelector('ion-router') && !this.el.closest('[no-router]'); + /** + * JavaScript and StencilJS use `ion-router`, while + * the other frameworks use `ion-router-outlet`. + * + * If either component is present then tabs will not use + * a basic tab-based navigation. It will use the history + * stack or URL updates associated with the router. + */ + this.useRouter = + (!!this.el.querySelector('ion-router-outlet') || !!document.querySelector('ion-router')) && + !this.el.closest('[no-router]'); } if (!this.useRouter) { const tabs = this.tabs; diff --git a/core/stencil.config.ts b/core/stencil.config.ts index 7f7113c22b9..f2e10d10119 100644 --- a/core/stencil.config.ts +++ b/core/stencil.config.ts @@ -26,7 +26,6 @@ const getAngularOutputTargets = () => { // tabs 'ion-tabs', - 'ion-tab', // auxiliar 'ion-picker-legacy-column', @@ -177,7 +176,6 @@ export const config: Config = { 'ion-back-button', 'ion-tab-button', 'ion-tabs', - 'ion-tab', 'ion-tab-bar', // Overlays diff --git a/packages/angular/common/src/directives/navigation/tabs.ts b/packages/angular/common/src/directives/navigation/tabs.ts index 7906e890736..73e8c0cc777 100644 --- a/packages/angular/common/src/directives/navigation/tabs.ts +++ b/packages/angular/common/src/directives/navigation/tabs.ts @@ -7,6 +7,8 @@ import { HostListener, Output, ViewChild, + AfterViewInit, + QueryList, } from '@angular/core'; import { NavController } from '../../providers/nav-controller'; @@ -17,14 +19,15 @@ import { StackDidChangeEvent, StackWillChangeEvent } from './stack-utils'; selector: 'ion-tabs', }) // eslint-disable-next-line @angular-eslint/directive-class-suffix -export abstract class IonTabs implements AfterContentInit, AfterContentChecked { +export abstract class IonTabs implements AfterViewInit, AfterContentInit, AfterContentChecked { /** * Note: These must be redeclared on each child class since it needs * access to generated components such as IonRouterOutlet and IonTabBar. */ abstract outlet: any; abstract tabBar: any; - abstract tabBars: any; + abstract tabBars: QueryList; + abstract tabs: QueryList; @ViewChild('tabsInner', { read: ElementRef, static: true }) tabsInner: ElementRef; @@ -39,8 +42,29 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked { private tabBarSlot = 'bottom'; + private hasTab = false; + private selectedTab?: { tab: string }; + private leavingTab?: any; + constructor(private navCtrl: NavController) {} + ngAfterViewInit(): void { + /** + * Developers must pass at least one ion-tab + * inside of ion-tabs if they want to use a + * basic tab-based navigation without the + * history stack or URL updates associated + * with the router. + */ + const firstTab = this.tabs.length > 0 ? this.tabs.first : undefined; + + if (firstTab) { + this.hasTab = true; + this.setActiveTab(firstTab.tab); + this.tabSwitch(); + } + } + ngAfterContentInit(): void { this.detectSlotChanges(); } @@ -96,6 +120,19 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked { select(tabOrEvent: string | CustomEvent): Promise | undefined { const isTabString = typeof tabOrEvent === 'string'; const tab = isTabString ? tabOrEvent : (tabOrEvent as CustomEvent).detail.tab; + + /** + * If the tabs are not using the router, then + * the tab switch logic is handled by the tabs + * component itself. + */ + if (this.hasTab) { + this.setActiveTab(tab); + this.tabSwitch(); + + return; + } + const alreadySelected = this.outlet.getActiveStackId() === tab; const tabRootUrl = `${this.outlet.tabsPrefix}/${tab}`; @@ -142,7 +179,46 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked { } } + private setActiveTab(tab: string): void { + const tabs = this.tabs; + const selectedTab = tabs.find((t: any) => t.tab === tab); + + if (!selectedTab) { + console.error(`[Ionic Error]: Tab with id: "${tab}" does not exist`); + return; + } + + this.leavingTab = this.selectedTab; + this.selectedTab = selectedTab; + + this.ionTabsWillChange.emit({ tab }); + + selectedTab.el.active = true; + } + + private tabSwitch(): void { + const { selectedTab, leavingTab } = this; + + if (this.tabBar && selectedTab) { + this.tabBar.selectedTab = selectedTab.tab; + } + + if (leavingTab?.tab !== selectedTab?.tab) { + if (leavingTab?.el) { + leavingTab.el.active = false; + } + } + + if (selectedTab) { + this.ionTabsDidChange.emit({ tab: selectedTab.tab }); + } + } + getSelected(): string | undefined { + if (this.hasTab) { + return this.selectedTab?.tab; + } + return this.outlet.getActiveStackId(); } diff --git a/packages/angular/src/app-initialize.ts b/packages/angular/src/app-initialize.ts index 69cf4030f43..7b1062f82a9 100644 --- a/packages/angular/src/app-initialize.ts +++ b/packages/angular/src/app-initialize.ts @@ -20,7 +20,7 @@ export const appInitialize = (config: Config, doc: Document, zone: NgZone) => { return applyPolyfills().then(() => { return defineCustomElements(win, { - exclude: ['ion-tabs', 'ion-tab'], + exclude: ['ion-tabs'], syncQueue: true, raf, jmp: (h: any) => zone.runOutsideAngular(h), diff --git a/packages/angular/src/directives/navigation/ion-tabs.ts b/packages/angular/src/directives/navigation/ion-tabs.ts index 9b7737a5774..65819cde79f 100644 --- a/packages/angular/src/directives/navigation/ion-tabs.ts +++ b/packages/angular/src/directives/navigation/ion-tabs.ts @@ -1,7 +1,7 @@ import { Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core'; import { IonTabs as IonTabsBase } from '@ionic/angular/common'; -import { IonTabBar } from '../proxies'; +import { IonTabBar, IonTab } from '../proxies'; import { IonRouterOutlet } from './ion-router-outlet'; @@ -11,11 +11,13 @@ import { IonRouterOutlet } from './ion-router-outlet';
+
`, @@ -52,4 +54,5 @@ export class IonTabs extends IonTabsBase { @ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined; @ContentChildren(IonTabBar) tabBars: QueryList; + @ContentChildren(IonTab) tabs: QueryList; } diff --git a/packages/angular/src/directives/proxies-list.ts b/packages/angular/src/directives/proxies-list.ts index 8a0d9eb03ec..1874d0bfe2d 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -74,6 +74,7 @@ export const DIRECTIVES = [ d.IonSkeletonText, d.IonSpinner, d.IonSplitPane, + d.IonTab, d.IonTabBar, d.IonTabButton, d.IonText, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 8db34fa7b89..f448236a161 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2148,6 +2148,29 @@ export declare interface IonSplitPane extends Components.IonSplitPane { } +@ProxyCmp({ + inputs: ['component', 'tab'], + methods: ['setActive'] +}) +@Component({ + selector: 'ion-tab', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['component', 'tab'], +}) +export class IonTab { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonTab extends Components.IonTab {} + + @ProxyCmp({ inputs: ['color', 'mode', 'selectedTab', 'translucent'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 0db2a471286..c84717dfd1c 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -69,6 +69,7 @@ import { defineCustomElement as defineIonSelectOption } from '@ionic/core/compon import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js'; import { defineCustomElement as defineIonSplitPane } from '@ionic/core/components/ion-split-pane.js'; +import { defineCustomElement as defineIonTab } from '@ionic/core/components/ion-tab.js'; import { defineCustomElement as defineIonTabBar } from '@ionic/core/components/ion-tab-bar.js'; import { defineCustomElement as defineIonTabButton } from '@ionic/core/components/ion-tab-button.js'; import { defineCustomElement as defineIonText } from '@ionic/core/components/ion-text.js'; @@ -1939,6 +1940,31 @@ export declare interface IonSplitPane extends Components.IonSplitPane { } +@ProxyCmp({ + defineCustomElementFn: defineIonTab, + inputs: ['component', 'tab'], + methods: ['setActive'] +}) +@Component({ + selector: 'ion-tab', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['component', 'tab'], + standalone: true +}) +export class IonTab { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonTab extends Components.IonTab {} + + @ProxyCmp({ defineCustomElementFn: defineIonTabBar, inputs: ['color', 'mode', 'selectedTab', 'translucent'] diff --git a/packages/angular/standalone/src/navigation/tabs.ts b/packages/angular/standalone/src/navigation/tabs.ts index 50c85f1593f..61cbd12d088 100644 --- a/packages/angular/standalone/src/navigation/tabs.ts +++ b/packages/angular/standalone/src/navigation/tabs.ts @@ -1,7 +1,8 @@ +import { NgIf } from '@angular/common'; import { Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core'; import { IonTabs as IonTabsBase } from '@ionic/angular/common'; -import { IonTabBar } from '../directives/proxies'; +import { IonTabBar, IonTab } from '../directives/proxies'; import { IonRouterOutlet } from './router-outlet'; @@ -11,11 +12,13 @@ import { IonRouterOutlet } from './router-outlet';
+
`, @@ -46,7 +49,7 @@ import { IonRouterOutlet } from './router-outlet'; } `, ], - imports: [IonRouterOutlet], + imports: [IonRouterOutlet, NgIf], }) // eslint-disable-next-line @angular-eslint/component-class-suffix export class IonTabs extends IonTabsBase { @@ -54,4 +57,5 @@ export class IonTabs extends IonTabsBase { @ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined; @ContentChildren(IonTabBar) tabBars: QueryList; + @ContentChildren(IonTab) tabs: QueryList; } diff --git a/packages/angular/test/base/e2e/src/lazy/tabs.spec.ts b/packages/angular/test/base/e2e/src/lazy/tabs.spec.ts index e7c4b51cfbf..ac31d74060b 100644 --- a/packages/angular/test/base/e2e/src/lazy/tabs.spec.ts +++ b/packages/angular/test/base/e2e/src/lazy/tabs.spec.ts @@ -1,436 +1,462 @@ describe('Tabs', () => { - beforeEach(() => { - cy.visit('/lazy/tabs'); - }) - - describe('entry url - /tabs', () => { - it('should redirect and load tab-account', () => { - testTabTitle('Tab 1 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1']); - testState(1, 'account'); - }); - - it('should navigate between tabs and ionChange events should be dispatched', () => { - let tab = testTabTitle('Tab 1 - Page 1'); - tab.find('.segment-changed').should('have.text', 'false'); - - cy.get('#tab-button-contact').click(); - tab = testTabTitle('Tab 2 - Page 1'); - tab.find('.segment-changed').should('have.text', 'false'); - }); - - describe('when navigating between tabs', () => { - - it('should emit ionTabsWillChange before setting the selected tab', () => { - cy.get('#ionTabsWillChangeCounter').should('have.text', '1'); - cy.get('#ionTabsWillChangeEvent').should('have.text', 'account'); - cy.get('#ionTabsWillChangeSelectedTab').should('have.text', ''); - - cy.get('#ionTabsDidChangeCounter').should('have.text', '1'); - cy.get('#ionTabsDidChangeEvent').should('have.text', 'account'); - cy.get('#ionTabsDidChangeSelectedTab').should('have.text', 'account'); - + describe('With IonRouterOutlet', () => { + beforeEach(() => { + cy.visit('/lazy/tabs'); + }) + + describe('entry url - /tabs', () => { + it('should redirect and load tab-account', () => { + testTabTitle('Tab 1 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1']); + testState(1, 'account'); + }); + + it('should navigate between tabs and ionChange events should be dispatched', () => { + let tab = testTabTitle('Tab 1 - Page 1'); + tab.find('.segment-changed').should('have.text', 'false'); + cy.get('#tab-button-contact').click(); - - cy.get('#ionTabsWillChangeCounter').should('have.text', '2'); - cy.get('#ionTabsWillChangeEvent').should('have.text', 'contact'); - cy.get('#ionTabsWillChangeSelectedTab').should('have.text', 'account'); - - cy.get('#ionTabsDidChangeCounter').should('have.text', '2'); - cy.get('#ionTabsDidChangeEvent').should('have.text', 'contact'); - cy.get('#ionTabsDidChangeSelectedTab').should('have.text', 'contact'); - }) - - }); - - it('should simulate stack + double tab click', () => { - let tab = getSelectedTab(); - tab.find('#goto-tab1-page2').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested']); - testState(1, 'account'); - - // When you call find on tab above it changes the value of tab - // so we need to redefine it - tab = getSelectedTab(); - tab.find('ion-back-button').should('be.visible'); - - cy.get('#tab-button-contact').click(); - testTabTitle('Tab 2 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested', 'app-tabs-tab2']); - testState(2, 'contact'); - - cy.get('#tab-button-account').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested', 'app-tabs-tab2']); - testState(3, 'account'); - - tab = getSelectedTab(); - tab.find('ion-back-button').should('be.visible'); - - cy.get('#tab-button-account').click(); - testTabTitle('Tab 1 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); - testState(3, 'account'); - }); - - it('should simulate stack + back button click', () => { - const tab = getSelectedTab(); - tab.find('#goto-tab1-page2').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - testState(1, 'account'); - - cy.get('#tab-button-contact').click(); - testTabTitle('Tab 2 - Page 1'); - testState(2, 'contact'); - - cy.get('#tab-button-account').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - testState(3, 'account'); - - cy.get('ion-back-button').click(); - testTabTitle('Tab 1 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); - testState(3, 'account'); - }); - - it('should navigate deep then go home', () => { - const tab = getSelectedTab(); - tab.find('#goto-tab1-page2').click(); - cy.ionPageVisible('app-tabs-tab1-nested'); - cy.ionPageHidden('app-tabs-tab1'); - - testTabTitle('Tab 1 - Page 2 (1)'); - - cy.get('#goto-next').click(); - cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); - cy.ionPageHidden('app-tabs-tab1-nested:first-of-type'); - - testTabTitle('Tab 1 - Page 2 (2)'); - - cy.get('#tab-button-contact').click(); - cy.ionPageVisible('app-tabs-tab2'); - cy.ionPageHidden('app-tabs-tab1-nested:last-of-type'); - - testTabTitle('Tab 2 - Page 1'); - - cy.get('#tab-button-account').click(); - cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); - cy.ionPageHidden('app-tabs-tab2'); - - testTabTitle('Tab 1 - Page 2 (2)'); - cy.testStack('ion-tabs ion-router-outlet', [ - 'app-tabs-tab1', - 'app-tabs-tab1-nested', - 'app-tabs-tab1-nested', - 'app-tabs-tab2' - ]); - - cy.get('#tab-button-account').click(); - - /** - * Wait for the leaving view to - * be unmounted otherwise testTabTitle - * may get the leaving view before it - * is unmounted. - */ - cy.ionPageVisible('app-tabs-tab1'); - cy.ionPageDoesNotExist('app-tabs-tab1-nested'); - - testTabTitle('Tab 1 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', [ - 'app-tabs-tab1', - 'app-tabs-tab2' - ]); - }); - - it('should switch tabs and go back', () => { - cy.get('#tab-button-contact').click(); - const tab = testTabTitle('Tab 2 - Page 1'); - - tab.find('#goto-tab1-page1').click(); - testTabTitle('Tab 1 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); - }); - - it('should switch tabs and go to nested', () => { - cy.get('#tab-button-contact').click(); - const tab = testTabTitle('Tab 2 - Page 1'); - - tab.find('#goto-tab1-page2').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab2', 'app-tabs-tab1-nested']); - }); - - it('should load lazy loaded tab', () => { - cy.get('#tab-button-lazy').click(); - cy.ionPageVisible('app-tabs-tab3'); - testTabTitle('Tab 3 - Page 1'); - }); - - it('should use ion-back-button defaultHref', () => { - let tab = getSelectedTab(); - tab.find('#goto-tab3-page2').click(); - testTabTitle('Tab 3 - Page 2'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab3-nested']); - - tab = getSelectedTab(); - tab.find('ion-back-button').click(); - testTabTitle('Tab 3 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab3']); - }); - - it('should preserve navigation extras when switching tabs', () => { - const expectUrlToContain = 'search=hello#fragment'; - let tab = getSelectedTab(); - tab.find('#goto-nested-page1-with-query-params').click(); - testTabTitle('Tab 1 - Page 2 (1)'); - testUrlContains(expectUrlToContain); - - cy.get('#tab-button-contact').click(); - testTabTitle('Tab 2 - Page 1'); - - cy.get('#tab-button-account').click(); - tab = testTabTitle('Tab 1 - Page 2 (1)'); - testUrlContains(expectUrlToContain); - }); - - it('should set root when clicking on an active tab to navigate to the root', () => { + tab = testTabTitle('Tab 2 - Page 1'); + tab.find('.segment-changed').should('have.text', 'false'); + }); + + describe('when navigating between tabs', () => { + + it('should emit ionTabsWillChange before setting the selected tab', () => { + cy.get('#ionTabsWillChangeCounter').should('have.text', '1'); + cy.get('#ionTabsWillChangeEvent').should('have.text', 'account'); + cy.get('#ionTabsWillChangeSelectedTab').should('have.text', ''); + + cy.get('#ionTabsDidChangeCounter').should('have.text', '1'); + cy.get('#ionTabsDidChangeEvent').should('have.text', 'account'); + cy.get('#ionTabsDidChangeSelectedTab').should('have.text', 'account'); + + cy.get('#tab-button-contact').click(); + + cy.get('#ionTabsWillChangeCounter').should('have.text', '2'); + cy.get('#ionTabsWillChangeEvent').should('have.text', 'contact'); + cy.get('#ionTabsWillChangeSelectedTab').should('have.text', 'account'); + + cy.get('#ionTabsDidChangeCounter').should('have.text', '2'); + cy.get('#ionTabsDidChangeEvent').should('have.text', 'contact'); + cy.get('#ionTabsDidChangeSelectedTab').should('have.text', 'contact'); + }) + + }); + + it('should simulate stack + double tab click', () => { + let tab = getSelectedTab(); + tab.find('#goto-tab1-page2').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested']); + testState(1, 'account'); + + // When you call find on tab above it changes the value of tab + // so we need to redefine it + tab = getSelectedTab(); + tab.find('ion-back-button').should('be.visible'); + + cy.get('#tab-button-contact').click(); + testTabTitle('Tab 2 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested', 'app-tabs-tab2']); + testState(2, 'contact'); + + cy.get('#tab-button-account').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab1-nested', 'app-tabs-tab2']); + testState(3, 'account'); + + tab = getSelectedTab(); + tab.find('ion-back-button').should('be.visible'); + + cy.get('#tab-button-account').click(); + testTabTitle('Tab 1 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); + testState(3, 'account'); + }); + + it('should simulate stack + back button click', () => { + const tab = getSelectedTab(); + tab.find('#goto-tab1-page2').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + testState(1, 'account'); + + cy.get('#tab-button-contact').click(); + testTabTitle('Tab 2 - Page 1'); + testState(2, 'contact'); + + cy.get('#tab-button-account').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + testState(3, 'account'); + + cy.get('ion-back-button').click(); + testTabTitle('Tab 1 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); + testState(3, 'account'); + }); + + it('should navigate deep then go home', () => { + const tab = getSelectedTab(); + tab.find('#goto-tab1-page2').click(); + cy.ionPageVisible('app-tabs-tab1-nested'); + cy.ionPageHidden('app-tabs-tab1'); + + testTabTitle('Tab 1 - Page 2 (1)'); + + cy.get('#goto-next').click(); + cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); + cy.ionPageHidden('app-tabs-tab1-nested:first-of-type'); + + testTabTitle('Tab 1 - Page 2 (2)'); + + cy.get('#tab-button-contact').click(); + cy.ionPageVisible('app-tabs-tab2'); + cy.ionPageHidden('app-tabs-tab1-nested:last-of-type'); + + testTabTitle('Tab 2 - Page 1'); + + cy.get('#tab-button-account').click(); + cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); + cy.ionPageHidden('app-tabs-tab2'); + + testTabTitle('Tab 1 - Page 2 (2)'); + cy.testStack('ion-tabs ion-router-outlet', [ + 'app-tabs-tab1', + 'app-tabs-tab1-nested', + 'app-tabs-tab1-nested', + 'app-tabs-tab2' + ]); + + cy.get('#tab-button-account').click(); + + /** + * Wait for the leaving view to + * be unmounted otherwise testTabTitle + * may get the leaving view before it + * is unmounted. + */ + cy.ionPageVisible('app-tabs-tab1'); + cy.ionPageDoesNotExist('app-tabs-tab1-nested'); + + testTabTitle('Tab 1 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', [ + 'app-tabs-tab1', + 'app-tabs-tab2' + ]); + }); + + it('should switch tabs and go back', () => { + cy.get('#tab-button-contact').click(); + const tab = testTabTitle('Tab 2 - Page 1'); + + tab.find('#goto-tab1-page1').click(); + testTabTitle('Tab 1 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab2']); + }); + + it('should switch tabs and go to nested', () => { + cy.get('#tab-button-contact').click(); + const tab = testTabTitle('Tab 2 - Page 1'); + + tab.find('#goto-tab1-page2').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab2', 'app-tabs-tab1-nested']); + }); + + it('should load lazy loaded tab', () => { + cy.get('#tab-button-lazy').click(); + cy.ionPageVisible('app-tabs-tab3'); + testTabTitle('Tab 3 - Page 1'); + }); + + it('should use ion-back-button defaultHref', () => { + let tab = getSelectedTab(); + tab.find('#goto-tab3-page2').click(); + testTabTitle('Tab 3 - Page 2'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab3-nested']); + + tab = getSelectedTab(); + tab.find('ion-back-button').click(); + testTabTitle('Tab 3 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab3']); + }); + + it('should preserve navigation extras when switching tabs', () => { + const expectUrlToContain = 'search=hello#fragment'; + let tab = getSelectedTab(); + tab.find('#goto-nested-page1-with-query-params').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + testUrlContains(expectUrlToContain); + + cy.get('#tab-button-contact').click(); + testTabTitle('Tab 2 - Page 1'); + + cy.get('#tab-button-account').click(); + tab = testTabTitle('Tab 1 - Page 2 (1)'); + testUrlContains(expectUrlToContain); + }); + + it('should set root when clicking on an active tab to navigate to the root', () => { + const expectNestedTabUrlToContain = 'search=hello#fragment'; + cy.url().then(url => { + const tab = getSelectedTab(); + tab.find('#goto-nested-page1-with-query-params').click(); + testTabTitle('Tab 1 - Page 2 (1)'); + testUrlContains(expectNestedTabUrlToContain); + + cy.get('#tab-button-account').click(); + testTabTitle('Tab 1 - Page 1'); + + testUrlEquals(url); + }) + }); + }) + + describe('entry tab contains navigation extras', () => { const expectNestedTabUrlToContain = 'search=hello#fragment'; - cy.url().then(url => { + const rootUrlParams = 'test=123#rootFragment'; + const rootUrl = `/lazy/tabs/account?${rootUrlParams}`; + + beforeEach(() => { + cy.visit(rootUrl); + }) + + it('should preserve root url navigation extras when clicking on an active tab to navigate to the root', () => { const tab = getSelectedTab(); tab.find('#goto-nested-page1-with-query-params').click(); + testTabTitle('Tab 1 - Page 2 (1)'); testUrlContains(expectNestedTabUrlToContain); - + cy.get('#tab-button-account').click(); testTabTitle('Tab 1 - Page 1'); - - testUrlEquals(url); - }) - }); - }) - - describe('entry tab contains navigation extras', () => { - const expectNestedTabUrlToContain = 'search=hello#fragment'; - const rootUrlParams = 'test=123#rootFragment'; - const rootUrl = `/lazy/tabs/account?${rootUrlParams}`; - - beforeEach(() => { - cy.visit(rootUrl); + + testUrlContains(rootUrl); + }); + + it('should preserve root url navigation extras when changing tabs', () => { + getSelectedTab(); + cy.get('#tab-button-contact').click(); + testTabTitle('Tab 2 - Page 1'); + + cy.get('#tab-button-account').click(); + testTabTitle('Tab 1 - Page 1'); + + testUrlContains(rootUrl); + }); + + it('should navigate deep then go home and preserve navigation extras', () => { + let tab = getSelectedTab(); + tab.find('#goto-tab1-page2').click(); + cy.ionPageVisible('app-tabs-tab1-nested'); + cy.ionPageHidden('app-tabs-tab1'); + + tab = testTabTitle('Tab 1 - Page 2 (1)'); + + tab.find('#goto-next').click(); + cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); + cy.ionPageHidden('app-tabs-tab1-nested:first-of-type'); + + testTabTitle('Tab 1 - Page 2 (2)'); + + cy.ionTabClick('Tab Two'); + cy.ionPageVisible('app-tabs-tab2'); + cy.ionPageHidden('app-tabs-tab1-nested:last-of-type'); + + testTabTitle('Tab 2 - Page 1'); + + cy.ionTabClick('Tab One'); + cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); + cy.ionPageHidden('app-tabs-tab2'); + + testTabTitle('Tab 1 - Page 2 (2)'); + + cy.ionTabClick('Tab One'); + /** + * Wait for the leaving view to + * be unmounted otherwise testTabTitle + * may get the leaving view before it + * is unmounted. + */ + cy.ionPageVisible('app-tabs-tab1'); + cy.ionPageDoesNotExist('app-tabs-tab1-nested'); + + testTabTitle('Tab 1 - Page 1'); + + testUrlContains(rootUrl); + }); }) - - it('should preserve root url navigation extras when clicking on an active tab to navigate to the root', () => { - const tab = getSelectedTab(); - tab.find('#goto-nested-page1-with-query-params').click(); - - testTabTitle('Tab 1 - Page 2 (1)'); - testUrlContains(expectNestedTabUrlToContain); - - cy.get('#tab-button-account').click(); - testTabTitle('Tab 1 - Page 1'); - - testUrlContains(rootUrl); - }); - - it('should preserve root url navigation extras when changing tabs', () => { - getSelectedTab(); - cy.get('#tab-button-contact').click(); - testTabTitle('Tab 2 - Page 1'); - - cy.get('#tab-button-account').click(); - testTabTitle('Tab 1 - Page 1'); - - testUrlContains(rootUrl); - }); - - it('should navigate deep then go home and preserve navigation extras', () => { - let tab = getSelectedTab(); - tab.find('#goto-tab1-page2').click(); - cy.ionPageVisible('app-tabs-tab1-nested'); - cy.ionPageHidden('app-tabs-tab1'); - - tab = testTabTitle('Tab 1 - Page 2 (1)'); - - tab.find('#goto-next').click(); - cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); - cy.ionPageHidden('app-tabs-tab1-nested:first-of-type'); - - testTabTitle('Tab 1 - Page 2 (2)'); - - cy.ionTabClick('Tab Two'); - cy.ionPageVisible('app-tabs-tab2'); - cy.ionPageHidden('app-tabs-tab1-nested:last-of-type'); - - testTabTitle('Tab 2 - Page 1'); - - cy.ionTabClick('Tab One'); - cy.ionPageVisible('app-tabs-tab1-nested:last-of-type'); - cy.ionPageHidden('app-tabs-tab2'); - - testTabTitle('Tab 1 - Page 2 (2)'); - - cy.ionTabClick('Tab One'); - /** - * Wait for the leaving view to - * be unmounted otherwise testTabTitle - * may get the leaving view before it - * is unmounted. - */ - cy.ionPageVisible('app-tabs-tab1'); - cy.ionPageDoesNotExist('app-tabs-tab1-nested'); - - testTabTitle('Tab 1 - Page 1'); - - testUrlContains(rootUrl); - }); - }) - - describe('entry url - /tabs/account', () => { - beforeEach(() => { - cy.visit('/lazy/tabs/account'); - }); - it('should pop to previous view when leaving tabs outlet', () => { - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); - - cy.get('#goto-tab1-page2').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); - - cy.get('#goto-global').click(); - - cy.get('ion-title').should('contain.text', 'Global Page'); - - cy.get('#goto-prev-pop').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); - - cy.get('#goto-prev').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); - - /** - * Verifies that when entering the tabs outlet directly, - * the navController.pop() method does not pop the previous view, - * when you are at the root of the tabs outlet. - */ - cy.get('#goto-previous-page').click(); - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + + describe('entry url - /tabs/account', () => { + beforeEach(() => { + cy.visit('/lazy/tabs/account'); + }); + it('should pop to previous view when leaving tabs outlet', () => { + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + + cy.get('#goto-tab1-page2').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); + + cy.get('#goto-global').click(); + + cy.get('ion-title').should('contain.text', 'Global Page'); + + cy.get('#goto-prev-pop').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); + + cy.get('#goto-prev').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + + /** + * Verifies that when entering the tabs outlet directly, + * the navController.pop() method does not pop the previous view, + * when you are at the root of the tabs outlet. + */ + cy.get('#goto-previous-page').click(); + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + }); }); - }); - - describe('entry url - /', () => { - it('should pop to the root outlet from the tabs outlet', () => { - cy.visit('/lazy/'); - - cy.get('ion-title').should('contain.text', 'Test App'); - - cy.get('ion-item').contains('Tabs test').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); - - cy.get('#goto-tab1-page2').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); - - cy.get('#goto-global').click(); - - cy.get('ion-title').should('contain.text', 'Global Page'); - - cy.get('#goto-prev-pop').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); - - cy.get('#goto-prev').click(); - - cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); - - cy.get('#goto-previous-page').click(); - - cy.get('ion-title').should('contain.text', 'Test App'); - + + describe('entry url - /', () => { + it('should pop to the root outlet from the tabs outlet', () => { + cy.visit('/lazy/'); + + cy.get('ion-title').should('contain.text', 'Test App'); + + cy.get('ion-item').contains('Tabs test').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + + cy.get('#goto-tab1-page2').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); + + cy.get('#goto-global').click(); + + cy.get('ion-title').should('contain.text', 'Global Page'); + + cy.get('#goto-prev-pop').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 2 (1)'); + + cy.get('#goto-prev').click(); + + cy.get('ion-title').should('contain.text', 'Tab 1 - Page 1'); + + cy.get('#goto-previous-page').click(); + + cy.get('ion-title').should('contain.text', 'Test App'); + + }); }); - }); - - - describe('entry url - /tabs/account/nested/1', () => { - beforeEach(() => { - cy.visit('/lazy/tabs/account/nested/1'); + + + describe('entry url - /tabs/account/nested/1', () => { + beforeEach(() => { + cy.visit('/lazy/tabs/account/nested/1'); + }) + + it('should only display the back-button when there is a page in the stack', () => { + let tab = getSelectedTab(); + tab.find('ion-back-button').should('not.be.visible'); + testTabTitle('Tab 1 - Page 2 (1)'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1-nested']); + + cy.get('#tab-button-account').click(); + tab = testTabTitle('Tab 1 - Page 1'); + + tab.find('#goto-tab1-page2').click(); + tab = testTabTitle('Tab 1 - Page 2 (1)'); + tab.find('ion-back-button').should('be.visible'); + }); + + it('should not reuse the same page', () => { + let tab = testTabTitle('Tab 1 - Page 2 (1)'); + tab.find('#goto-next').click(); + tab = testTabTitle('Tab 1 - Page 2 (2)'); + + tab.find('#goto-next').click(); + tab = testTabTitle('Tab 1 - Page 2 (3)'); + + cy.testStack('ion-tabs ion-router-outlet', [ + 'app-tabs-tab1-nested', + 'app-tabs-tab1-nested', + 'app-tabs-tab1-nested' + ]); + + tab = getSelectedTab(); + tab.find('ion-back-button').click(); + tab = testTabTitle('Tab 1 - Page 2 (2)'); + tab.find('ion-back-button').click(); + tab = testTabTitle('Tab 1 - Page 2 (1)'); + + tab.find('ion-back-button').should('not.be.visible'); + + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1-nested']); + }); + }) + + describe('entry url - /tabs/lazy', () => { + beforeEach(() => { + cy.visit('/lazy/tabs/lazy'); + }); + + it('should not display the back-button if coming from a different stack', () => { + let tab = testTabTitle('Tab 3 - Page 1'); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab3']); + + tab = getSelectedTab(); + tab.find('#goto-tab1-page2').click(); + cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab3', 'app-tabs-tab1-nested']); + + tab = testTabTitle('Tab 1 - Page 2 (1)'); + tab.find('ion-back-button').should('not.be.visible'); + }); + }) + + describe('enter url - /tabs/contact/one', () => { + beforeEach(() => { + cy.visit('/lazy/tabs/contact/one'); + }); + + it('should return to correct tab after going to page in different outlet', () => { + const tab = getSelectedTab(); + tab.find('#goto-nested-page1').click(); + cy.testStack('app-nested-outlet ion-router-outlet', ['app-nested-outlet-page']); + + const nestedOutlet = cy.get('app-nested-outlet'); + nestedOutlet.find('ion-back-button').click(); + + testTabTitle('Tab 2 - Page 1'); + }); }) - - it('should only display the back-button when there is a page in the stack', () => { - let tab = getSelectedTab(); - tab.find('ion-back-button').should('not.be.visible'); - testTabTitle('Tab 1 - Page 2 (1)'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1-nested']); - - cy.get('#tab-button-account').click(); - tab = testTabTitle('Tab 1 - Page 1'); - - tab.find('#goto-tab1-page2').click(); - tab = testTabTitle('Tab 1 - Page 2 (1)'); - tab.find('ion-back-button').should('be.visible'); - }); - - it('should not reuse the same page', () => { - let tab = testTabTitle('Tab 1 - Page 2 (1)'); - tab.find('#goto-next').click(); - tab = testTabTitle('Tab 1 - Page 2 (2)'); - - tab.find('#goto-next').click(); - tab = testTabTitle('Tab 1 - Page 2 (3)'); - - cy.testStack('ion-tabs ion-router-outlet', [ - 'app-tabs-tab1-nested', - 'app-tabs-tab1-nested', - 'app-tabs-tab1-nested' - ]); - - tab = getSelectedTab(); - tab.find('ion-back-button').click(); - tab = testTabTitle('Tab 1 - Page 2 (2)'); - tab.find('ion-back-button').click(); - tab = testTabTitle('Tab 1 - Page 2 (1)'); - - tab.find('ion-back-button').should('not.be.visible'); - - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1-nested']); - }); }) - describe('entry url - /tabs/lazy', () => { + describe('Without IonRouterOutlet', () => { beforeEach(() => { - cy.visit('/lazy/tabs/lazy'); - }); + cy.visit('/lazy/tabs-basic'); + }) - it('should not display the back-button if coming from a different stack', () => { - let tab = testTabTitle('Tab 3 - Page 1'); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab3']); + it('should show correct tab when clicking the tab button', () => { + cy.get('ion-tab[tab="tab1"]').should('be.visible'); + cy.get('ion-tab[tab="tab2"]').should('not.be.visible'); - tab = getSelectedTab(); - tab.find('#goto-tab1-page2').click(); - cy.testStack('ion-tabs ion-router-outlet', ['app-tabs-tab3', 'app-tabs-tab1-nested']); + cy.get('ion-tab-button[tab="tab2"]').click(); - tab = testTabTitle('Tab 1 - Page 2 (1)'); - tab.find('ion-back-button').should('not.be.visible'); - }); - }) - - describe('enter url - /tabs/contact/one', () => { - beforeEach(() => { - cy.visit('/lazy/tabs/contact/one'); + cy.get('ion-tab[tab="tab1"]').should('not.be.visible'); + cy.get('ion-tab[tab="tab2"]').should('be.visible'); }); - it('should return to correct tab after going to page in different outlet', () => { - const tab = getSelectedTab(); - tab.find('#goto-nested-page1').click(); - cy.testStack('app-nested-outlet ion-router-outlet', ['app-nested-outlet-page']); + it('should not change the URL when clicking the tab button', () => { + cy.url().should('include', '/tabs-basic'); - const nestedOutlet = cy.get('app-nested-outlet'); - nestedOutlet.find('ion-back-button').click(); + cy.get('ion-tab-button[tab="tab2"]').click(); - testTabTitle('Tab 2 - Page 1'); + cy.url().should('include', '/tabs-basic'); }); }) }) diff --git a/packages/angular/test/base/e2e/src/standalone/tabs.spec.ts b/packages/angular/test/base/e2e/src/standalone/tabs.spec.ts index bb0901a1127..f1798cbdabf 100644 --- a/packages/angular/test/base/e2e/src/standalone/tabs.spec.ts +++ b/packages/angular/test/base/e2e/src/standalone/tabs.spec.ts @@ -1,21 +1,47 @@ describe('Tabs', () => { - beforeEach(() => { - cy.visit('/standalone/tabs'); + describe('Without IonRouterOutlet', () => { + beforeEach(() => { + cy.visit('/standalone/tabs'); + }); + + it('should redirect to the default tab', () => { + cy.get('app-tab-one').should('be.visible'); + cy.contains('Tab 1'); + }); + + it('should render new content when switching tabs', () => { + cy.get('#tab-button-tab-two').click(); + cy.get('app-tab-two').should('be.visible'); + cy.contains('Tab 2'); + }); + + // Issue: https://github.com/ionic-team/ionic-framework/issues/28417 + it('parentOutlet should be defined', () => { + cy.get('#parent-outlet span').should('have.text', 'true'); + }); }); - it('should redirect to the default tab', () => { - cy.get('app-tab-one').should('be.visible'); - cy.contains('Tab 1'); - }); + describe('Without IonRouterOutlet', () => { + beforeEach(() => { + cy.visit('/standalone/tabs-basic'); + }) - it('should render new content when switching tabs', () => { - cy.get('#tab-button-tab-two').click(); - cy.get('app-tab-two').should('be.visible'); - cy.contains('Tab 2'); - }); + it('should show correct tab when clicking the tab button', () => { + cy.get('ion-tab[tab="tab1"]').should('be.visible'); + cy.get('ion-tab[tab="tab2"]').should('not.be.visible'); + + cy.get('ion-tab-button[tab="tab2"]').click(); + + cy.get('ion-tab[tab="tab1"]').should('not.be.visible'); + cy.get('ion-tab[tab="tab2"]').should('be.visible'); + }); + + it('should not change the URL when clicking the tab button', () => { + cy.url().should('include', '/tabs-basic'); + + cy.get('ion-tab-button[tab="tab2"]').click(); - // Fixes https://github.com/ionic-team/ionic-framework/issues/28417 - it('parentOutlet should be defined', () => { - cy.get('#parent-outlet span').should('have.text', 'true'); + cy.url().should('include', '/tabs-basic'); + }); }); }); diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts index c7c911da16e..caf27670d2d 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.module.ts @@ -27,6 +27,7 @@ import { NavigationPage3Component } from '../navigation-page3/navigation-page3.c import { AlertComponent } from '../alert/alert.component'; import { AccordionComponent } from '../accordion/accordion.component'; import { AccordionModalComponent } from '../accordion/accordion-modal/accordion-modal.component'; +import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component'; @NgModule({ declarations: [ @@ -51,7 +52,8 @@ import { AccordionModalComponent } from '../accordion/accordion-modal/accordion- NavigationPage3Component, AlertComponent, AccordionComponent, - AccordionModalComponent + AccordionModalComponent, + TabsBasicComponent ], imports: [ CommonModule, diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts index 8f96df5270f..0e15ea2867d 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts @@ -18,6 +18,7 @@ import { NavigationPage2Component } from '../navigation-page2/navigation-page2.c import { NavigationPage3Component } from '../navigation-page3/navigation-page3.component'; import { AlertComponent } from '../alert/alert.component'; import { AccordionComponent } from '../accordion/accordion.component'; +import { TabsBasicComponent } from '../tabs-basic/tabs-basic.component'; export const routes: Routes = [ { @@ -65,6 +66,7 @@ export const routes: Routes = [ path: 'tabs-slots', loadComponent: () => import('../tabs-slots.component').then(c => c.TabsSlotsComponent) }, + { path: 'tabs-basic', component: TabsBasicComponent }, { path: 'nested-outlet', component: NestedOutletComponent, diff --git a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html index 3c78cea6951..7fdca9c50d8 100644 --- a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html @@ -37,6 +37,11 @@ Tabs test + + + Basic Tabs test + + Nested ion-router-outlet diff --git a/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.css b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.css new file mode 100644 index 00000000000..f6f95679b81 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.css @@ -0,0 +1,5 @@ +#test { + position: absolute; + bottom: 100px; + left: 0; +} diff --git a/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.html b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.html new file mode 100644 index 00000000000..e000d6932f7 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.html @@ -0,0 +1,53 @@ + + + + Tab One + + + + + Tab Two + + + + + Tab Three + + + + + + Tab 1 Content + + + Tab 2 Content + + + Tab 3 Content + + + +
+
    +
  • + ionTabsWillChange counter: {{ tabsWillChangeCounter }} +
  • +
  • + ionTabsWillChange event: {{ tabsWillChangeEvent }} +
  • +
  • + ionTabsWillChange selectedTab: {{ tabsWillChangeSelectedTab }} +
  • +
+
    +
  • + ionTabsDidChange counter: {{ tabsDidChangeCounter }} +
  • +
  • + ionTabsDidChange event: {{ tabsDidChangeEvent }} +
  • +
  • + ionTabsDidChange selectedTab: {{ tabsDidChangeSelectedTab }} +
  • +
+
diff --git a/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.ts b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.ts new file mode 100644 index 00000000000..371dbb8a794 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/tabs-basic/tabs-basic.component.ts @@ -0,0 +1,35 @@ +import { Component, ViewChild } from '@angular/core'; +import { IonTabBar } from '@ionic/angular'; + +@Component({ + selector: 'app-tabs-basic', + templateUrl: './tabs-basic.component.html', + styleUrls: ['./tabs-basic.component.css'] +}) +export class TabsBasicComponent { + constructor() { } + + tabsWillChangeCounter = 0; + tabsWillChangeEvent = ''; + tabsWillChangeSelectedTab? = ''; + + tabsDidChangeCounter = 0; + tabsDidChangeEvent = ''; + tabsDidChangeSelectedTab? = ''; + + @ViewChild(IonTabBar) tabBar!: IonTabBar; + + onTabWillChange(ev: { tab: string }) { + console.log('ionTabsWillChange', this.tabBar.selectedTab); + this.tabsWillChangeCounter++; + this.tabsWillChangeEvent = ev.tab; + this.tabsWillChangeSelectedTab = this.tabBar.selectedTab; + } + + onTabDidChange(ev: { tab: string }) { + console.log('ionTabsDidChange', this.tabBar.selectedTab); + this.tabsDidChangeCounter++; + this.tabsDidChangeEvent = ev.tab; + this.tabsDidChangeSelectedTab = this.tabBar.selectedTab; + } +} 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 68213d8375e..40afc7a68de 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 @@ -28,6 +28,7 @@ export const routes: Routes = [ { path: 'tab-three', loadComponent: () => import('../tabs/tab3.component').then(c => c.TabThreeComponent) } ] }, + { path: 'tabs-basic', loadComponent: () => import('../tabs-basic/tabs-basic.component').then(c => c.TabsBasicComponent) }, { path: 'value-accessors', children: [ diff --git a/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.css b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.css new file mode 100644 index 00000000000..f6f95679b81 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.css @@ -0,0 +1,5 @@ +#test { + position: absolute; + bottom: 100px; + left: 0; +} diff --git a/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.html b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.html new file mode 100644 index 00000000000..e000d6932f7 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.html @@ -0,0 +1,53 @@ + + + + Tab One + + + + + Tab Two + + + + + Tab Three + + + + + + Tab 1 Content + + + Tab 2 Content + + + Tab 3 Content + + + +
+
    +
  • + ionTabsWillChange counter: {{ tabsWillChangeCounter }} +
  • +
  • + ionTabsWillChange event: {{ tabsWillChangeEvent }} +
  • +
  • + ionTabsWillChange selectedTab: {{ tabsWillChangeSelectedTab }} +
  • +
+
    +
  • + ionTabsDidChange counter: {{ tabsDidChangeCounter }} +
  • +
  • + ionTabsDidChange event: {{ tabsDidChangeEvent }} +
  • +
  • + ionTabsDidChange selectedTab: {{ tabsDidChangeSelectedTab }} +
  • +
+
diff --git a/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.ts b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.ts new file mode 100644 index 00000000000..d66a5924df0 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/tabs-basic/tabs-basic.component.ts @@ -0,0 +1,39 @@ +import { Component, ViewChild } from '@angular/core'; +import { IonTabBar, IonTabButton, IonIcon, IonLabel, IonTabs, IonTab } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { add, logoIonic, save } from 'ionicons/icons'; + +addIcons({ add, logoIonic, save }); + +@Component({ + selector: 'app-tabs-basic', + templateUrl: './tabs-basic.component.html', + styleUrls: ['./tabs-basic.component.css'], + standalone: true, + imports: [IonTabBar, IonTabButton, IonIcon, IonLabel, IonTabs, IonTab] +}) +export class TabsBasicComponent { + tabsDidChangeCounter = 0; + tabsDidChangeEvent = ''; + tabsDidChangeSelectedTab? = ''; + + tabsWillChangeCounter = 0; + tabsWillChangeEvent = ''; + tabsWillChangeSelectedTab? = ''; + + @ViewChild(IonTabBar) tabBar!: IonTabBar; + + onTabWillChange(ev: { tab: string }) { + console.log('ionTabsWillChange', this.tabBar.selectedTab); + this.tabsWillChangeCounter++; + this.tabsWillChangeEvent = ev.tab; + this.tabsWillChangeSelectedTab = this.tabBar.selectedTab; + } + + onTabDidChange(ev: { tab: string }) { + console.log('ionTabsDidChange', this.tabBar.selectedTab); + this.tabsDidChangeCounter++; + this.tabsDidChangeEvent = ev.tab; + this.tabsDidChangeSelectedTab = this.tabBar.selectedTab; + } +} diff --git a/packages/react/src/components/inner-proxies.ts b/packages/react/src/components/inner-proxies.ts index a2d39091efe..1cdf7e10142 100644 --- a/packages/react/src/components/inner-proxies.ts +++ b/packages/react/src/components/inner-proxies.ts @@ -4,6 +4,7 @@ import { defineCustomElement as defineIonBackButton } from '@ionic/core/componen import { defineCustomElement as defineIonRouterOutlet } from '@ionic/core/components/ion-router-outlet.js'; import { defineCustomElement as defineIonTabBar } from '@ionic/core/components/ion-tab-bar.js'; import { defineCustomElement as defineIonTabButton } from '@ionic/core/components/ion-tab-button.js'; +import { defineCustomElement as defineIonTabs } from '@ionic/core/components/ion-tabs.js'; import type { JSX as IoniconsJSX } from 'ionicons'; import { defineCustomElement as defineIonIcon } from 'ionicons/components/ion-icon.js'; @@ -19,6 +20,12 @@ export const IonTabBarInner = /*@__PURE__*/ createReactComponent( + 'ion-tabs', + undefined, + undefined, + defineIonTabs +); export const IonBackButtonInner = /*@__PURE__*/ createReactComponent< Omit, HTMLIonBackButtonElement diff --git a/packages/react/src/components/navigation/IonTabBar.tsx b/packages/react/src/components/navigation/IonTabBar.tsx index d38f0ccfd3d..7ae5c9e7cd2 100644 --- a/packages/react/src/components/navigation/IonTabBar.tsx +++ b/packages/react/src/components/navigation/IonTabBar.tsx @@ -21,6 +21,7 @@ interface InternalProps extends IonTabBarProps { forwardedRef?: React.ForwardedRef; onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void; routeInfo: RouteInfo; + routerOutletRef?: React.RefObject | undefined; } interface TabUrls { @@ -182,7 +183,12 @@ class IonTabBarUnwrapped extends React.PureComponent 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; const children = @@ -98,19 +102,30 @@ 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); + outlet = React.cloneElement(child, outletProps); } else if (child.type === Fragment && child.props.children[0].type === IonRouterOutlet) { - outlet = child.props.children[0]; + outlet = React.cloneElement(child.props.children[0], outletProps); + } else if (child.type === IonTab) { + /** + * This indicates that IonTabs will be using a basic tab-based navigation + * without the history stack or URL updates associated with the router. + */ + hasTab = true; } let childProps: any = { ref: this.tabBarRef, + routerOutletRef: this.routerOutletRef, }; /** @@ -144,24 +159,67 @@ export const IonTabs = /*@__PURE__*/ (() => } }); - if (!outlet) { - throw new Error('IonTabs must contain an IonRouterOutlet'); + if (!outlet && !hasTab) { + throw new Error('IonTabs must contain an IonRouterOutlet or an IonTab'); + } + if (outlet && hasTab) { + throw new Error('IonTabs cannot contain an IonRouterOutlet and an IonTab at the same time'); } if (!tabBar) { throw new Error('IonTabs needs a IonTabBar'); } + if (hasTab) { + return ; + } + + /** + * TODO(ROU-11051) + * + * There is no error handling for the case where there + * is no associated Route for the given IonTabButton. + * + * More investigation is needed to determine how to + * handle this to prevent any overwriting of the + * IonTabButton's onClick handler and how the routing + * is handled. + */ + return ( {this.context.hasIonicRouter() ? ( - - {tabBar.props.slot === 'top' ? tabBar : null} -
- {outlet} -
- {tabBar.props.slot === 'bottom' ? tabBar : null} -
+ + {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; + })} +
) : (
diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx index 9278550f153..eaf99c129f0 100644 --- a/packages/react/test/base/src/App.tsx +++ b/packages/react/test/base/src/App.tsx @@ -26,6 +26,7 @@ import OverlayHooks from './pages/overlay-hooks/OverlayHooks'; import OverlayComponents from './pages/overlay-components/OverlayComponents'; import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted'; import Tabs from './pages/Tabs'; +import TabsBasic from './pages/TabsBasic'; import Icons from './pages/Icons'; import NavComponent from './pages/navigation/NavComponent'; import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling'; @@ -60,6 +61,7 @@ const App: React.FC = () => ( + diff --git a/packages/react/test/base/src/pages/Main.tsx b/packages/react/test/base/src/pages/Main.tsx index 0d95f9d0eff..8286e02a366 100644 --- a/packages/react/test/base/src/pages/Main.tsx +++ b/packages/react/test/base/src/pages/Main.tsx @@ -37,6 +37,9 @@ const Main: React.FC = () => { Tabs + + Tabs with Basic Navigation + Icons diff --git a/packages/react/test/base/src/pages/TabsBasic.tsx b/packages/react/test/base/src/pages/TabsBasic.tsx new file mode 100644 index 00000000000..63821f06e0b --- /dev/null +++ b/packages/react/test/base/src/pages/TabsBasic.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { IonLabel, IonTabBar, IonTabButton, IonTabs, IonTab } from '@ionic/react'; + +interface TabsProps {} + +const TabsBasic: React.FC = () => { + const onTabWillChange = (event: CustomEvent) => { + console.log('onIonTabsWillChange', event.detail.tab); + }; + + const onTabDidChange = (event: CustomEvent) => { + console.log('onIonTabsDidChange:', event.detail.tab); + }; + + return ( + + + Tab 1 Content + + + Tab 2 Content + + + + Tab 1 + + + Tab 2 + + + + ); +}; + +export default TabsBasic; 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 82307081881..622d62fc931 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 @@ -1,15 +1,46 @@ describe('IonTabs', () => { - beforeEach(() => { - cy.visit('/tabs/tab1'); + describe('With IonRouterOutlet', () => { + beforeEach(() => { + cy.visit('/tabs/tab1'); + }); + + it('should handle onClick handlers on IonTabButton', () => { + const stub = cy.stub(); + + cy.on('window:alert', stub); + cy.get('ion-tab-button[tab="tab1"]').click().then(() => { + expect(stub.getCall(0)).to.be.calledWith('Tab was clicked') + }); + + }); }); - it('should handle onClick handlers on IonTabButton', () => { - const stub = cy.stub(); - - cy.on('window:alert', stub); - cy.get('ion-tab-button[tab="tab1"]').click().then(() => { - expect(stub.getCall(0)).to.be.calledWith('Tab was clicked') + describe('Without IonRouterOutlet', () => { + beforeEach(() => { + cy.visit('/tabs-basic'); + }); + + it('should show correct tab when clicking the tab button', () => { + cy.get('ion-tab[tab="tab1"]').should('be.visible'); + cy.get('ion-tab[tab="tab2"]').should('not.be.visible'); + + cy.get('ion-tab-button[tab="tab2"]').click(); + + cy.get('ion-tab[tab="tab1"]').should('not.be.visible'); + cy.get('ion-tab[tab="tab2"]').should('be.visible'); + + cy.get('ion-tab-button[tab="tab1"]').click(); + + cy.get('ion-tab[tab="tab1"]').should('be.visible'); + cy.get('ion-tab[tab="tab2"]').should('not.be.visible'); }); + it('should not change the URL when clicking the tab button', () => { + cy.url().should('include', '/tabs-basic'); + + cy.get('ion-tab-button[tab="tab2"]').click(); + + cy.url().should('include', '/tabs-basic'); + }); }); }); diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 16ff89ddf01..74879390f10 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -7,6 +7,7 @@ import { h, defineComponent, getCurrentInstance, inject } from "vue"; interface TabState { activeTab?: string; tabs: { [k: string]: Tab }; + hasRouterOutlet?: boolean; } interface Tab { @@ -37,6 +38,7 @@ export const IonTabBar = defineComponent({ /* eslint-disable @typescript-eslint/no-empty-function */ _tabsWillChange: { type: Function, default: () => {} }, _tabsDidChange: { type: Function, default: () => {} }, + _hasRouterOutlet: { type: Boolean, default: false }, /* eslint-enable @typescript-eslint/no-empty-function */ }, data() { @@ -53,6 +55,7 @@ export const IonTabBar = defineComponent({ }, methods: { setupTabState(ionRouter: any) { + const hasRouterOutlet = this.$props._hasRouterOutlet; /** * For each tab, we need to keep track of its * base href as well as any child page that @@ -72,27 +75,76 @@ export const IonTabBar = defineComponent({ ref: child, }; + /** + * Passing this prop to each tab button + * lets it be aware of the presence of + * the router outlet. + */ + tabState.hasRouterOutlet = hasRouterOutlet; + /** * Passing this prop to each tab button * lets it be aware of the state that * ion-tab-bar is managing for it. */ child.component.props._getTabState = () => tabState; + + /** + * If the router outlet is not defined, then the tabs are being used + * as a basic tab navigation without the router. In this case, the + * tabs will not emit the `ionTabsDidChange` and `ionTabsWillChange` + * events through the `checkActiveTab` method. Instead, we need to + * handle those events through the tab buttons. + */ + if (!hasRouterOutlet) { + child.component.props._onClick = ( + event: CustomEvent<{ + href: string; + selected: boolean; + tab: string; + }> + ) => { + this.handleIonTabButtonClick(event); + }; + } }); this.checkActiveTab(ionRouter); }, + /** + * This method is called upon setup and when the + * history changes. It checks the current route + * and updates the active tab accordingly. + * + * History changes only occur when the router + * outlet is present. Due to this, the + * `ionTabsDidChange` and `ionTabsWillChange` + * events are only emitted when the router + * outlet is present. A different approach must + * be taken for tabs without a router outlet. + * + * @param ionRouter + */ checkActiveTab(ionRouter: any) { + const hasRouterOutlet = this.$props._hasRouterOutlet; const currentRoute = ionRouter.getCurrentRouteInfo(); const childNodes = this.$data.tabVnodes; const { tabs, activeTab: prevActiveTab } = this.$data.tabState; - const tabState = this.$data.tabState; const tabKeys = Object.keys(tabs); - const activeTab = tabKeys.find((key) => { + let activeTab = tabKeys.find((key) => { const href = tabs[key].originalHref; return currentRoute.pathname.startsWith(href); }); + /** + * Tabs is being used as a basic tab navigation, + * so we need to set the first tab as active since + * `checkActiveTab` will not be called after setup. + */ + if (!activeTab && !hasRouterOutlet) { + activeTab = tabKeys[0]; + } + /** * For each tab, check to see if the * base href has changed. If so, update @@ -147,6 +199,24 @@ export const IonTabBar = defineComponent({ } } + this.tabSwitch(activeTab, ionRouter); + }, + handleIonTabButtonClick( + event: CustomEvent<{ + href: string; + selected: boolean; + tab: string; + }> + ) { + const activeTab = event.detail.tab; + + this.tabSwitch(activeTab); + }, + tabSwitch(activeTab: string, ionRouter?: any) { + const hasRouterOutlet = this.$props._hasRouterOutlet; + const childNodes = this.$data.tabVnodes; + const { activeTab: prevActiveTab } = this.$data.tabState; + const tabState = this.$data.tabState; const activeChild = childNodes.find( (child: VNode) => isTabButton(child) && child.props?.tab === activeTab ); @@ -156,17 +226,20 @@ export const IonTabBar = defineComponent({ if (activeChild) { tabDidChange && this.$props._tabsWillChange(activeTab); - ionRouter.handleSetCurrentTab(activeTab); + if (hasRouterOutlet && ionRouter) { + ionRouter.handleSetCurrentTab(activeTab); + } + tabBar.selectedTab = tabState.activeTab = activeTab; tabDidChange && this.$props._tabsDidChange(activeTab); + } else { /** * When going to a tab that does * not have an associated ion-tab-button * we need to remove the selected state from * the old tab. */ - } else { tabBar.selectedTab = tabState.activeTab = ""; } } diff --git a/packages/vue/src/components/IonTabButton.ts b/packages/vue/src/components/IonTabButton.ts index 45435514fe9..b5105a125a0 100644 --- a/packages/vue/src/components/IonTabButton.ts +++ b/packages/vue/src/components/IonTabButton.ts @@ -18,6 +18,10 @@ export const IonTabButton = /*@__PURE__*/ defineComponent({ selected: Boolean, tab: String, target: String, + _onClick: { + type: Function, + required: false, + }, }, setup(props, { slots }) { defineCustomElement(); @@ -37,11 +41,29 @@ export const IonTabButton = /*@__PURE__*/ defineComponent({ */ const { tab, href, _getTabState } = props; const tabState = _getTabState(); + const hasRouterOutlet = tabState.hasRouterOutlet; const tappedTab = tabState.tabs[tab] || {}; const originalHref = tappedTab.originalHref || href; - const currentHref = tappedTab.currentHref || href; + /** + * 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 = hasRouterOutlet ? tappedTab.currentHref || href : ""; const prevActiveTab = tabState.activeTab; + if (!hasRouterOutlet && props._onClick) { + props._onClick( + new CustomEvent("ionTabButtonClick", { + detail: { + href: currentHref, + selected: tab === prevActiveTab, + tab, + }, + }) + ); + } + /** * If we are still on the same * tab as before, but the base href diff --git a/packages/vue/src/components/IonTabs.ts b/packages/vue/src/components/IonTabs.ts index 432c2479b59..4654261d6e9 100644 --- a/packages/vue/src/components/IonTabs.ts +++ b/packages/vue/src/components/IonTabs.ts @@ -1,5 +1,8 @@ +import { defineCustomElement } from "@ionic/core/components/ion-tabs.js"; import type { VNode } from "vue"; -import { h, defineComponent } from "vue"; +import { h, defineComponent, Fragment, isVNode } from "vue"; + +import { IonTab } from "../proxies"; const WILL_CHANGE = "ionTabsWillChange"; const DID_CHANGE = "ionTabsDidChange"; @@ -28,64 +31,74 @@ const isTabBar = (node: VNode) => { ); }; +const isTab = (node: VNode): boolean => { + // The `ion-tab` component was created with the `v-for` directive. + if (node.type === Fragment) { + if (Array.isArray(node.children)) { + return node.children.some((child) => isVNode(child) && isTab(child)); + } + + return false; // In case the fragment has no children. + } + + return ( + node.type && ((node.type as any).name === "ion-tab" || node.type === IonTab) + ); +}; + export const IonTabs = /*@__PURE__*/ defineComponent({ name: "IonTabs", emits: [WILL_CHANGE, DID_CHANGE], + setup(props, { slots, emit }) { + // Define the custom element + defineCustomElement(); + + return { + props, + slots, + emit, + }; + }, render() { - const { $slots: slots, $emit } = this; + const { slots, emit, props } = this; const slottedContent = slots.default && slots.default(); let routerOutlet; + let hasTab = false; - /** - * Developers must pass an ion-router-outlet - * inside of ion-tabs. - */ if (slottedContent && slottedContent.length > 0) { + /** + * Developers must pass an ion-router-outlet + * inside of ion-tabs if they want to use + * the history stack or URL updates associated + * wit the router. + */ routerOutlet = slottedContent.find((child: VNode) => isRouterOutlet(child) ); + + /** + * Developers must pass at least one ion-tab + * inside of ion-tabs if they want to use a + * basic tab-based navigation without the + * history stack or URL updates associated + * with the router. + */ + hasTab = slottedContent.some((child: VNode) => isTab(child)); } - if (!routerOutlet) { + if (!routerOutlet && !hasTab) { + throw new Error("IonTabs must contain an IonRouterOutlet or an IonTab."); + } + if (routerOutlet && hasTab) { throw new Error( - "IonTabs must contain an IonRouterOutlet. See https://ionicframework.com/docs/vue/navigation#working-with-tabs for more information." + "IonTabs cannot contain an IonRouterOutlet and IonTab at the same time." ); } - let childrenToRender = [ - h( - "div", - { - class: "tabs-inner", - style: { - position: "relative", - flex: "1", - contain: "layout size style", - }, - }, - routerOutlet - ), - ]; - - /** - * If ion-tab-bar has slot="top" it needs to be - * rendered before `.tabs-inner` otherwise it will - * not show above the tab content. - */ if (slottedContent && slottedContent.length > 0) { - /** - * Render all content except for router outlet - * since that needs to be inside of `.tabs-inner`. - */ - const filteredContent = slottedContent.filter( - (child: VNode) => !child.type || !isRouterOutlet(child) - ); - - const slottedTabBar = filteredContent.find((child: VNode) => + const slottedTabBar = slottedContent.find((child: VNode) => isTabBar(child) ); - const hasTopSlotTabBar = - slottedTabBar && slottedTabBar.props?.slot === "top"; if (slottedTabBar) { if (!slottedTabBar.props) { @@ -99,18 +112,34 @@ export const IonTabs = /*@__PURE__*/ defineComponent({ * so we do not have code split across two components. */ slottedTabBar.props._tabsWillChange = (tab: string) => - $emit(WILL_CHANGE, { tab }); + emit(WILL_CHANGE, { tab }); slottedTabBar.props._tabsDidChange = (tab: string) => - $emit(DID_CHANGE, { tab }); + emit(DID_CHANGE, { tab }); + slottedTabBar.props._hasRouterOutlet = !!routerOutlet; } + } - if (hasTopSlotTabBar) { - childrenToRender = [...filteredContent, ...childrenToRender]; - } else { - childrenToRender = [...childrenToRender, ...filteredContent]; - } + if (hasTab) { + return h( + "ion-tabs", + { + ...props, + }, + slottedContent + ); } + /** + * TODO(ROU-11056) + * + * Vue handles the error case for when there is no + * associated page matching the tab `href`. + * + * More investigation is needed to determine if we + * override the error handling and provide our own + * error message. + */ + return h( "ion-tabs", { @@ -128,7 +157,7 @@ export const IonTabs = /*@__PURE__*/ defineComponent({ "z-index": "0", }, }, - childrenToRender + slottedContent ); }, }); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 936b1e1278b..347673a7461 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -72,6 +72,7 @@ import { defineCustomElement as defineIonSelectOption } from '@ionic/core/compon import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js'; import { defineCustomElement as defineIonSplitPane } from '@ionic/core/components/ion-split-pane.js'; +import { defineCustomElement as defineIonTab } from '@ionic/core/components/ion-tab.js'; import { defineCustomElement as defineIonText } from '@ionic/core/components/ion-text.js'; import { defineCustomElement as defineIonTextarea } from '@ionic/core/components/ion-textarea.js'; import { defineCustomElement as defineIonThumbnail } from '@ionic/core/components/ion-thumbnail.js'; @@ -809,6 +810,14 @@ export const IonSplitPane = /*@__PURE__*/ defineContainer('ion ]); +export const IonTab = /*@__PURE__*/ defineContainer('ion-tab', defineIonTab, [ + 'active', + 'delegate', + 'tab', + 'component' +]); + + export const IonText = /*@__PURE__*/ defineContainer('ion-text', defineIonText, [ 'color' ]); diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index f55aec808af..5e344988313 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -161,6 +161,10 @@ const routes: Array = [ } ] }, + { + path: '/tabs-basic', + component: () => import('@/views/TabsBasic.vue') + }, ] const router = createRouter({ diff --git a/packages/vue/test/base/src/views/Home.vue b/packages/vue/test/base/src/views/Home.vue index 0f04f228904..853832bd8c6 100644 --- a/packages/vue/test/base/src/views/Home.vue +++ b/packages/vue/test/base/src/views/Home.vue @@ -44,6 +44,9 @@ Tabs Secondary + + Tabs with Basic Navigation + Lifecycle diff --git a/packages/vue/test/base/src/views/Tabs.vue b/packages/vue/test/base/src/views/Tabs.vue index dc07a09bae5..03c9c93c74a 100644 --- a/packages/vue/test/base/src/views/Tabs.vue +++ b/packages/vue/test/base/src/views/Tabs.vue @@ -1,7 +1,7 @@