From d20bea561c362eacd250cdedbc9b79159eb2c95f Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Tue, 6 Jun 2023 22:41:33 -0400 Subject: [PATCH] fix(angular): tabs supports conditional slot bindings (#27582) Issue number: Resolves #19484 --------- ## What is the current behavior? The Angular implementation of `ion-tabs` does not support conditional binding the `slot` property on `ion-tab-bar` or assigning a variable as the slot. For example, this usage is invalid: ```html ``` This occurs because `ng-content` only supports static content projection. It is not intended for scenarios where the content can be relocated or conditionally rendered. An example of static content projection would be: ```html ``` ## What is the new behavior? - `ion-tabs` supports conditional slot bindings or a slot that is bound to a variable in Angular. The revised implementation relocates the tab bar in the `ion-tabs` template, based on it's current slot attribute. The implementation checks the tab bar slot whenever the content changes. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Dev-build: `7.0.11-dev.11685631370.18980633` --------- Co-authored-by: Liam DeBeasi --- angular/src/directives/navigation/ion-tabs.ts | 81 +++++++++++++++++-- angular/test/base/e2e/src/tabs.spec.ts | 24 ++++++ .../test/base/src/app/app-routing.module.ts | 4 + .../test/base/src/app/tabs-slots.component.ts | 32 ++++++++ 4 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 angular/test/base/src/app/tabs-slots.component.ts diff --git a/angular/src/directives/navigation/ion-tabs.ts b/angular/src/directives/navigation/ion-tabs.ts index 4ae93fd6ba..1fa8965218 100644 --- a/angular/src/directives/navigation/ion-tabs.ts +++ b/angular/src/directives/navigation/ion-tabs.ts @@ -1,4 +1,16 @@ -import { Component, ContentChild, EventEmitter, HostListener, Output, ViewChild } from '@angular/core'; +import { + AfterContentChecked, + AfterContentInit, + Component, + ContentChild, + ContentChildren, + ElementRef, + EventEmitter, + HostListener, + Output, + QueryList, + ViewChild, +} from '@angular/core'; import { NavController } from '../../providers/nav-controller'; import { IonTabBar } from '../proxies'; @@ -8,11 +20,13 @@ import { StackEvent } from './stack-utils'; @Component({ selector: 'ion-tabs', - template: ` -
+ template: ` + +
- `, + + `, styles: [ ` :host { @@ -41,15 +55,28 @@ import { StackEvent } from './stack-utils'; ], }) // eslint-disable-next-line @angular-eslint/component-class-suffix -export class IonTabs { +export class IonTabs implements AfterContentInit, AfterContentChecked { @ViewChild('outlet', { read: IonRouterOutlet, static: false }) outlet: IonRouterOutlet; + @ViewChild('tabsInner', { read: ElementRef, static: true }) tabsInner: ElementRef; + @ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined; + @ContentChildren(IonTabBar) tabBars: QueryList; @Output() ionTabsWillChange = new EventEmitter<{ tab: string }>(); @Output() ionTabsDidChange = new EventEmitter<{ tab: string }>(); + private tabBarSlot = 'bottom'; + constructor(private navCtrl: NavController) {} + ngAfterContentInit(): void { + this.detectSlotChanges(); + } + + ngAfterContentChecked(): void { + this.detectSlotChanges(); + } + /** * @internal */ @@ -137,4 +164,48 @@ export class IonTabs { getSelected(): string | undefined { return this.outlet.getActiveStackId(); } + + /** + * Detects changes to the slot attribute of the tab bar. + * + * If the slot attribute has changed, then the tab bar + * should be relocated to the new slot position. + */ + private detectSlotChanges(): void { + this.tabBars.forEach((tabBar: any) => { + // el is a protected attribute from the generated component wrapper + const currentSlot = tabBar.el.getAttribute('slot'); + + if (currentSlot !== this.tabBarSlot) { + this.tabBarSlot = currentSlot; + this.relocateTabBar(); + } + }); + } + + /** + * Relocates the tab bar to the new slot position. + */ + private relocateTabBar(): void { + /** + * `el` is a protected attribute from the generated component wrapper. + * To avoid having to manually create the wrapper for tab bar, we + * cast the tab bar to any and access the protected attribute. + */ + const tabBar = (this.tabBar as any).el as HTMLElement; + + if (this.tabBarSlot === 'top') { + /** + * A tab bar with a slot of "top" should be inserted + * at the top of the container. + */ + this.tabsInner.nativeElement.before(tabBar); + } else { + /** + * A tab bar with a slot of "bottom" or without a slot + * should be inserted at the end of the container. + */ + this.tabsInner.nativeElement.after(tabBar); + } + } } diff --git a/angular/test/base/e2e/src/tabs.spec.ts b/angular/test/base/e2e/src/tabs.spec.ts index 618c064751..48d1dd5c6d 100644 --- a/angular/test/base/e2e/src/tabs.spec.ts +++ b/angular/test/base/e2e/src/tabs.spec.ts @@ -435,6 +435,30 @@ describe('Tabs', () => { }) }) +it('Tabs should support conditional slots', () => { + cy.visit('/tabs-slots'); + + cy.get('ion-tabs .tabs-inner + ion-tab-bar').should('have.length', 1); + + // Click the button to change the slot to the top + cy.get('#set-slot-top').click(); + + // The tab bar should be removed from the bottom + cy.get('ion-tabs .tabs-inner + ion-tab-bar').should('have.length', 0); + + // The tab bar should be added to the top + cy.get('ion-tabs ion-tab-bar + .tabs-inner').should('have.length', 1); + + // Click the button to change the slot to the bottom + cy.get('#set-slot-bottom').click(); + + // The tab bar should be removed from the top + cy.get('ion-tabs ion-tab-bar + .tabs-inner').should('have.length', 0); + + // The tab bar should be added to the bottom + cy.get('ion-tabs .tabs-inner + ion-tab-bar').should('have.length', 1); +}); + function testTabTitle(title) { const tab = getSelectedTab(); diff --git a/angular/test/base/src/app/app-routing.module.ts b/angular/test/base/src/app/app-routing.module.ts index c30f654b0e..183d235e94 100644 --- a/angular/test/base/src/app/app-routing.module.ts +++ b/angular/test/base/src/app/app-routing.module.ts @@ -57,6 +57,10 @@ const routes: Routes = [ path: 'tabs-global', loadChildren: () => import('./tabs-global/tabs-global.module').then(m => m.TabsGlobalModule) }, + { + path: 'tabs-slots', + loadComponent: () => import('./tabs-slots.component').then(c => c.TabsSlotsComponent) + }, { path: 'nested-outlet', component: NestedOutletComponent, diff --git a/angular/test/base/src/app/tabs-slots.component.ts b/angular/test/base/src/app/tabs-slots.component.ts new file mode 100644 index 0000000000..68715ff9c2 --- /dev/null +++ b/angular/test/base/src/app/tabs-slots.component.ts @@ -0,0 +1,32 @@ +import { Component } from "@angular/core"; +import { IonicModule } from "@ionic/angular"; + +/** + * Test purpose: Validates that the tab bar is relocated to the + * correct location when the slot attribute changes or is bound + * to a variable. + */ +@Component({ + selector: 'app-tabs-slots', + template: ` + + + + + +
+ +
+ `, + imports: [IonicModule], + standalone: true +}) +export class TabsSlotsComponent { + + slot?: string; + + setSlot(newSlot: string) { + this.slot = newSlot; + } + +}