Skip to content
Open
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 packages/angular/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { AngularModalOptions, AngularPopoverOptions } from './types/overlay-options';

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

Expand Down
17 changes: 11 additions & 6 deletions packages/angular/common/src/providers/angular-delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,17 @@ export class AngularDelegate {
create(
environmentInjector: EnvironmentInjector,
injector: Injector,
elementReferenceKey?: string
elementReferenceKey?: string,
customInjector?: Injector
): AngularFrameworkDelegate {
return new AngularFrameworkDelegate(
environmentInjector,
injector,
this.applicationRef,
this.zone,
elementReferenceKey,
this.config.useSetInputAPI ?? false
this.config.useSetInputAPI ?? false,
customInjector
);
}
}
Expand All @@ -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> {
Expand Down Expand Up @@ -93,7 +96,8 @@ export class AngularFrameworkDelegate implements FrameworkDelegate {
componentProps,
cssClasses,
this.elementReferenceKey,
this.enableSignalsSupport
this.enableSignalsSupport,
this.customInjector
);
resolve(el);
});
Expand Down Expand Up @@ -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
Expand All @@ -158,7 +163,7 @@ export const attachView = (

const childInjector = Injector.create({
providers,
parent: injector,
parent: customInjector ?? injector,
});

const componentRef = createComponent<any>(component, {
Expand Down
10 changes: 10 additions & 0 deletions packages/angular/common/src/types/overlay-options.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if using Angular as the prefix is consistent with how the other types are set up. Wouldn't it make more sense to call it IonicModalOptions and IonicPopoverOptions?

Copy link
Member Author

@ShaneK ShaneK Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, honestly. We're kind of all over the place with our naming. For me, it makes sense that package-specific options would be named after our packages, but we have names kinda everywhere. Like:

  • IonicVueRouterOptions does Ionic{Package}{Thing}
  • Angular has IonicGlobal and IonicWindow which is just Ionic{Thing}
  • Angular also has NavigationOptions, which doesn't have Ionic at all or Angular
  • Angular also also has AngularDelegate and AngularFrameworkDelegate which is in line with this naming
  • React has IonicReactProps, which is Ionic{Package}{Thing} and it also has HookOverlayOptions which has neither Ionic nor React.

Probably the most widely used of these is IonicVueRouterOptions, and I could see us going for example IonicAngularModalOptions. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I lean towards using IonicAngularModalOptions... but it also feels a bit too longer. @brandyscarney you have a knack on naming, thoughts?

Also would be great if we can create a ticket to try to sync the naming especially since Ionic React router is being worked on.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Injector } from '@angular/core';
import type { ModalOptions, PopoverOptions } from '@ionic/core/components';

export interface AngularModalOptions extends ModalOptions {
injector?: Injector;
}

export interface AngularPopoverOptions extends PopoverOptions {
injector?: Injector;
}
1 change: 1 addition & 0 deletions packages/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
ViewDidEnter,
ViewDidLeave,
} from '@ionic/angular/common';
export type { AngularModalOptions, AngularPopoverOptions } from '@ionic/angular/common';
export { AlertController } from './providers/alert-controller';
export { AnimationController } from './providers/animation-controller';
export { ActionSheetController } from './providers/action-sheet-controller';
Expand Down
11 changes: 6 additions & 5 deletions packages/angular/src/providers/modal-controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { ModalOptions } from '@ionic/core';
import type { AngularModalOptions } from '@ionic/angular/common';
import { modalController } from '@ionic/core';

@Injectable()
export class ModalController extends OverlayBaseController<ModalOptions, HTMLIonModalElement> {
export class ModalController extends OverlayBaseController<AngularModalOptions, HTMLIonModalElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
Expand All @@ -13,10 +13,11 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
super(modalController);
}

create(opts: ModalOptions): Promise<HTMLIonModalElement> {
create(opts: AngularModalOptions): 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),
});
}
}
11 changes: 6 additions & 5 deletions packages/angular/src/providers/popover-controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Injector, inject, EnvironmentInjector } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { PopoverOptions } from '@ionic/core';
import type { AngularPopoverOptions } from '@ionic/angular/common';
import { popoverController } from '@ionic/core';

export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
export class PopoverController extends OverlayBaseController<AngularPopoverOptions, HTMLIonPopoverElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
Expand All @@ -12,10 +12,11 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
super(popoverController);
}

create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
create(opts: AngularPopoverOptions): 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),
});
}
}
1 change: 1 addition & 0 deletions packages/angular/standalone/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
ViewWillLeave,
ViewDidLeave,
} from '@ionic/angular/common';
export type { AngularModalOptions, AngularPopoverOptions } from '@ionic/angular/common';
export { IonNav } from './navigation/nav';
export {
IonCheckbox,
Expand Down
11 changes: 6 additions & 5 deletions packages/angular/standalone/src/providers/modal-controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { ModalOptions } from '@ionic/core/components';
import type { AngularModalOptions } from '@ionic/angular/common';
import { modalController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';

@Injectable()
export class ModalController extends OverlayBaseController<ModalOptions, HTMLIonModalElement> {
export class ModalController extends OverlayBaseController<AngularModalOptions, HTMLIonModalElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
Expand All @@ -15,10 +15,11 @@ export class ModalController extends OverlayBaseController<ModalOptions, HTMLIon
defineCustomElement();
}

create(opts: ModalOptions): Promise<HTMLIonModalElement> {
create(opts: AngularModalOptions): 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),
});
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Injector, inject, EnvironmentInjector } from '@angular/core';
import { AngularDelegate, OverlayBaseController } from '@ionic/angular/common';
import type { PopoverOptions } from '@ionic/core/components';
import type { AngularPopoverOptions } from '@ionic/angular/common';
import { popoverController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';

export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
export class PopoverController extends OverlayBaseController<AngularPopoverOptions, HTMLIonPopoverElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
Expand All @@ -14,10 +14,11 @@ export class PopoverController extends OverlayBaseController<PopoverOptions, HTM
defineCustomElement();
}

create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
create(opts: AngularPopoverOptions): 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),
});
}
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading