Compare commits

...

3 Commits

Author SHA1 Message Date
Brandy Carney
ac17dd6a44 chore: use proper file 2024-09-05 12:01:46 -04:00
Brandy Carney
7fc02b4251 chore: build 2024-09-05 11:51:26 -04:00
Maria Hutt
e97ccd8370 feat(react, vue, angular): use tabs without router (#29794)
Issue number: resolves #25184

---------

Co-authored-by: Brandy Carney <brandyscarney@users.noreply.github.com>
Co-authored-by: Sean Perkins <13732623+sean-perkins@users.noreply.github.com>
2024-09-05 11:33:37 -04:00
38 changed files with 1946 additions and 1281 deletions

View File

@@ -46,7 +46,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;

View File

@@ -26,7 +26,6 @@ const getAngularOutputTargets = () => {
// tabs
'ion-tabs',
'ion-tab',
// auxiliar
'ion-picker-legacy-column',
@@ -173,7 +172,6 @@ export const config: Config = {
'ion-back-button',
'ion-tab-button',
'ion-tabs',
'ion-tab',
'ion-tab-bar',
// Overlays

View File

@@ -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<any>;
abstract tabs: QueryList<any>;
@ViewChild('tabsInner', { read: ElementRef, static: true }) tabsInner: ElementRef<HTMLDivElement>;
@@ -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<boolean> | 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();
}

View File

@@ -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),

View File

@@ -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';
<ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner" #tabsInner>
<ion-router-outlet
*ngIf="tabs.length === 0"
#outlet
tabs="true"
(stackWillChange)="onStackWillChange($event)"
(stackDidChange)="onStackDidChange($event)"
></ion-router-outlet>
<ng-content *ngIf="tabs.length > 0" select="ion-tab"></ng-content>
</div>
<ng-content></ng-content>
`,
@@ -52,4 +54,5 @@ export class IonTabs extends IonTabsBase {
@ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined;
@ContentChildren(IonTabBar) tabBars: QueryList<IonTabBar>;
@ContentChildren(IonTab) tabs: QueryList<IonTab>;
}

View File

@@ -74,6 +74,7 @@ export const DIRECTIVES = [
d.IonSkeletonText,
d.IonSpinner,
d.IonSplitPane,
d.IonTab,
d.IonTabBar,
d.IonTabButton,
d.IonText,

View File

@@ -2154,6 +2154,29 @@ export declare interface IonSplitPane extends Components.IonSplitPane {
}
@ProxyCmp({
inputs: ['component', 'mode', 'tab', 'theme'],
methods: ['setActive']
})
@Component({
selector: 'ion-tab',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['component', 'mode', 'tab', 'theme'],
})
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', 'theme', 'translucent']
})

View File

@@ -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';
@@ -1944,6 +1945,31 @@ export declare interface IonSplitPane extends Components.IonSplitPane {
}
@ProxyCmp({
defineCustomElementFn: defineIonTab,
inputs: ['component', 'mode', 'tab', 'theme'],
methods: ['setActive']
})
@Component({
selector: 'ion-tab',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['component', 'mode', 'tab', 'theme'],
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', 'theme', 'translucent']

View File

@@ -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';
<ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner" #tabsInner>
<ion-router-outlet
*ngIf="tabs.length === 0"
#outlet
tabs="true"
(stackWillChange)="onStackWillChange($event)"
(stackDidChange)="onStackDidChange($event)"
></ion-router-outlet>
<ng-content *ngIf="tabs.length > 0" select="ion-tab"></ng-content>
</div>
<ng-content></ng-content>
`,
@@ -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<IonTabBar>;
@ContentChildren(IonTab) tabs: QueryList<IonTab>;
}

View File

