Skip to content

feat(many): use tabs without router outlet #29737

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,7 @@ ion-tab-button,css-prop,--ripple-color,md
ion-tab-button,part,native

ion-tabs,shadow
ion-tabs,prop,noOutlet,boolean,false,false,false
ion-tabs,method,getSelected,getSelected() => Promise<string | undefined>
ion-tabs,method,getTab,getTab(tab: string | HTMLIonTabElement) => Promise<HTMLIonTabElement | undefined>
ion-tabs,method,select,select(tab: string | HTMLIonTabElement) => Promise<boolean>
Expand Down
2 changes: 2 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,7 @@ export namespace Components {
* @param tab The tab instance to select. If passed a string, it should be the value of the tab's `tab` property.
*/
"getTab": (tab: string | HTMLIonTabElement) => Promise<HTMLIonTabElement | undefined>;
"noOutlet": boolean;
/**
* Select a tab by the value of its `tab` property or an element reference. This method is only available for vanilla JavaScript projects. The Angular, React, and Vue implementations of tabs are coupled to each framework's router.
* @param tab The tab instance to select. If passed a string, it should be the value of the tab's `tab` property.
Expand Down Expand Up @@ -7727,6 +7728,7 @@ declare namespace LocalJSX {
"target"?: string | undefined;
}
interface IonTabs {
"noOutlet"?: boolean;
/**
* Emitted when the navigation will load a component.
*/
Expand Down
4 changes: 3 additions & 1 deletion core/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export class Tabs implements NavOutlet {

@State() selectedTab?: HTMLIonTabElement;

@Prop() noOutlet = false;

/** @internal */
@Prop({ mutable: true }) useRouter = false;

Expand All @@ -42,7 +44,7 @@ export class Tabs implements NavOutlet {
@Event({ bubbles: false }) ionTabsDidChange!: EventEmitter<{ tab: string }>;

async componentWillLoad() {
if (!this.useRouter) {
if (!this.useRouter && !this.noOutlet) {
this.useRouter = !!document.querySelector('ion-router') && !this.el.closest('[no-router]');
}
if (!this.useRouter) {
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
70 changes: 65 additions & 5 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,
Input,
AfterViewInit,
} from '@angular/core';

import { NavController } from '../../providers/nav-controller';
Expand All @@ -17,14 +19,18 @@ 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 tabs: any;

private selectedTab?: any;
private leavingTab?: any;

@ViewChild('tabsInner', { read: ElementRef, static: true }) tabsInner: ElementRef<HTMLDivElement>;

Expand All @@ -39,8 +45,23 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked {

private tabBarSlot = 'bottom';

@Input() noOutlet = false;

constructor(private navCtrl: NavController) {}

ngAfterViewInit(): void {
if (this.noOutlet) {
const tabs = this.tabs;
const selectedTab = tabs.get(0);

if (tabs.length > 0) {
this.select(selectedTab.tab);
}

this.tabBar.selectedTab = selectedTab.tab;
}
}

ngAfterContentInit(): void {
this.detectSlotChanges();
}
Expand All @@ -53,6 +74,10 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked {
* @internal
*/
onStackWillChange({ enteringView, tabSwitch }: StackWillChangeEvent): void {
if (this.noOutlet) {
return;
}

const stackId = enteringView.stackId;
if (tabSwitch && stackId !== undefined) {
this.ionTabsWillChange.emit({ tab: stackId });
Expand All @@ -63,12 +88,13 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked {
* @internal
*/
onStackDidChange({ enteringView, tabSwitch }: StackDidChangeEvent): void {
if (this.noOutlet) {
return;
}

const stackId = enteringView.stackId;
if (tabSwitch && stackId !== undefined) {
if (this.tabBar) {
this.tabBar.selectedTab = stackId;
}
this.ionTabsDidChange.emit({ tab: stackId });
this.tabSwitch(stackId);
}
}

Expand Down Expand Up @@ -96,6 +122,18 @@ 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 (this.noOutlet) {
const selectedTab = this.tabs.find((t: any) => t.tab === tab);
this.leavingTab = this.selectedTab;
this.selectedTab = selectedTab;

this.ionTabsWillChange.emit({ tab: selectedTab.tab });
this.tabSwitch();
selectedTab.setActive();
return Promise.resolve(true);
}

const alreadySelected = this.outlet.getActiveStackId() === tab;
const tabRootUrl = `${this.outlet.tabsPrefix}/${tab}`;

Expand Down Expand Up @@ -143,6 +181,10 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked {
}

getSelected(): string | undefined {
if (this.noOutlet) {
return this.selectedTab?.tab;
}

return this.outlet.getActiveStackId();
}

Expand Down Expand Up @@ -189,4 +231,22 @@ export abstract class IonTabs implements AfterContentInit, AfterContentChecked {
this.tabsInner.nativeElement.after(tabBar);
}
}

private tabSwitch(stackId?: string): void {
const selectedTab = this.selectedTab;
const selectedTabValue = stackId ?? selectedTab.tab;
const leavingTab = this.leavingTab;

if (this.tabBar) {
this.tabBar.selectedTab = selectedTabValue;
}

if (this.noOutlet && leavingTab.tab !== selectedTab.tab) {
if (leavingTab) {
leavingTab.active = false;
}
}

this.ionTabsDidChange.emit({ tab: selectedTabValue });
}
}
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
8 changes: 7 additions & 1 deletion packages/angular/src/directives/navigation/ion-tabs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core';
import { Component, ContentChild, ContentChildren, ViewChild, QueryList, Input } from '@angular/core';
import { IonTabs as IonTabsBase } from '@ionic/angular/common';

import { IonTabBar } from '../proxies';

Check failure on line 4 in packages/angular/src/directives/navigation/ion-tabs.ts

View workflow job for this annotation

GitHub Actions / build-angular

'/home/runner/work/ionic-framework/ionic-framework/packages/angular/src/directives/proxies.ts' imported multiple times
import { IonTab } from '../proxies';

Check failure on line 5 in packages/angular/src/directives/navigation/ion-tabs.ts

View workflow job for this annotation

GitHub Actions / build-angular

'/home/runner/work/ionic-framework/ionic-framework/packages/angular/src/directives/proxies.ts' imported multiple times

import { IonRouterOutlet } from './ion-router-outlet';

Expand All @@ -10,7 +11,9 @@
template: `
<ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner" #tabsInner>
<ng-content *ngIf="noOutlet" select="ion-tab"></ng-content>
<ion-router-outlet
*ngIf="!noOutlet"
#outlet
tabs="true"
(stackWillChange)="onStackWillChange($event)"
Expand Down Expand Up @@ -52,4 +55,7 @@

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

@Input() noOutlet = false;
}
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: 7 additions & 1 deletion packages/angular/standalone/src/navigation/tabs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core';
import { Component, ContentChild, ContentChildren, ViewChild, QueryList, Input } from '@angular/core';
import { IonTabs as IonTabsBase } from '@ionic/angular/common';

import { IonTabBar } from '../directives/proxies';

Check failure on line 4 in packages/angular/standalone/src/navigation/tabs.ts

View workflow job for this annotation

GitHub Actions / build-angular

'/home/runner/work/ionic-framework/ionic-framework/packages/angular/standalone/src/directives/proxies.ts' imported multiple times
import { IonTab } from '../directives/proxies';

Check failure on line 5 in packages/angular/standalone/src/navigation/tabs.ts

View workflow job for this annotation

GitHub Actions / build-angular

'/home/runner/work/ionic-framework/ionic-framework/packages/angular/standalone/src/directives/proxies.ts' imported multiple times

import { IonRouterOutlet } from './router-outlet';

Expand All @@ -10,7 +11,9 @@
template: `
<ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner" #tabsInner>
<ng-content *ngIf="noOutlet" select="ion-tab"></ng-content>
<ion-router-outlet
*ngIf="!noOutlet"
#outlet
tabs="true"
(stackWillChange)="onStackWillChange($event)"
Expand Down Expand Up @@ -54,4 +57,7 @@

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

@Input() noOutlet = false;
}
5 changes: 5 additions & 0 deletions packages/react/src/components/inner-proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,6 +20,10 @@ 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
Expand Down
16 changes: 14 additions & 2 deletions packages/react/src/components/navigation/IonTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NavContext } from '../../contexts/NavContext';
import PageManager from '../../routing/PageManager';
import { HTMLElementSSR } from '../../utils/HTMLElementSSR';
import { IonRouterOutlet } from '../IonRouterOutlet';
import { IonTabsInner } from '../inner-proxies';

import { IonTabBar } from './IonTabBar';
import type { IonTabsContextState } from './IonTabsContext';
Expand Down Expand Up @@ -91,7 +92,7 @@ export const IonTabs = /*@__PURE__*/ (() =>
render() {
let outlet: React.ReactElement<{}> | undefined;
let tabBar: React.ReactElement | undefined;
const { className, onIonTabsDidChange, onIonTabsWillChange, ...props } = this.props;
const { className, onIonTabsDidChange, onIonTabsWillChange, noOutlet, ...props } = this.props;

const children =
typeof this.props.children === 'function'
Expand Down Expand Up @@ -144,13 +145,24 @@ export const IonTabs = /*@__PURE__*/ (() =>
}
});

if (!outlet) {
if (!outlet && !noOutlet) {
throw new Error('IonTabs must contain an IonRouterOutlet');
}

// Should we also warn about including IonTab when noOutlet is false?

if (noOutlet && outlet) {
throw new Error('IonTabs must not contain an IonRouterOutlet when using the noOutlet prop');
}

if (!tabBar) {
throw new Error('IonTabs needs a IonTabBar');
}

if (!outlet) {
return <IonTabsInner {...props}></IonTabsInner>;
}

return (
<IonTabsContext.Provider value={this.ionTabContextState}>
{this.context.hasIonicRouter() ? (
Expand Down
Loading
Loading