Skip to content

Commit

Permalink
feat(react, vue, angular): use tabs without router (#29794)
Browse files Browse the repository at this point in the history
Issue number: resolves #25184 

---------

Co-authored-by: Brandy Carney <[email protected]>
Co-authored-by: Sean Perkins <[email protected]>
  • Loading branch information
3 people authored Aug 26, 2024
1 parent 4580edc commit 867066b
Show file tree
Hide file tree
Showing 38 changed files with 1,875 additions and 1,210 deletions.
12 changes: 11 additions & 1 deletion core/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 0 additions & 2 deletions core/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const getAngularOutputTargets = () => {

// tabs
'ion-tabs',
'ion-tab',

// auxiliar
'ion-picker-legacy-column',
Expand Down Expand Up @@ -177,7 +176,6 @@ export const config: Config = {
'ion-back-button',
'ion-tab-button',
'ion-tabs',
'ion-tab',
'ion-tab-bar',

// Overlays
Expand Down
80 changes: 78 additions & 2 deletions packages/angular/common/src/directives/navigation/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
HostListener,
Output,
ViewChild,
AfterViewInit,
QueryList,
} from '@angular/core';

import { NavController } from '../../providers/nav-controller';
Expand All @@ -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>;

Expand All @@ -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();
}
Expand Down Expand Up @@ -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}`;

Expand Down Expand Up @@ -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();
}

Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/app-initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion packages/angular/src/directives/navigation/ion-tabs.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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>
`,
Expand Down Expand Up @@ -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>;
}
1 change: 1 addition & 0 deletions packages/angular/src/directives/proxies-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const DIRECTIVES = [
d.IonSkeletonText,
d.IonSpinner,
d.IonSplitPane,
d.IonTab,
d.IonTabBar,
d.IonTabButton,
d.IonText,
Expand Down
23 changes: 23 additions & 0 deletions packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<ng-content></ng-content>',
// 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']
})
Expand Down
26 changes: 26 additions & 0 deletions packages/angular/standalone/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: '<ng-content></ng-content>',
// 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']
Expand Down
8 changes: 6 additions & 2 deletions packages/angular/standalone/src/navigation/tabs.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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>
`,
Expand Down Expand Up @@ -46,12 +49,13 @@ import { IonRouterOutlet } from './router-outlet';
}
`,
],
imports: [IonRouterOutlet],
imports: [IonRouterOutlet, NgIf],
})
// eslint-disable-next-line @angular-eslint/component-class-suffix
export class IonTabs extends IonTabsBase {
@ViewChild('outlet', { read: IonRouterOutlet, static: false }) outlet: IonRouterOutlet;

@ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined;
@ContentChildren(IonTabBar) tabBars: QueryList<IonTabBar>;
@ContentChildren(IonTab) tabs: QueryList<IonTab>;
}
Loading

0 comments on commit 867066b

Please sign in to comment.