mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
feat(angular): add custom injector support for modal and popover controllers (#30899)
Issue number: resolves #30638 --------- <!-- 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? When using `ModalController.create()` or `PopoverController.create()` in Angular, components rendered inside overlays cannot access non-global services or tokens from the component tree. For example, route-scoped services or Angular's Dir directive for bidirectional text support are not accessible from within a modal, requiring complex workarounds with wrapper components. ## What is the new behavior? `ModalController.create()` and `PopoverController.create()` now accept an optional injector property that allows passing a custom Angular Injector. This enables overlay components to access services and tokens that are not available in the root injector, such as route-scoped services or the Dir directive from Angular CDK. ```typescript const customInjector = Injector.create({ providers: [{ provide: MyService, useValue: myServiceInstance }], parent: this.injector, }); ``` ```typescript const modal = await this.modalController.create({ component: MyModalComponent, injector: customInjector, }); ``` ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.17-dev.11769628168.11eca7cd ```
This commit is contained in:
@@ -9,6 +9,7 @@ export { AngularDelegate, bindLifecycleEvents, IonModalToken } from './providers
|
||||
|
||||
export type { IonicWindow } from './types/interfaces';
|
||||
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';
|
||||
export type { ModalOptions, PopoverOptions } from './types/overlay-options';
|
||||
|
||||
export { NavParams } from './directives/navigation/nav-params';
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export class AngularDelegate {
|
||||
create(
|
||||
environmentInjector: EnvironmentInjector,
|
||||
injector: Injector,
|
||||
elementReferenceKey?: string
|
||||
elementReferenceKey?: string,
|
||||
customInjector?: Injector
|
||||
): AngularFrameworkDelegate {
|
||||
return new AngularFrameworkDelegate(
|
||||
environmentInjector,
|
||||
@@ -44,7 +45,8 @@ export class AngularDelegate {
|
||||
this.applicationRef,
|
||||
this.zone,
|
||||
elementReferenceKey,
|
||||
this.config.useSetInputAPI ?? false
|
||||
this.config.useSetInputAPI ?? false,
|
||||
customInjector
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +61,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
|
||||
private applicationRef: ApplicationRef,
|
||||
private zone: NgZone,
|
||||
private elementReferenceKey?: string,
|
||||
private enableSignalsSupport?: boolean
|
||||
private enableSignalsSupport?: boolean,
|
||||
private customInjector?: Injector
|
||||
) {}
|
||||
|
||||
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
|
||||
@@ -93,7 +96,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
|
||||
componentProps,
|
||||
cssClasses,
|
||||
this.elementReferenceKey,
|
||||
this.enableSignalsSupport
|
||||
this.enableSignalsSupport,
|
||||
this.customInjector
|
||||
);
|
||||
resolve(el);
|
||||
});
|
||||
@@ -131,7 +135,8 @@ export const attachView = (
|
||||
params: any,
|
||||
cssClasses: string[] | undefined,
|
||||
elementReferenceKey: string | undefined,
|
||||
enableSignalsSupport: boolean | undefined
|
||||
enableSignalsSupport: boolean | undefined,
|
||||
customInjector?: Injector
|
||||
): any => {
|
||||
/**
|
||||
* Wraps the injector with a custom injector that
|
||||
@@ -158,7 +163,7 @@ export const attachView = (
|
||||
|
||||
const childInjector = Injector.create({
|
||||
providers,
|
||||
parent: injector,
|
||||
parent: customInjector ?? injector,
|
||||
});
|
||||
|
||||
const componentRef = createComponent<any>(component, {
|
||||
|
||||
18
packages/angular/common/src/types/overlay-options.ts
Normal file
18
packages/angular/common/src/types/overlay-options.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Injector } from '@angular/core';
|
||||
import type { ModalOptions as CoreModalOptions, PopoverOptions as CorePopoverOptions } from '@ionic/core/components';
|
||||
|
||||
/**
|
||||
* Modal options with Angular-specific injector support.
|
||||
* Extends @ionic/core ModalOptions with an optional injector property.
|
||||
*/
|
||||
export type ModalOptions = CoreModalOptions & {
|
||||
injector?: Injector;
|
||||
};
|
||||
|
||||
/**
|
||||
* Popover options with Angular-specific injector support.
|
||||
* Extends @ionic/core PopoverOptions with an optional injector property.
|
||||
*/
|
||||
export type PopoverOptions = CorePopoverOptions & {
|
||||
injector?: Injector;
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
ViewDidEnter,
|
||||
ViewDidLeave,
|
||||
} from '@ionic/angular/common';
|
||||
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
|
||||
export { AlertController } from './providers/alert-controller';
|
||||
export { AnimationController } from './providers/animation-controller';
|
||||
export { ActionSheetController } from './providers/action-sheet-controller';
|
||||
@@ -98,14 +99,12 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalOptions,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
PickerButton,
|
||||
PickerColumn,
|
||||
PickerColumnOption,
|
||||
PlatformConfig,
|
||||
PopoverOptions,
|
||||
RadioGroupCustomEvent,
|
||||
RadioGroupChangeEventDetail,
|
||||
RangeCustomEvent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
|
||||
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
|
||||
import type { ModalOptions } from '@ionic/core';
|
||||
import type { ModalOptions } from '@ionic/angular/common';
|
||||
import { modalController } from '@ionic/core';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,9 +14,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
|
||||
}
|
||||
|
||||
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
|
||||
const { injector: customInjector, ...restOpts } = opts;
|
||||
return super.create({
|
||||
...opts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
|
||||
...restOpts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injector, inject, EnvironmentInjector } from '@angular/core';
|
||||
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
|
||||
import type { PopoverOptions } from '@ionic/core';
|
||||
import type { PopoverOptions } from '@ionic/angular/common';
|
||||
import { popoverController } from '@ionic/core';
|
||||
|
||||
export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
|
||||
@@ -13,9 +13,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
|
||||
}
|
||||
|
||||
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
|
||||
const { injector: customInjector, ...restOpts } = opts;
|
||||
return super.create({
|
||||
...opts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
|
||||
...restOpts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
ViewWillLeave,
|
||||
ViewDidLeave,
|
||||
} from '@ionic/angular/common';
|
||||
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
|
||||
export { IonNav } from './navigation/nav';
|
||||
export {
|
||||
IonCheckbox,
|
||||
@@ -96,14 +97,12 @@ export {
|
||||
IonicSafeString,
|
||||
LoadingOptions,
|
||||
MenuCustomEvent,
|
||||
ModalOptions,
|
||||
NavCustomEvent,
|
||||
PickerOptions,
|
||||
PickerButton,
|
||||
PickerColumn,
|
||||
PickerColumnOption,
|
||||
PlatformConfig,
|
||||
PopoverOptions,
|
||||
RadioGroupCustomEvent,
|
||||
RadioGroupChangeEventDetail,
|
||||
RangeCustomEvent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
|
||||
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
|
||||
import type { ModalOptions } from '@ionic/core/components';
|
||||
import type { ModalOptions } from '@ionic/angular/common';
|
||||
import { modalController } from '@ionic/core/components';
|
||||
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
|
||||
|
||||
@@ -16,9 +16,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
|
||||
}
|
||||
|
||||
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
|
||||
const { injector: customInjector, ...restOpts } = opts;
|
||||
return super.create({
|
||||
...opts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
|
||||
...restOpts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injector, inject, EnvironmentInjector } from '@angular/core';
|
||||
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
|
||||
import type { PopoverOptions } from '@ionic/core/components';
|
||||
import type { PopoverOptions } from '@ionic/angular/common';
|
||||
import { popoverController } from '@ionic/core/components';
|
||||
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';
|
||||
|
||||
@@ -15,9 +15,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
|
||||
}
|
||||
|
||||
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
|
||||
const { injector: customInjector, ...restOpts } = opts;
|
||||
return super.create({
|
||||
...opts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
|
||||
...restOpts,
|
||||
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Modal: Custom Injector', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/standalone/modal-custom-injector');
|
||||
});
|
||||
|
||||
test('should inject custom service via custom injector', async ({ page }) => {
|
||||
await page.locator('ion-button#open-modal-with-custom-injector').click();
|
||||
|
||||
await expect(page.locator('ion-modal')).toBeVisible();
|
||||
|
||||
const serviceValue = page.locator('#service-value');
|
||||
await expect(serviceValue).toHaveText('Service Value: custom-injector-value');
|
||||
|
||||
await page.locator('#close-modal').click();
|
||||
await expect(page.locator('ion-modal')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should fail without custom injector when service is not globally provided', async ({ page }) => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain('TestService not available');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.locator('ion-button#open-modal-without-custom-injector').click();
|
||||
|
||||
await page.waitForEvent('dialog');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Popover: Custom Injector', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/standalone/popover-custom-injector');
|
||||
});
|
||||
|
||||
test('should inject custom service via custom injector', async ({ page }) => {
|
||||
await page.locator('ion-button#open-popover-with-custom-injector').click();
|
||||
|
||||
await expect(page.locator('ion-popover')).toBeVisible();
|
||||
|
||||
const serviceValue = page.locator('#service-value');
|
||||
await expect(serviceValue).toHaveText('Service Value: custom-injector-value');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export const routes: Routes = [
|
||||
]
|
||||
},
|
||||
{ path: 'programmatic-modal', loadComponent: () => import('../programmatic-modal/programmatic-modal.component').then(c => c.ProgrammaticModalComponent) },
|
||||
{ path: 'modal-custom-injector', loadComponent: () => import('../modal-custom-injector/modal-custom-injector.component').then(c => c.ModalCustomInjectorComponent) },
|
||||
{ path: 'popover-custom-injector', loadComponent: () => import('../popover-custom-injector/popover-custom-injector.component').then(c => c.PopoverCustomInjectorComponent) },
|
||||
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
|
||||
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },
|
||||
{ path: 'router-link', loadComponent: () => import('../router-link/router-link.component').then(c => c.RouterLinkComponent) },
|
||||
|
||||
@@ -110,6 +110,11 @@
|
||||
Programmatic Modal Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/modal-custom-injector">
|
||||
<ion-label>
|
||||
Modal Custom Injector Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/overlay-controllers">
|
||||
<ion-label>
|
||||
Overlay Controllers Test
|
||||
@@ -120,6 +125,11 @@
|
||||
Popover Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/popover-custom-injector">
|
||||
<ion-label>
|
||||
Popover Custom Injector Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Component, inject, Injector } from '@angular/core';
|
||||
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, ModalController } from '@ionic/angular/standalone';
|
||||
import { ModalCustomInjectorModalComponent } from './modal/modal.component';
|
||||
import { TestService } from './test.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal-custom-injector',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal Custom Injector Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<ion-button id="open-modal-with-custom-injector" (click)="openWithCustomInjector()">
|
||||
Open Modal with Custom Injector
|
||||
</ion-button>
|
||||
<ion-button id="open-modal-without-custom-injector" (click)="openWithoutCustomInjector()">
|
||||
Open Modal without Custom Injector
|
||||
</ion-button>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton]
|
||||
})
|
||||
export class ModalCustomInjectorComponent {
|
||||
private modalController = inject(ModalController);
|
||||
private injector = inject(Injector);
|
||||
|
||||
async openWithCustomInjector() {
|
||||
const testService = new TestService();
|
||||
testService.setValue('custom-injector-value');
|
||||
|
||||
const customInjector = Injector.create({
|
||||
providers: [{ provide: TestService, useValue: testService }],
|
||||
parent: this.injector,
|
||||
});
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalCustomInjectorModalComponent,
|
||||
injector: customInjector,
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async openWithoutCustomInjector() {
|
||||
try {
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalCustomInjectorModalComponent,
|
||||
});
|
||||
await modal.present();
|
||||
} catch (e) {
|
||||
alert('Error: TestService not available without custom injector');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, IonButtons } from '@ionic/angular/standalone';
|
||||
import { TestService } from '../test.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal-custom-injector-modal',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal with Custom Injector</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button id="close-modal" (click)="dismiss()">Close</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<p id="service-value">Service Value: {{ serviceValue }}</p>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton, IonButtons]
|
||||
})
|
||||
export class ModalCustomInjectorModalComponent implements OnInit {
|
||||
private testService = inject(TestService);
|
||||
serviceValue = '';
|
||||
modal: HTMLIonModalElement | undefined;
|
||||
|
||||
ngOnInit() {
|
||||
this.serviceValue = this.testService.getValue();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modal?.dismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable()
|
||||
export class TestService {
|
||||
private value = 'default-value';
|
||||
|
||||
setValue(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, inject, Injector } from '@angular/core';
|
||||
import { IonContent, IonHeader, IonTitle, IonToolbar, IonButton, PopoverController } from '@ionic/angular/standalone';
|
||||
import { PopoverCustomInjectorPopoverComponent } from './popover/popover.component';
|
||||
import { TestService } from './test.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-popover-custom-injector',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Popover Custom Injector Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<ion-button id="open-popover-with-custom-injector" (click)="openWithCustomInjector($event)">
|
||||
Open Popover with Custom Injector
|
||||
</ion-button>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonButton]
|
||||
})
|
||||
export class PopoverCustomInjectorComponent {
|
||||
private popoverController = inject(PopoverController);
|
||||
private injector = inject(Injector);
|
||||
|
||||
async openWithCustomInjector(event: Event) {
|
||||
const testService = new TestService();
|
||||
testService.setValue('custom-injector-value');
|
||||
|
||||
const customInjector = Injector.create({
|
||||
providers: [{ provide: TestService, useValue: testService }],
|
||||
parent: this.injector,
|
||||
});
|
||||
|
||||
const popover = await this.popoverController.create({
|
||||
component: PopoverCustomInjectorPopoverComponent,
|
||||
event: event,
|
||||
injector: customInjector,
|
||||
});
|
||||
|
||||
await popover.present();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { IonContent } from '@ionic/angular/standalone';
|
||||
import { TestService } from '../test.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-popover-custom-injector-popover',
|
||||
template: `
|
||||
<ion-content>
|
||||
<p id="service-value">Service Value: {{ serviceValue }}</p>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonContent]
|
||||
})
|
||||
export class PopoverCustomInjectorPopoverComponent implements OnInit {
|
||||
private testService = inject(TestService);
|
||||
serviceValue = '';
|
||||
|
||||
ngOnInit() {
|
||||
this.serviceValue = this.testService.getValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable()
|
||||
export class TestService {
|
||||
private value = 'default-value';
|
||||
|
||||
setValue(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user