Skip to content

Commit 57e2476

Browse files
liamdebeasithetaPCsean-perkinsaveryjohnston
authored
feat(angular): ship Ionic components as Angular standalone components (ionic-team#28311)
Issue number: N/A --------- <!-- 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. --> **1. Bundle Size Reductions** All Ionic UI components and Ionicons are added to the final bundle of an Ionic Angular application. This is because all components and icons are lazily loaded as needed. This prevents the compiler from properly tree shaking applications. This does not cause all components and icons to be loaded on application start, but it does increase the size of the final app output that all users need to download. **Related Issues** ionic-team/ionicons#910 ionic-team/ionicons#536 ionic-team#27280 ionic-team#24352 **2. Standalone Component Support** Standalone Components are a stable API as of Angular 15. The Ionic starter apps on the CLI have NgModule and Standalone options, but all of the Ionic components are still lazily/dynamically loaded using `IonicModule`. Standalone components in Ionic also enable support for new Angular features such as bundling with ESBuild instead of Webpack. ESBuild does not work in Ionic Angular right now because components cannot be statically analyzed since they are dynamically imported. We added preliminary support for standalone components in Ionic v6.3.0. This enabled developers to use their own custom standalone components when routing with `ion-router-outlet`. However, we did not ship standalone components for Ionic's UI components. **Related Issues** ionic-team#25404 ionic-team#27251 ionic-team#27387 **3. Faster Component Load Times** Since Ionic Angular components are lazily loaded, they also need to be hydrated. However, this hydration does not happen immediately which prevents components from being usable for multiple frames. **Related Issues** ionic-team#24352 ionic-team#26474 ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - Ionic components and directives are accessible as Angular standalone components/directives ## 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. --> Associated documentation branch: https://github.com/ionic-team/ionic-docs/tree/feature-7.5 --------- Co-authored-by: Maria Hutt <[email protected]> Co-authored-by: Sean Perkins <[email protected]> Co-authored-by: Amanda Johnston <[email protected]> Co-authored-by: Maria Hutt <[email protected]> Co-authored-by: Sean Perkins <[email protected]>
1 parent 4f43d5c commit 57e2476

File tree

302 files changed

+6918
-1624
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

302 files changed

+6918
-1624
lines changed

core/stencil.config.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,71 @@ import { vueOutputTarget } from '@stencil/vue-output-target';
77
// @ts-ignore
88
import { apiSpecGenerator } from './scripts/api-spec-generator';
99

10+
const componentCorePackage = '@ionic/core';
11+
12+
const getAngularOutputTargets = () => {
13+
const excludeComponents = [
14+
// overlays that accept user components
15+
'ion-modal',
16+
'ion-popover',
17+
18+
// navigation
19+
'ion-router',
20+
'ion-route',
21+
'ion-route-redirect',
22+
'ion-router-link',
23+
'ion-router-outlet',
24+
'ion-nav',
25+
'ion-back-button',
26+
27+
// tabs
28+
'ion-tabs',
29+
'ion-tab',
30+
31+
// auxiliar
32+
'ion-picker-column',
33+
]
34+
return [
35+
angularOutputTarget({
36+
componentCorePackage,
37+
directivesProxyFile: '../packages/angular/src/directives/proxies.ts',
38+
directivesArrayFile: '../packages/angular/src/directives/proxies-list.ts',
39+
excludeComponents,
40+
outputType: 'component',
41+
}),
42+
angularOutputTarget({
43+
componentCorePackage,
44+
directivesProxyFile: '../packages/angular/standalone/src/directives/proxies.ts',
45+
excludeComponents: [
46+
...excludeComponents,
47+
/**
48+
* IonIcon is a special case because it does not come
49+
* from the `@ionic/core` package, so generating proxies that
50+
* are reliant on the CE build will reference the wrong
51+
* import location.
52+
*/
53+
'ion-icon',
54+
/**
55+
* Value Accessors are manually implemented in the `@ionic/angular/standalone` package.
56+
*/
57+
'ion-input',
58+
'ion-textarea',
59+
'ion-searchbar',
60+
'ion-datetime',
61+
'ion-radio',
62+
'ion-segment',
63+
'ion-checkbox',
64+
'ion-toggle',
65+
'ion-range',
66+
'ion-radio-group',
67+
'ion-select'
68+
69+
],
70+
outputType: 'standalone',
71+
})
72+
];
73+
}
74+
1075
export const config: Config = {
1176
autoprefixCss: true,
1277
sourceMap: false,
@@ -61,7 +126,7 @@ export const config: Config = {
61126
],
62127
outputTargets: [
63128
reactOutputTarget({
64-
componentCorePackage: '@ionic/core',
129+
componentCorePackage,
65130
includeImportCustomElements: true,
66131
includePolyfills: false,
67132
includeDefineCustomElements: false,
@@ -98,7 +163,7 @@ export const config: Config = {
98163
]
99164
}),
100165
vueOutputTarget({
101-
componentCorePackage: '@ionic/core',
166+
componentCorePackage,
102167
includeImportCustomElements: true,
103168
includePolyfills: false,
104169
includeDefineCustomElements: false,
@@ -182,30 +247,7 @@ export const config: Config = {
182247
// type: 'stats',
183248
// file: 'stats.json'
184249
// },
185-
angularOutputTarget({
186-
componentCorePackage: '@ionic/core',
187-
directivesProxyFile: '../packages/angular/src/directives/proxies.ts',
188-
directivesArrayFile: '../packages/angular/src/directives/proxies-list.ts',
189-
excludeComponents: [
190-
// overlays that accept user components
191-
'ion-modal',
192-
'ion-popover',
193-
194-
// navigation
195-
'ion-router',
196-
'ion-route',
197-
'ion-route-redirect',
198-
'ion-router-link',
199-
'ion-router-outlet',
200-
201-
// tabs
202-
'ion-tabs',
203-
'ion-tab',
204-
205-
// auxiliar
206-
'ion-picker-column',
207-
],
208-
}),
250+
...getAngularOutputTargets(),
209251
],
210252
buildEs5: 'prod',
211253
testing: {

packages/angular/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,23 @@ $ npx schematics @ionic/angular:ng-add
5252

5353

5454
You'll now be able to add ionic components to a vanilla Angular app setup.
55+
56+
## Project Structure
57+
58+
**common**
59+
60+
This is where logic that is shared between lazy loaded and standalone components live. For example, the lazy loaded IonPopover and standalone IonPopover components extend from a base IonPopover implementation that exists in this directory.
61+
62+
**Note:** This directory exposes internal APIs and is only accessed in the `standalone` and `src` submodules. Ionic developers should never import directly from `@ionic/angular/common`. Instead, they should import from `@ionic/angular` or `@ionic/angular/standalone`.
63+
64+
**standalone**
65+
66+
This is where the standalone component implementations live. It was added as a separate entry point to avoid any lazy loaded logic from accidentally being pulled in to the final build. Having a separate directory allows the lazy loaded implementation to remain accessible from `@ionic/angular` for backwards compatibility.
67+
68+
Ionic developers can access this by importing from `@ionic/angular/standalone`.
69+
70+
**src**
71+
72+
This is where the lazy loaded component implementations live.
73+
74+
Ionic developers can access this by importing from `@ionic/angular`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/index.ts"
4+
},
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './value-accessor';

packages/angular/src/directives/control-value-accessors/value-accessor.ts renamed to packages/angular/common/src/directives/control-value-accessors/value-accessor.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AfterViewInit, ElementRef, Injector, OnDestroy, Directive, HostListener
22
import { ControlValueAccessor, NgControl } from '@angular/forms';
33
import { Subscription } from 'rxjs';
44

5-
import { raf } from '../../util/util';
5+
import { raf } from '../../utils/util';
66

77
// TODO(FW-2827): types
88

@@ -17,11 +17,11 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
1717
protected lastValue: any;
1818
private statusChanges?: Subscription;
1919

20-
constructor(protected injector: Injector, protected el: ElementRef) {}
20+
constructor(protected injector: Injector, protected elementRef: ElementRef) {}
2121

2222
writeValue(value: any): void {
23-
this.el.nativeElement.value = this.lastValue = value;
24-
setIonicClasses(this.el);
23+
this.elementRef.nativeElement.value = this.lastValue = value;
24+
setIonicClasses(this.elementRef);
2525
}
2626

2727
/**
@@ -38,20 +38,20 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
3838
* @param value The new value of the control.
3939
*/
4040
handleValueChange(el: HTMLElement, value: any): void {
41-
if (el === this.el.nativeElement) {
41+
if (el === this.elementRef.nativeElement) {
4242
if (value !== this.lastValue) {
4343
this.lastValue = value;
4444
this.onChange(value);
4545
}
46-
setIonicClasses(this.el);
46+
setIonicClasses(this.elementRef);
4747
}
4848
}
4949

5050
@HostListener('ionBlur', ['$event.target'])
5151
_handleBlurEvent(el: any): void {
52-
if (el === this.el.nativeElement) {
52+
if (el === this.elementRef.nativeElement) {
5353
this.onTouched();
54-
setIonicClasses(this.el);
54+
setIonicClasses(this.elementRef);
5555
}
5656
}
5757

@@ -64,7 +64,7 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
6464
}
6565

6666
setDisabledState(isDisabled: boolean): void {
67-
this.el.nativeElement.disabled = isDisabled;
67+
this.elementRef.nativeElement.disabled = isDisabled;
6868
}
6969

7070
ngOnDestroy(): void {
@@ -87,7 +87,7 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
8787

8888
// Listen for changes in validity, disabled, or pending states
8989
if (ngControl.statusChanges) {
90-
this.statusChanges = ngControl.statusChanges.subscribe(() => setIonicClasses(this.el));
90+
this.statusChanges = ngControl.statusChanges.subscribe(() => setIonicClasses(this.elementRef));
9191
}
9292

9393
/**
@@ -102,7 +102,7 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
102102
const oldFn = formControl[method].bind(formControl);
103103
formControl[method] = (...params: any[]) => {
104104
oldFn(...params);
105-
setIonicClasses(this.el);
105+
setIonicClasses(this.elementRef);
106106
};
107107
}
108108
});
@@ -129,7 +129,7 @@ export const setIonicClasses = (element: ElementRef): void => {
129129

130130
const getClasses = (element: HTMLElement) => {
131131
const classList = element.classList;
132-
const classes = [];
132+
const classes: string[] = [];
133133
for (let i = 0; i < classList.length; i++) {
134134
const item = classList.item(i);
135135
if (item !== null && startsWith(item, 'ng-')) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { HostListener, Input, Optional, ElementRef, NgZone, ChangeDetectorRef, Directive } from '@angular/core';
2+
import type { Components } from '@ionic/core';
3+
import type { AnimationBuilder } from '@ionic/core/components';
4+
5+
import { Config } from '../../providers/config';
6+
import { NavController } from '../../providers/nav-controller';
7+
import { ProxyCmp } from '../../utils/proxy';
8+
9+
import { IonRouterOutlet } from './router-outlet';
10+
11+
const BACK_BUTTON_INPUTS = ['color', 'defaultHref', 'disabled', 'icon', 'mode', 'routerAnimation', 'text', 'type'];
12+
13+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
14+
export declare interface IonBackButton extends Components.IonBackButton {}
15+
16+
@ProxyCmp({
17+
inputs: BACK_BUTTON_INPUTS,
18+
})
19+
@Directive({
20+
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
21+
inputs: BACK_BUTTON_INPUTS,
22+
})
23+
// eslint-disable-next-line @angular-eslint/directive-class-suffix
24+
export class IonBackButton {
25+
@Input()
26+
defaultHref: string | undefined;
27+
28+
@Input()
29+
routerAnimation: AnimationBuilder | undefined;
30+
31+
protected el: HTMLElement;
32+
33+
constructor(
34+
@Optional() private routerOutlet: IonRouterOutlet,
35+
private navCtrl: NavController,
36+
private config: Config,
37+
private r: ElementRef,
38+
protected z: NgZone,
39+
c: ChangeDetectorRef
40+
) {
41+
c.detach();
42+
this.el = this.r.nativeElement;
43+
}
44+
45+
/**
46+
* @internal
47+
*/
48+
@HostListener('click', ['$event'])
49+
onClick(ev: Event): void {
50+
const defaultHref = this.defaultHref || this.config.get('backButtonDefaultHref');
51+
52+
if (this.routerOutlet?.canGoBack()) {
53+
this.navCtrl.setDirection('back', undefined, undefined, this.routerAnimation);
54+
this.routerOutlet.pop();
55+
ev.preventDefault();
56+
} else if (defaultHref != null) {
57+
this.navCtrl.navigateBack(defaultHref, { animation: this.routerAnimation });
58+
ev.preventDefault();
59+
}
60+
}
61+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ElementRef, Injector, EnvironmentInjector, NgZone, ChangeDetectorRef, Directive } from '@angular/core';
2+
import type { Components } from '@ionic/core';
3+
4+
import { AngularDelegate } from '../../providers/angular-delegate';
5+
import { ProxyCmp, proxyOutputs } from '../../utils/proxy';
6+
7+
const NAV_INPUTS = ['animated', 'animation', 'root', 'rootParams', 'swipeGesture'];
8+
9+
const NAV_METHODS = [
10+
'push',
11+
'insert',
12+
'insertPages',
13+
'pop',
14+
'popTo',
15+
'popToRoot',
16+
'removeIndex',
17+
'setRoot',
18+
'setPages',
19+
'getActive',
20+
'getByIndex',
21+
'canGoBack',
22+
'getPrevious',
23+
];
24+
25+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
26+
export declare interface IonNav extends Components.IonNav {}
27+
28+
@ProxyCmp({
29+
inputs: NAV_INPUTS,
30+
methods: NAV_METHODS,
31+
})
32+
@Directive({
33+
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
34+
inputs: NAV_INPUTS,
35+
})
36+
// eslint-disable-next-line @angular-eslint/directive-class-suffix
37+
export class IonNav {
38+
protected el: HTMLElement;
39+
constructor(
40+
ref: ElementRef,
41+
environmentInjector: EnvironmentInjector,
42+
injector: Injector,
43+
angularDelegate: AngularDelegate,
44+
protected z: NgZone,
45+
c: ChangeDetectorRef
46+
) {
47+
c.detach();
48+
this.el = ref.nativeElement;
49+
ref.nativeElement.delegate = angularDelegate.create(environmentInjector, injector);
50+
proxyOutputs(this, this.el, ['ionNavDidChange', 'ionNavWillChange']);
51+
}
52+
}

0 commit comments

Comments
 (0)