@@ -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', () => {
const expectNestedTabUrlToContain = 'search=hello#fragment';
cy.url().then(url => {
const tab = getSelectedTab();
tab.find('#goto-nested-page1-with-query-params').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');
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)');
testUrlContains(expectNestedTabUrlToContain);
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');
testUrlEquals(url);
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';
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');
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 - /', () => {
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');
})
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');
});
})
})
describe('entry tab contains navigation extras', () => {
const expectNestedTabUrlToContain = 'search=hello#fragment';
const rootUrlParams = 'test=123#rootFragment';
const rootUrl = `/lazy/tabs/account?${rootUrlParams}`;
describe('Without IonRouterOutlet', () => {
beforeEach(() => {
cy.visit(rootUrl);
cy.visit('/lazy/tabs-basic');
})
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();
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');
testTabTitle('Tab 1 - Page 2 (1)');
testUrlContains(expectNestedTabUrlToContain);
cy.get('ion-tab-button[tab="tab2"]').click();
cy.get('#tab-button-account').click();
testTabTitle('Tab 1 - Page 1');
testUrlContains(rootUrl);
cy.get('ion-tab[tab="tab1"]').should('not.be.visible');
cy.get('ion-tab[tab="tab2"]').should('be.visible');
});
it('should preserve root url navigation extras when changing tabs', () => {
getSelectedTab();
cy.get('#tab-button-contact').click();
testTabTitle('Tab 2 - Page 1');
it('should not change the URL when clicking the tab button', () => {
cy.url().should('include', '/tabs-basic');
cy.get('#tab-button-account').click();
testTabTitle('Tab 1 - Page 1');
cy.get('ion-tab-button[tab="tab2"]').click();
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 - /', () => {
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');
})
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');
cy.url().should('include', '/tabs-basic');
});
})
})

View File

@@ -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');
// 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.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();
cy.url().should('include', '/tabs-basic');
});
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -37,6 +37,11 @@
Tabs test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/tabs-basic">
<ion-label>
Basic Tabs test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/nested-outlet/page">
<ion-label>
Nested ion-router-outlet

View File

@@ -0,0 +1,5 @@
#test {
position: absolute;
bottom: 100px;
left: 0;
}

View File

@@ -0,0 +1,53 @@
<ion-tabs (ionTabsWillChange)="onTabWillChange($event)" (ionTabsDidChange)="onTabDidChange($event)">
<ion-tab-bar slot="bottom">
<ion-tab-button tab="tab1">
<ion-label>Tab One</ion-label>
<ion-icon name="add"></ion-icon>
</ion-tab-button>
<ion-tab-button tab="tab2">
<ion-label>Tab Two</ion-label>
<ion-icon name="logo-ionic"></ion-icon>
</ion-tab-button>
<ion-tab-button tab="tab3">
<ion-label>Tab Three</ion-label>
<ion-icon name="save"></ion-icon>
</ion-tab-button>
</ion-tab-bar>
<ion-tab tab="tab1">
<ion-label>Tab 1 Content</ion-label>
</ion-tab>
<ion-tab tab="tab2">
<ion-label>Tab 2 Content</ion-label>
</ion-tab>
<ion-tab tab="tab3">
<ion-label>Tab 3 Content</ion-label>
</ion-tab>
</ion-tabs>
<div id="test">
<ul>
<li>
ionTabsWillChange counter: <span id="ionTabsWillChangeCounter">{{ tabsWillChangeCounter }}</span>
</li>
<li>
ionTabsWillChange event: <span id="ionTabsWillChangeEvent">{{ tabsWillChangeEvent }}</span>
</li>
<li>
ionTabsWillChange selectedTab: <span id="ionTabsWillChangeSelectedTab">{{ tabsWillChangeSelectedTab }}</span>
</li>
</ul>
<ul>
<li>
ionTabsDidChange counter: <span id="ionTabsDidChangeCounter">{{ tabsDidChangeCounter }}</span>
</li>
<li>
ionTabsDidChange event: <span id="ionTabsDidChangeEvent">{{ tabsDidChangeEvent }}</span>
</li>
<li>
ionTabsDidChange selectedTab: <span id="ionTabsDidChangeSelectedTab">{{ tabsDidChangeSelectedTab }}</span>
</li>
</ul>
</div>

View File

@@ -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;
}
}

View File

@@ -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: [

View File

@@ -0,0 +1,5 @@
#test {
position: absolute;
bottom: 100px;
left: 0;
}

View File

