fix(angular): tabs supports conditional slot bindings (#27582)

Issue number: Resolves #19484

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

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
<ion-tabs>
  <ion-tab-bar [slot]="slot"></ion-tab-bar>
</ion-tabs>
```

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
<ion-tabs>
  <ion-tab-bar slot="top"></ion-tab-bar>
</ion-tabs>
```

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

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

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev-build: `7.0.11-dev.11685631370.18980633`

---------

Co-authored-by: Liam DeBeasi <liamdebeasi@users.noreply.github.com>
This commit is contained in:
Sean Perkins
2023-06-06 22:41:33 -04:00
committed by GitHub
parent f2c1845fba
commit d20bea561c
4 changed files with 136 additions and 5 deletions

View File

@ -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 { NavController } from '../../providers/nav-controller';
import { IonTabBar } from '../proxies'; import { IonTabBar } from '../proxies';
@ -8,11 +20,13 @@ import { StackEvent } from './stack-utils';
@Component({ @Component({
selector: 'ion-tabs', selector: 'ion-tabs',
template: `<ng-content select="[slot=top]"></ng-content> template: `
<div class="tabs-inner"> <ng-content select="[slot=top]"></ng-content>
<div class="tabs-inner" #tabsInner>
<ion-router-outlet #outlet tabs="true" (stackEvents)="onPageSelected($event)"></ion-router-outlet> <ion-router-outlet #outlet tabs="true" (stackEvents)="onPageSelected($event)"></ion-router-outlet>
</div> </div>
<ng-content></ng-content>`, <ng-content></ng-content>
`,
styles: [ styles: [
` `
:host { :host {
@ -41,15 +55,28 @@ import { StackEvent } from './stack-utils';
], ],
}) })
// eslint-disable-next-line @angular-eslint/component-class-suffix // 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('outlet', { read: IonRouterOutlet, static: false }) outlet: IonRouterOutlet;
@ViewChild('tabsInner', { read: ElementRef, static: true }) tabsInner: ElementRef<HTMLDivElement>;
@ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined; @ContentChild(IonTabBar, { static: false }) tabBar: IonTabBar | undefined;
@ContentChildren(IonTabBar) tabBars: QueryList<IonTabBar>;
@Output() ionTabsWillChange = new EventEmitter<{ tab: string }>(); @Output() ionTabsWillChange = new EventEmitter<{ tab: string }>();
@Output() ionTabsDidChange = new EventEmitter<{ tab: string }>(); @Output() ionTabsDidChange = new EventEmitter<{ tab: string }>();
private tabBarSlot = 'bottom';
constructor(private navCtrl: NavController) {} constructor(private navCtrl: NavController) {}
ngAfterContentInit(): void {
this.detectSlotChanges();
}
ngAfterContentChecked(): void {
this.detectSlotChanges();
}
/** /**
* @internal * @internal
*/ */
@ -137,4 +164,48 @@ export class IonTabs {
getSelected(): string | undefined { getSelected(): string | undefined {
return this.outlet.getActiveStackId(); 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);
}
}
} }

View File

@ -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) { function testTabTitle(title) {
const tab = getSelectedTab(); const tab = getSelectedTab();

View File

@ -57,6 +57,10 @@ const routes: Routes = [
path: 'tabs-global', path: 'tabs-global',
loadChildren: () => import('./tabs-global/tabs-global.module').then(m => m.TabsGlobalModule) 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', path: 'nested-outlet',
component: NestedOutletComponent, component: NestedOutletComponent,

View File

@ -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: `
<ion-tabs>
<ion-tab-bar [slot]="slot"></ion-tab-bar>
</ion-tabs>
<ion-fab vertical="bottom" horizontal="end">
<button id="set-slot-top" (click)="setSlot('top')">Slot - top</button>
<br />
<button id="set-slot-bottom"(click)="setSlot('bottom')">Slot - bottom</button>
</ion-fab>
`,
imports: [IonicModule],
standalone: true
})
export class TabsSlotsComponent {
slot?: string;
setSlot(newSlot: string) {
this.slot = newSlot;
}
}