Skip to content

Commit 822da42

Browse files
authored
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 ```
1 parent 0cf4c03 commit 822da42

File tree

19 files changed

+292
-22
lines changed

19 files changed

+292
-22
lines changed

packages/angular/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { AngularDelegate, bindLifecycleEvents, IonModalToken } from './providers
99

1010
export type { IonicWindow } from './types/interfaces';
1111
export type { ViewDidEnter, ViewDidLeave, ViewWillEnter, ViewWillLeave } from './types/ionic-lifecycle-hooks';
12+
export type { ModalOptions, PopoverOptions } from './types/overlay-options';
1213

1314
export { NavParams } from './directives/navigation/nav-params';
1415

packages/angular/common/src/providers/angular-delegate.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ export class AngularDelegate {
3636
create(
3737
environmentInjector: EnvironmentInjector,
3838
injector: Injector,
39-
elementReferenceKey?: string
39+
elementReferenceKey?: string,
40+
customInjector?: Injector
4041
): AngularFrameworkDelegate {
4142
return new AngularFrameworkDelegate(
4243
environmentInjector,
4344
injector,
4445
this.applicationRef,
4546
this.zone,
4647
elementReferenceKey,
47-
this.config.useSetInputAPI ?? false
48+
this.config.useSetInputAPI ?? false,
49+
customInjector
4850
);
4951
}
5052
}
@@ -59,7 +61,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
5961
private applicationRef: ApplicationRef,
6062
private zone: NgZone,
6163
private elementReferenceKey?: string,
62-
private enableSignalsSupport?: boolean
64+
private enableSignalsSupport?: boolean,
65+
private customInjector?: Injector
6366
) {}
6467

6568
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
@@ -93,7 +96,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
9396
componentProps,
9497
cssClasses,
9598
this.elementReferenceKey,
96-
this.enableSignalsSupport
99+
this.enableSignalsSupport,
100+
this.customInjector
97101
);
98102
resolve(el);
99103
});
@@ -131,7 +135,8 @@ export const attachView = (
131135
params: any,
132136
cssClasses: string[] | undefined,
133137
elementReferenceKey: string | undefined,
134-
enableSignalsSupport: boolean | undefined
138+
enableSignalsSupport: boolean | undefined,
139+
customInjector?: Injector
135140
): any => {
136141
/**
137142
* Wraps the injector with a custom injector that
@@ -158,7 +163,7 @@ export const attachView = (
158163

159164
const childInjector = Injector.create({
160165
providers,
161-
parent: injector,
166+
parent: customInjector ?? injector,
162167
});
163168

164169
const componentRef = createComponent<any>(component, {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Injector } from '@angular/core';
2+
import type { ModalOptions as CoreModalOptions, PopoverOptions as CorePopoverOptions } from '@ionic/core/components';
3+
4+
/**
5+
* Modal options with Angular-specific injector support.
6+
* Extends @ionic/core ModalOptions with an optional injector property.
7+
*/
8+
export type ModalOptions = CoreModalOptions & {
9+
injector?: Injector;
10+
};
11+
12+
/**
13+
* Popover options with Angular-specific injector support.
14+
* Extends @ionic/core PopoverOptions with an optional injector property.
15+
*/
16+
export type PopoverOptions = CorePopoverOptions & {
17+
injector?: Injector;
18+
};

packages/angular/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export {
3232
ViewDidEnter,
3333
ViewDidLeave,
3434
} from '@ionic/angular/common';
35+
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
3536
export { AlertController } from './providers/alert-controller';
3637
export { AnimationController } from './providers/animation-controller';
3738
export { ActionSheetController } from './providers/action-sheet-controller';
@@ -98,14 +99,12 @@ export {
9899
IonicSafeString,
99100
LoadingOptions,
100101
MenuCustomEvent,
101-
ModalOptions,
102102
NavCustomEvent,
103103
PickerOptions,
104104
PickerButton,
105105
PickerColumn,
106106
PickerColumnOption,
107107
PlatformConfig,
108-
PopoverOptions,
109108
RadioGroupCustomEvent,
110109
RadioGroupChangeEventDetail,
111110
RangeCustomEvent,

packages/angular/src/providers/modal-controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
22
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
3-
import type { ModalOptions } from '@ionic/core';
3+
import type { ModalOptions } from '@ionic/angular/common';
44
import { modalController } from '@ionic/core';
55

66
@Injectable()
@@ -14,9 +14,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
1414
}
1515

1616
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
17+
const { injector: customInjector, ...restOpts } = opts;
1718
return super.create({
18-
...opts,
19-
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
19+
...restOpts,
20+
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
2021
});
2122
}
2223
}

packages/angular/src/providers/popover-controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injector, inject, EnvironmentInjector } from '@angular/core';
22
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
3-
import type { PopoverOptions } from '@ionic/core';
3+
import type { PopoverOptions } from '@ionic/angular/common';
44
import { popoverController } from '@ionic/core';
55

66
export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
@@ -13,9 +13,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
1313
}
1414

1515
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
16+
const { injector: customInjector, ...restOpts } = opts;
1617
return super.create({
17-
...opts,
18-
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
18+
...restOpts,
19+
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
1920
});
2021
}
2122
}

packages/angular/standalone/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828
ViewWillLeave,
2929
ViewDidLeave,
3030
} from '@ionic/angular/common';
31+
export type { ModalOptions, PopoverOptions } from '@ionic/angular/common';
3132
export { IonNav } from './navigation/nav';
3233
export {
3334
IonCheckbox,
@@ -96,14 +97,12 @@ export {
9697
IonicSafeString,
9798
LoadingOptions,
9899
MenuCustomEvent,
99-
ModalOptions,
100100
NavCustomEvent,
101101
PickerOptions,
102102
PickerButton,
103103
PickerColumn,
104104
PickerColumnOption,
105105
PlatformConfig,
106-
PopoverOptions,
107106
RadioGroupCustomEvent,
108107
RadioGroupChangeEventDetail,
109108
RangeCustomEvent,

packages/angular/standalone/src/providers/modal-controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
22
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
3-
import type { ModalOptions } from '@ionic/core/components';
3+
import type { ModalOptions } from '@ionic/angular/common';
44
import { modalController } from '@ionic/core/components';
55
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
66

@@ -16,9 +16,10 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
1616
}
1717

1818
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
19+
const { injector: customInjector, ...restOpts } = opts;
1920
return super.create({
20-
...opts,
21-
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
21+
...restOpts,
22+
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal', customInjector),
2223
});
2324
}
2425
}

packages/angular/standalone/src/providers/popover-controller.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injector, inject, EnvironmentInjector } from '@angular/core';
22
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
3-
import type { PopoverOptions } from '@ionic/core/components';
3+
import type { PopoverOptions } from '@ionic/angular/common';
44
import { popoverController } from '@ionic/core/components';
55
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';
66

@@ -15,9 +15,10 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
1515
}
1616

1717
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
18+
const { injector: customInjector, ...restOpts } = opts;
1819
return super.create({
19-
...opts,
20-
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
20+
...restOpts,
21+
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover', customInjector),
2122
});
2223
}
2324
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Modal: Custom Injector', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/standalone/modal-custom-injector');
6+
});
7+
8+
test('should inject custom service via custom injector', async ({ page }) => {
9+
await page.locator('ion-button#open-modal-with-custom-injector').click();
10+
11+
await expect(page.locator('ion-modal')).toBeVisible();
12+
13+
const serviceValue = page.locator('#service-value');
14+
await expect(serviceValue).toHaveText('Service Value: custom-injector-value');
15+
16+
await page.locator('#close-modal').click();
17+
await expect(page.locator('ion-modal')).not.toBeVisible();
18+
});
19+
20+
test('should fail without custom injector when service is not globally provided', async ({ page }) => {
21+
page.on('dialog', async (dialog) => {
22+
expect(dialog.message()).toContain('TestService not available');
23+
await dialog.accept();
24+
});
25+
26+
await page.locator('ion-button#open-modal-without-custom-injector').click();
27+
28+
await page.waitForEvent('dialog');
29+
});
30+
});

0 commit comments

Comments
 (0)