@@ -0,0 +1,53 @@
<ion-tabs (ionTabsWillChange)="onTabWillChange($event)" (ionTabsDidChange)="onTabDidChange($event)">
<ion-tab-bar slot="bottom">
<ion-tab-button tab="tab1">
<ion-label>Tab One</ion-label>
<ion-icon name="add"></ion-icon>
</ion-tab-button>
<ion-tab-button tab="tab2">
<ion-label>Tab Two</ion-label>
<ion-icon name="logo-ionic"></ion-icon>
</ion-tab-button>
<ion-tab-button tab="tab3">
<ion-label>Tab Three</ion-label>
<ion-icon name="save"></ion-icon>
</ion-tab-button>
</ion-tab-bar>
<ion-tab tab="tab1">
<ion-label>Tab 1 Content</ion-label>
</ion-tab>
<ion-tab tab="tab2">
<ion-label>Tab 2 Content</ion-label>
</ion-tab>
<ion-tab tab="tab3">
<ion-label>Tab 3 Content</ion-label>
</ion-tab>
</ion-tabs>
<div id="test">
<ul>
<li>
ionTabsWillChange counter: <span id="ionTabsWillChangeCounter">{{ tabsWillChangeCounter }}</span>
</li>
<li>
ionTabsWillChange event: <span id="ionTabsWillChangeEvent">{{ tabsWillChangeEvent }}</span>
</li>
<li>
ionTabsWillChange selectedTab: <span id="ionTabsWillChangeSelectedTab">{{ tabsWillChangeSelectedTab }}</span>
</li>
</ul>
<ul>
<li>
ionTabsDidChange counter: <span id="ionTabsDidChangeCounter">{{ tabsDidChangeCounter }}</span>
</li>
<li>
ionTabsDidChange event: <span id="ionTabsDidChangeEvent">{{ tabsDidChangeEvent }}</span>
</li>
<li>
ionTabsDidChange selectedTab: <span id="ionTabsDidChangeSelectedTab">{{ tabsDidChangeSelectedTab }}</span>
</li>
</ul>
</div>

View File

@@ -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;
}
}

View File

@@ -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<JSX.IonTabBar,
undefined,
defineIonTabBar
);
export const IonTabsInner = /*@__PURE__*/ createReactComponent<JSX.IonTabs, HTMLIonTabsElement>(
'ion-tabs',
undefined,
undefined,
defineIonTabs
);
export const IonBackButtonInner = /*@__PURE__*/ createReactComponent<
Omit<JSX.IonBackButton, 'icon'>,
HTMLIonBackButtonElement

View File

@@ -21,6 +21,7 @@ interface InternalProps extends IonTabBarProps {
forwardedRef?: React.ForwardedRef<HTMLIonIconElement>;
onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void;
routeInfo: RouteInfo;
routerOutletRef?: React.RefObject<HTMLIonRouterOutletElement> | undefined;
}
interface TabUrls {
@@ -182,7 +183,12 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
) {
const tappedTab = this.state.tabs[e.detail.tab];
const originalHref = tappedTab.originalHref;
const currentHref = e.detail.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 = this.props.routerOutletRef?.current ? e.detail.href : '';
const { activeTab: prevActiveTab } = this.state;
if (onClickFn) {

View File

@@ -5,6 +5,8 @@ import { NavContext } from '../../contexts/NavContext';
import PageManager from '../../routing/PageManager';
import { HTMLElementSSR } from '../../utils/HTMLElementSSR';
import { IonRouterOutlet } from '../IonRouterOutlet';
import { IonTab } from '../components';
import { IonTabsInner } from '../inner-proxies';
import { IonTabBar } from './IonTabBar';
import type { IonTabsContextState } from './IonTabsContext';
@@ -91,6 +93,8 @@ export const IonTabs = /*@__PURE__*/ (() =>
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 <IonTabsInner {...this.props}></IonTabsInner>;
}
/**
* 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 (
<IonTabsContext.Provider value={this.ionTabContextState}>
{this.context.hasIonicRouter() ? (
<PageManager className={className ? `${className}` : ''} routeInfo={this.context.routeInfo} {...props}>
<ion-tabs className="ion-tabs" style={hostStyles}>
{tabBar.props.slot === 'top' ? tabBar : null}
<div style={tabsInner} className="tabs-inner">
{outlet}
</div>
{tabBar.props.slot === 'bottom' ? tabBar : null}
</ion-tabs>
<IonTabsInner {...this.props}>
{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;
})}
</IonTabsInner>
</PageManager>
) : (
<div className={className ? `${className}` : 'ion-tabs'} {...props} style={hostStyles}>

View File

@@ -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 = () => (
<Route path="/keep-contents-mounted" component={KeepContentsMounted} />
<Route path="/navigation" component={NavComponent} />
<Route path="/tabs" component={Tabs} />
<Route path="/tabs-basic" component={TabsBasic} />
<Route path="/icons" component={Icons} />
</IonRouterOutlet>
</IonReactRouter>

View File

@@ -37,6 +37,9 @@ const Main: React.FC<MainProps> = () => {
<IonItem routerLink="/tabs">
<IonLabel>Tabs</IonLabel>
</IonItem>
<IonItem routerLink="/tabs-basic">
<IonLabel>Tabs with Basic Navigation</IonLabel>
</IonItem>
<IonItem routerLink="/icons">
<IonLabel>Icons</IonLabel>
</IonItem>

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { IonLabel, IonTabBar, IonTabButton, IonTabs, IonTab } from '@ionic/react';
interface TabsProps {}
const TabsBasic: React.FC<TabsProps> = () => {
const onTabWillChange = (event: CustomEvent) => {
console.log('onIonTabsWillChange', event.detail.tab);
};
const onTabDidChange = (event: CustomEvent) => {
console.log('onIonTabsDidChange:', event.detail.tab);
};
return (
<IonTabs onIonTabsWillChange={onTabWillChange} onIonTabsDidChange={onTabDidChange}>
<IonTab tab="tab1">
<IonLabel>Tab 1 Content</IonLabel>
</IonTab>
<IonTab tab="tab2">
<IonLabel>Tab 2 Content</IonLabel>
</IonTab>
<IonTabBar slot="bottom">
<IonTabButton tab="tab1">
<IonLabel>Tab 1</IonLabel>
</IonTabButton>
<IonTabButton tab="tab2">
<IonLabel>Tab 2</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
};
export default TabsBasic;

View File

@@ -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');
});
});
});

View File

@@ -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 = "";
}
}

View File

@@ -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

View File

@@ -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)
);
}
if (!routerOutlet) {
throw new Error(
"IonTabs must contain an IonRouterOutlet. See https://ionicframework.com/docs/vue/navigation#working-with-tabs for more information."
);
}
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`.
* 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 filteredContent = slottedContent.filter(
(child: VNode) => !child.type || !isRouterOutlet(child)
);
hasTab = slottedContent.some((child: VNode) => isTab(child));
}
const slottedTabBar = filteredContent.find((child: VNode) =>
if (!routerOutlet && !hasTab) {
throw new Error("IonTabs must contain an IonRouterOutlet or an IonTab.");
}
if (routerOutlet && hasTab) {
throw new Error(
"IonTabs cannot contain an IonRouterOutlet and IonTab at the same time."
);
}
if (slottedContent && slottedContent.length > 0) {
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 });
}
if (hasTopSlotTabBar) {
childrenToRender = [...filteredContent, ...childrenToRender];
} else {
childrenToRender = [...childrenToRender, ...filteredContent];
emit(DID_CHANGE, { tab });
slottedTabBar.props._hasRouterOutlet = !!routerOutlet;
}
}
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
);
},
});

View File

@@ -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';
@@ -824,6 +825,14 @@ export const IonSplitPane = /*@__PURE__*/ defineContainer<JSX.IonSplitPane>('ion
]);
export const IonTab = /*@__PURE__*/ defineContainer<JSX.IonTab>('ion-tab', defineIonTab, [
'active',
'delegate',
'tab',
'component'
]);
export const IonText = /*@__PURE__*/ defineContainer<JSX.IonText>('ion-text', defineIonText, [
'color'
]);

View File

@@ -161,6 +161,10 @@ const routes: Array<RouteRecordRaw> = [
}
]
},
{
path: '/tabs-basic',
component: () => import('@/views/TabsBasic.vue')
},
]
const router = createRouter({

View File

@@ -44,6 +44,9 @@
<ion-item router-link="/tabs-secondary" id="tab-secondary">
<ion-label>Tabs Secondary</ion-label>
</ion-item>
<ion-item router-link="/tabs-basic" id="tab-basic">
<ion-label>Tabs with Basic Navigation</ion-label>
</ion-item>
<ion-item router-link="/lifecycle" id="lifecycle">
<ion-label>Lifecycle</ion-label>
</ion-item>

View File

@@ -1,7 +1,7 @@
<template>
<ion-page data-pageid="tabs">
<ion-content>
<ion-tabs id="tabs">
<ion-tabs id="tabs" @ionTabsWillChange="onTabWillChange" @ionTabsDidChange="onTabDidChange">
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar slot="bottom">
<ion-tab-button
@@ -47,7 +47,15 @@ export default defineComponent({
]
}
return { tabs, addTab }
const onTabWillChange = (e: { tab: string }) => {
console.log('ionTabsWillChange', e.tab);
}
const onTabDidChange = (e: { tab: string }) => {
console.log('ionTabsDidChange', e.tab);
}
return { tabs, addTab, onTabWillChange, onTabDidChange }
}
});
</script>

View File

@@ -0,0 +1,55 @@
<template>
<ion-page data-pageid="tabs">
<ion-content>
<ion-tabs id="tabs" @ionTabsWillChange="onTabWillChange" @ionTabsDidChange="onTabDidChange">
<ion-tab-bar slot="bottom">
<ion-tab-button
v-for="tab in tabs"
:tab="'tab' + tab.id"
:key="tab.id"
>
<ion-icon :icon="tab.icon" />
<ion-label>Tab {{ tab.id }}</ion-label>
</ion-tab-button>
</ion-tab-bar>
<ion-tab tab="tab1" data-pageid="tab1">
<ion-label>Tab 1 Content</ion-label>
</ion-tab>
<ion-tab tab="tab2" data-pageid="tab2">
<ion-label>Tab 2 Content</ion-label>
</ion-tab>
<ion-tab tab="tab3" data-pageid="tab3">
<ion-label>Tab 3 Content</ion-label>
</ion-tab>
</ion-tabs>
</ion-content>
</ion-page>
</template>
<script lang="ts">
import { IonTabBar, IonTabButton, IonTabs, IonContent, IonLabel, IonIcon, IonPage, IonTab } from '@ionic/vue';
import { ellipse, square, triangle } from 'ionicons/icons';
import { ref, defineComponent } from 'vue';
export default defineComponent({
components: { IonContent, IonLabel, IonTabs, IonTabBar, IonTabButton, IonIcon, IonPage, IonTab },
setup() {
const tabs = ref([
{ id: 1, icon: triangle },
{ id: 2, icon: ellipse },
{ id: 3, icon: square }
])
const onTabWillChange = (e: { tab: string }) => {
console.log('ionTabsWillChange', e.tab);
}
const onTabDidChange = (e: { tab: string }) => {
console.log('ionTabsDidChange', e.tab);
}
return { tabs, onTabWillChange, onTabDidChange }
}
});
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,113 +4,6 @@ import { createRouter, createWebHistory } from '@ionic/vue-router';
import { IonicVue, IonRouterOutlet, IonPage, IonTabs, IonTabBar } from '@ionic/vue';
describe('ion-tab-bar', () => {
it('should render in the top slot', async () => {
const Tabs = {
components: { IonPage, IonTabs, IonTabBar, IonRouterOutlet },
template: `
<ion-page>
<ion-tabs>
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar slot="top"></ion-tab-bar>
</ion-tabs>
</ion-page>
`,
}
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes: [
{ path: '/', component: Tabs }
]
});
router.push('/');
await router.isReady();
const wrapper = mount(Tabs, {
global: {
plugins: [router, IonicVue]
}
});
const tabs = wrapper.findComponent(IonTabs);
const children = tabs.vm.$el.children;
expect(children[0].tagName).toEqual('ION-TAB-BAR');
expect(children[1].tagName).toEqual('DIV');
expect(children[1].className).toEqual('tabs-inner');
});
it('should render in the bottom slot', async () => {
const Tabs = {
components: { IonPage, IonTabs, IonTabBar, IonRouterOutlet },
template: `
<ion-page>
<ion-tabs>
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar slot="bottom"></ion-tab-bar>
</ion-tabs>
</ion-page>
`,
}
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes: [
{ path: '/', component: Tabs }
]
});
router.push('/');
await router.isReady();
const wrapper = mount(Tabs, {
global: {
plugins: [router, IonicVue]
}
});
const tabs = wrapper.findComponent(IonTabs);
const children = tabs.vm.$el.children;
expect(children[0].tagName).toEqual('DIV');
expect(children[0].className).toEqual('tabs-inner');
expect(children[1].tagName).toEqual('ION-TAB-BAR');
});
it('should render in the default slot', async () => {
const Tabs = {
components: { IonPage, IonTabs, IonTabBar, IonRouterOutlet },
template: `
<ion-page>
<ion-tabs>
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar></ion-tab-bar>
</ion-tabs>
</ion-page>
`,
}
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes: [
{ path: '/', component: Tabs }
]
});
router.push('/');
await router.isReady();
const wrapper = mount(Tabs, {
global: {
plugins: [router, IonicVue]
}
});
const tabs = wrapper.findComponent(IonTabs);
const children = tabs.vm.$el.children;
const tabsInner = children[0];
expect(tabsInner.tagName).toEqual('DIV');
expect(tabsInner.className).toEqual('tabs-inner');
expect(tabsInner.children[0].tagName).toEqual('ION-ROUTER-OUTLET');
});
// Verifies the fix for https://github.com/ionic-team/ionic-framework/issues/22642
it('should not fail on non tab button elements', async () => {
const Tabs = {