feat(angular): ship Ionic components as Angular standalone components (#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**

https://github.com/ionic-team/ionicons/issues/910

https://github.com/ionic-team/ionicons/issues/536

https://github.com/ionic-team/ionic-framework/issues/27280

https://github.com/ionic-team/ionic-framework/issues/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**

https://github.com/ionic-team/ionic-framework/issues/25404

https://github.com/ionic-team/ionic-framework/issues/27251

https://github.com/ionic-team/ionic-framework/issues/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**

https://github.com/ionic-team/ionic-framework/issues/24352

https://github.com/ionic-team/ionic-framework/issues/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 <thetaPC@users.noreply.github.com>
Co-authored-by: Sean Perkins <sean@ionic.io>
Co-authored-by: Amanda Johnston <90629384+amandaejohnston@users.noreply.github.com>
Co-authored-by: Maria Hutt <maria@ionic.io>
Co-authored-by: Sean Perkins <13732623+sean-perkins@users.noreply.github.com>
This commit is contained in:
Liam DeBeasi
2023-10-10 13:06:23 -04:00
committed by GitHub
parent 4f43d5ce08
commit 57e2476370
302 changed files with 6918 additions and 1624 deletions

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import type { ActionSheetOptions } from '@ionic/core/components';
import { actionSheetController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
@Injectable({
providedIn: 'root',
})
export class ActionSheetController extends OverlayBaseController<ActionSheetOptions, HTMLIonActionSheetElement> {
constructor() {
super(actionSheetController);
}
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import type { AlertOptions } from '@ionic/core/components';
import { alertController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
@Injectable({
providedIn: 'root',
})
export class AlertController extends OverlayBaseController<AlertOptions, HTMLIonAlertElement> {
constructor() {
super(alertController);
}
}

View File

@ -0,0 +1,221 @@
import {
ApplicationRef,
NgZone,
Injectable,
Injector,
EnvironmentInjector,
inject,
createComponent,
InjectionToken,
ComponentRef,
} from '@angular/core';
import {
FrameworkDelegate,
LIFECYCLE_DID_ENTER,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_ENTER,
LIFECYCLE_WILL_LEAVE,
LIFECYCLE_WILL_UNLOAD,
} from '@ionic/core/components';
import { NavParams } from '../directives/navigation/nav-params';
// TODO(FW-2827): types
@Injectable()
export class AngularDelegate {
private zone = inject(NgZone);
private applicationRef = inject(ApplicationRef);
create(
environmentInjector: EnvironmentInjector,
injector: Injector,
elementReferenceKey?: string
): AngularFrameworkDelegate {
return new AngularFrameworkDelegate(
environmentInjector,
injector,
this.applicationRef,
this.zone,
elementReferenceKey
);
}
}
export class AngularFrameworkDelegate implements FrameworkDelegate {
private elRefMap = new WeakMap<HTMLElement, ComponentRef<any>>();
private elEventsMap = new WeakMap<HTMLElement, () => void>();
constructor(
private environmentInjector: EnvironmentInjector,
private injector: Injector,
private applicationRef: ApplicationRef,
private zone: NgZone,
private elementReferenceKey?: string
) {}
attachViewToDom(container: any, component: any, params?: any, cssClasses?: string[]): Promise<any> {
return this.zone.run(() => {
return new Promise((resolve) => {
const componentProps = {
...params,
};
/**
* Ionic Angular passes a reference to a modal
* or popover that can be accessed using a
* variable in the overlay component. If
* elementReferenceKey is defined, then we should
* pass a reference to the component using
* elementReferenceKey as the key.
*/
if (this.elementReferenceKey !== undefined) {
componentProps[this.elementReferenceKey] = container;
}
const el = attachView(
this.zone,
this.environmentInjector,
this.injector,
this.applicationRef,
this.elRefMap,
this.elEventsMap,
container,
component,
componentProps,
cssClasses,
this.elementReferenceKey
);
resolve(el);
});
});
}
removeViewFromDom(_container: any, component: any): Promise<void> {
return this.zone.run(() => {
return new Promise((resolve) => {
const componentRef = this.elRefMap.get(component);
if (componentRef) {
componentRef.destroy();
this.elRefMap.delete(component);
const unbindEvents = this.elEventsMap.get(component);
if (unbindEvents) {
unbindEvents();
this.elEventsMap.delete(component);
}
}
resolve();
});
});
}
}
export const attachView = (
zone: NgZone,
environmentInjector: EnvironmentInjector,
injector: Injector,
applicationRef: ApplicationRef,
elRefMap: WeakMap<HTMLElement, ComponentRef<any>>,
elEventsMap: WeakMap<HTMLElement, () => void>,
container: any,
component: any,
params: any,
cssClasses: string[] | undefined,
elementReferenceKey: string | undefined
): any => {
/**
* Wraps the injector with a custom injector that
* provides NavParams to the component.
*
* NavParams is a legacy feature from Ionic v3 that allows
* Angular developers to provide data to a component
* and access it by providing NavParams as a dependency
* in the constructor.
*
* The modern approach is to access the data directly
* from the component's class instance.
*/
const childInjector = Injector.create({
providers: getProviders(params),
parent: injector,
});
const componentRef = createComponent<any>(component, {
environmentInjector,
elementInjector: childInjector,
});
const instance = componentRef.instance;
const hostElement = componentRef.location.nativeElement;
if (params) {
/**
* For modals and popovers, a reference to the component is
* added to `params` during the call to attachViewToDom. If
* a reference using this name is already set, this means
* the app is trying to use the name as a component prop,
* which will cause collisions.
*/
if (elementReferenceKey && instance[elementReferenceKey] !== undefined) {
console.error(
`[Ionic Error]: ${elementReferenceKey} is a reserved property when using ${container.tagName.toLowerCase()}. Rename or remove the "${elementReferenceKey}" property from ${
component.name
}.`
);
}
Object.assign(instance, params);
}
if (cssClasses) {
for (const cssClass of cssClasses) {
hostElement.classList.add(cssClass);
}
}
const unbindEvents = bindLifecycleEvents(zone, instance, hostElement);
container.appendChild(hostElement);
applicationRef.attachView(componentRef.hostView);
elRefMap.set(hostElement, componentRef);
elEventsMap.set(hostElement, unbindEvents);
return hostElement;
};
const LIFECYCLES = [
LIFECYCLE_WILL_ENTER,
LIFECYCLE_DID_ENTER,
LIFECYCLE_WILL_LEAVE,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_UNLOAD,
];
export const bindLifecycleEvents = (zone: NgZone, instance: any, element: HTMLElement): (() => void) => {
return zone.run(() => {
const unregisters = LIFECYCLES.filter((eventName) => typeof instance[eventName] === 'function').map((eventName) => {
const handler = (ev: any) => instance[eventName](ev.detail);
element.addEventListener(eventName, handler);
return () => element.removeEventListener(eventName, handler);
});
return () => unregisters.forEach((fn) => fn());
});
};
const NavParamsToken = new InjectionToken<any>('NavParamsToken');
const getProviders = (params: { [key: string]: any }) => {
return [
{
provide: NavParamsToken,
useValue: params,
},
{
provide: NavParams,
useFactory: provideNavParamsInjectable,
deps: [NavParamsToken],
},
];
};
const provideNavParamsInjectable = (params: { [key: string]: any }) => {
return new NavParams(params);
};

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import type { Animation } from '@ionic/core/components';
import { createAnimation, getTimeGivenProgression } from '@ionic/core/components';
@Injectable({
providedIn: 'root',
})
export class AnimationController {
/**
* Create a new animation
*/
create(animationId?: string): Animation {
return createAnimation(animationId);
}
/**
* EXPERIMENTAL
*
* Given a progression and a cubic bezier function,
* this utility returns the time value(s) at which the
* cubic bezier reaches the given time progression.
*
* If the cubic bezier never reaches the progression
* the result will be an empty array.
*
* This is most useful for switching between easing curves
* when doing a gesture animation (i.e. going from linear easing
* during a drag, to another easing when `progressEnd` is called)
*/
easingTime(p0: number[], p1: number[], p2: number[], p3: number[], progression: number): number[] {
return getTimeGivenProgression(p0, p1, p2, p3, progression);
}
}

View File

@ -0,0 +1,45 @@
import { Injectable, InjectionToken } from '@angular/core';
import type { Config as CoreConfig, IonicConfig } from '@ionic/core/components';
import { IonicWindow } from '../types/interfaces';
@Injectable({
providedIn: 'root',
})
export class Config {
get(key: keyof IonicConfig, fallback?: any): any {
const c = getConfig();
if (c) {
return c.get(key, fallback);
}
return null;
}
getBoolean(key: keyof IonicConfig, fallback?: boolean): boolean {
const c = getConfig();
if (c) {
return c.getBoolean(key, fallback);
}
return false;
}
getNumber(key: keyof IonicConfig, fallback?: number): number {
const c = getConfig();
if (c) {
return c.getNumber(key, fallback);
}
return 0;
}
}
export const ConfigToken = new InjectionToken<any>('USERCONFIG');
const getConfig = (): CoreConfig | null => {
if (typeof (window as any) !== 'undefined') {
const Ionic = (window as any as IonicWindow).Ionic;
if (Ionic?.config) {
return Ionic.config;
}
}
return null;
};

View File

@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class DomController {
/**
* Schedules a task to run during the READ phase of the next frame.
* This task should only read the DOM, but never modify it.
*/
read(cb: RafCallback): void {
getQueue().read(cb);
}
/**
* Schedules a task to run during the WRITE phase of the next frame.
* This task should write the DOM, but never READ it.
*/
write(cb: RafCallback): void {
getQueue().write(cb);
}
}
const getQueue = () => {
const win = typeof (window as any) !== 'undefined' ? window : (null as any);
if (win != null) {
const Ionic = win.Ionic;
if (Ionic?.queue) {
return Ionic.queue;
}
return {
read: (cb: any) => win.requestAnimationFrame(cb),
write: (cb: any) => win.requestAnimationFrame(cb),
};
}
return {
read: (cb: any) => cb(),
write: (cb: any) => cb(),
};
};
export type RafCallback = (timeStamp?: number) => void;

View File

@ -0,0 +1,25 @@
import { NgZone, Injectable } from '@angular/core';
import type { Gesture, GestureConfig } from '@ionic/core/components';
import { createGesture } from '@ionic/core/components';
@Injectable({
providedIn: 'root',
})
export class GestureController {
constructor(private zone: NgZone) {}
/**
* Create a new gesture
*/
create(opts: GestureConfig, runInsideAngularZone = false): Gesture {
if (runInsideAngularZone) {
Object.getOwnPropertyNames(opts).forEach((key) => {
if (typeof opts[key] === 'function') {
const fn = opts[key];
opts[key] = (...props: any[]) => this.zone.run(() => fn(...props));
}
});
}
return createGesture(opts);
}
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import type { LoadingOptions } from '@ionic/core/components';
import { loadingController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
@Injectable({
providedIn: 'root',
})
export class LoadingController extends OverlayBaseController<LoadingOptions, HTMLIonLoadingElement> {
constructor() {
super(loadingController);
}
}

View File

@ -0,0 +1,103 @@
import { Injectable } from '@angular/core';
import { menuController } from '@ionic/core/components';
@Injectable({
providedIn: 'root',
})
export class MenuController {
/**
* Programmatically open the Menu.
* @param [menuId] Optionally get the menu by its id, or side.
* @return returns a promise when the menu is fully opened
*/
open(menuId?: string): Promise<boolean> {
return menuController.open(menuId);
}
/**
* Programmatically close the Menu. If no `menuId` is given as the first
* argument then it'll close any menu which is open. If a `menuId`
* is given then it'll close that exact menu.
* @param [menuId] Optionally get the menu by its id, or side.
* @return returns a promise when the menu is fully closed
*/
close(menuId?: string): Promise<boolean> {
return menuController.close(menuId);
}
/**
* Toggle the menu. If it's closed, it will open, and if opened, it
* will close.
* @param [menuId] Optionally get the menu by its id, or side.
* @return returns a promise when the menu has been toggled
*/
toggle(menuId?: string): Promise<boolean> {
return menuController.toggle(menuId);
}
/**
* Used to enable or disable a menu. For example, there could be multiple
* left menus, but only one of them should be able to be opened at the same
* time. If there are multiple menus on the same side, then enabling one menu
* will also automatically disable all the others that are on the same side.
* @param [menuId] Optionally get the menu by its id, or side.
* @return Returns the instance of the menu, which is useful for chaining.
*/
enable(shouldEnable: boolean, menuId?: string): Promise<HTMLIonMenuElement | undefined> {
return menuController.enable(shouldEnable, menuId);
}
/**
* Used to enable or disable the ability to swipe open the menu.
* @param shouldEnable True if it should be swipe-able, false if not.
* @param [menuId] Optionally get the menu by its id, or side.
* @return Returns the instance of the menu, which is useful for chaining.
*/
swipeGesture(shouldEnable: boolean, menuId?: string): Promise<HTMLIonMenuElement | undefined> {
return menuController.swipeGesture(shouldEnable, menuId);
}
/**
* @param [menuId] Optionally get the menu by its id, or side.
* @return Returns true if the specified menu is currently open, otherwise false.
* If the menuId is not specified, it returns true if ANY menu is currenly open.
*/
isOpen(menuId?: string): Promise<boolean> {
return menuController.isOpen(menuId);
}
/**
* @param [menuId] Optionally get the menu by its id, or side.
* @return Returns true if the menu is currently enabled, otherwise false.
*/
isEnabled(menuId?: string): Promise<boolean> {
return menuController.isEnabled(menuId);
}
/**
* Used to get a menu instance. If a `menuId` is not provided then it'll
* return the first menu found. If a `menuId` is `left` or `right`, then
* it'll return the enabled menu on that side. Otherwise, if a `menuId` is
* provided, then it'll try to find the menu using the menu's `id`
* property. If a menu is not found then it'll return `null`.
* @param [menuId] Optionally get the menu by its id, or side.
* @return Returns the instance of the menu if found, otherwise `null`.
*/
get(menuId?: string): Promise<HTMLIonMenuElement | undefined> {
return menuController.get(menuId);
}
/**
* @return Returns the instance of the menu already opened, otherwise `null`.
*/
getOpen(): Promise<HTMLIonMenuElement | undefined> {
return menuController.getOpen();
}
/**
* @return Returns an array of all menu instances.
*/
getMenus(): Promise<HTMLIonMenuElement[]> {
return menuController.getMenus();
}
}

View File

@ -0,0 +1,25 @@
import { Injector, Injectable, EnvironmentInjector, inject } from '@angular/core';
import type { ModalOptions } from '@ionic/core/components';
import { modalController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
import { AngularDelegate } from './angular-delegate';
@Injectable()
export class ModalController extends OverlayBaseController<ModalOptions, HTMLIonModalElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
constructor() {
super(modalController);
}
create(opts: ModalOptions): Promise<HTMLIonModalElement> {
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'modal'),
});
}
}

View File

@ -0,0 +1,258 @@
import { Location } from '@angular/common';
import { Injectable, Optional } from '@angular/core';
import { NavigationExtras, Router, UrlSerializer, UrlTree, NavigationStart } from '@angular/router';
import type { AnimationBuilder, NavDirection, RouterDirection } from '@ionic/core/components';
import { IonRouterOutlet } from '../directives/navigation/router-outlet';
import { Platform } from './platform';
export interface AnimationOptions {
animated?: boolean;
animation?: AnimationBuilder;
animationDirection?: 'forward' | 'back';
}
export interface NavigationOptions extends NavigationExtras, AnimationOptions {}
@Injectable({
providedIn: 'root',
})
export class NavController {
private topOutlet?: IonRouterOutlet;
private direction: 'forward' | 'back' | 'root' | 'auto' = DEFAULT_DIRECTION;
private animated?: NavDirection = DEFAULT_ANIMATED;
private animationBuilder?: AnimationBuilder;
private guessDirection: RouterDirection = 'forward';
private guessAnimation?: NavDirection;
private lastNavId = -1;
constructor(
platform: Platform,
private location: Location,
private serializer: UrlSerializer,
@Optional() private router?: Router
) {
// Subscribe to router events to detect direction
if (router) {
router.events.subscribe((ev) => {
if (ev instanceof NavigationStart) {
const id = ev.restoredState ? ev.restoredState.navigationId : ev.id;
this.guessDirection = id < this.lastNavId ? 'back' : 'forward';
this.guessAnimation = !ev.restoredState ? this.guessDirection : undefined;
this.lastNavId = this.guessDirection === 'forward' ? ev.id : id;
}
});
}
// Subscribe to backButton events
platform.backButton.subscribeWithPriority(0, (processNextHandler) => {
this.pop();
processNextHandler();
});
}
/**
* This method uses Angular's [Router](https://angular.io/api/router/Router) under the hood,
* it's equivalent to calling `this.router.navigateByUrl()`, but it's explicit about the **direction** of the transition.
*
* Going **forward** means that a new page is going to be pushed to the stack of the outlet (ion-router-outlet),
* and that it will show a "forward" animation by default.
*
* Navigating forward can also be triggered in a declarative manner by using the `[routerDirection]` directive:
*
* ```html
* <a routerLink="/path/to/page" routerDirection="forward">Link</a>
* ```
*/
navigateForward(url: string | UrlTree | any[], options: NavigationOptions = {}): Promise<boolean> {
this.setDirection('forward', options.animated, options.animationDirection, options.animation);
return this.navigate(url, options);
}
/**
* This method uses Angular's [Router](https://angular.io/api/router/Router) under the hood,
* it's equivalent to calling:
*
* ```ts
* this.navController.setDirection('back');
* this.router.navigateByUrl(path);
* ```
*
* Going **back** means that all the pages in the stack until the navigated page is found will be popped,
* and that it will show a "back" animation by default.
*
* Navigating back can also be triggered in a declarative manner by using the `[routerDirection]` directive:
*
* ```html
* <a routerLink="/path/to/page" routerDirection="back">Link</a>
* ```
*/
navigateBack(url: string | UrlTree | any[], options: NavigationOptions = {}): Promise<boolean> {
this.setDirection('back', options.animated, options.animationDirection, options.animation);
return this.navigate(url, options);
}
/**
* This method uses Angular's [Router](https://angular.io/api/router/Router) under the hood,
* it's equivalent to calling:
*
* ```ts
* this.navController.setDirection('root');
* this.router.navigateByUrl(path);
* ```
*
* Going **root** means that all existing pages in the stack will be removed,
* and the navigated page will become the single page in the stack.
*
* Navigating root can also be triggered in a declarative manner by using the `[routerDirection]` directive:
*
* ```html
* <a routerLink="/path/to/page" routerDirection="root">Link</a>
* ```
*/
navigateRoot(url: string | UrlTree | any[], options: NavigationOptions = {}): Promise<boolean> {
this.setDirection('root', options.animated, options.animationDirection, options.animation);
return this.navigate(url, options);
}
/**
* Same as [Location](https://angular.io/api/common/Location)'s back() method.
* It will use the standard `window.history.back()` under the hood, but featuring a `back` animation
* by default.
*/
back(options: AnimationOptions = { animated: true, animationDirection: 'back' }): void {
this.setDirection('back', options.animated, options.animationDirection, options.animation);
return this.location.back();
}
/**
* This methods goes back in the context of Ionic's stack navigation.
*
* It recursively finds the top active `ion-router-outlet` and calls `pop()`.
* This is the recommended way to go back when you are using `ion-router-outlet`.
*
* Resolves to `true` if it was able to pop.
*/
async pop(): Promise<boolean> {
let outlet = this.topOutlet;
while (outlet) {
if (await outlet.pop()) {
return true;
} else {
outlet = outlet.parentOutlet;
}
}
return false;
}
/**
* This methods specifies the direction of the next navigation performed by the Angular router.
*
* `setDirection()` does not trigger any transition, it just sets some flags to be consumed by `ion-router-outlet`.
*
* It's recommended to use `navigateForward()`, `navigateBack()` and `navigateRoot()` instead of `setDirection()`.
*/
setDirection(
direction: RouterDirection,
animated?: boolean,
animationDirection?: 'forward' | 'back',
animationBuilder?: AnimationBuilder
): void {
this.direction = direction;
this.animated = getAnimation(direction, animated, animationDirection);
this.animationBuilder = animationBuilder;
}
/**
* @internal
*/
setTopOutlet(outlet: IonRouterOutlet): void {
this.topOutlet = outlet;
}
/**
* @internal
*/
consumeTransition(): {
direction: RouterDirection;
animation: NavDirection | undefined;
animationBuilder: AnimationBuilder | undefined;
} {
let direction: RouterDirection = 'root';
let animation: NavDirection | undefined;
const animationBuilder = this.animationBuilder;
if (this.direction === 'auto') {
direction = this.guessDirection;
animation = this.guessAnimation;
} else {
animation = this.animated;
direction = this.direction;
}
this.direction = DEFAULT_DIRECTION;
this.animated = DEFAULT_ANIMATED;
this.animationBuilder = undefined;
return {
direction,
animation,
animationBuilder,
};
}
private navigate(url: string | UrlTree | any[], options: NavigationOptions) {
if (Array.isArray(url)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.router!.navigate(url, options);
} else {
/**
* navigateByUrl ignores any properties that
* would change the url, so things like queryParams
* would be ignored unless we create a url tree
* More Info: https://github.com/angular/angular/issues/18798
*/
const urlTree = this.serializer.parse(url.toString());
if (options.queryParams !== undefined) {
urlTree.queryParams = { ...options.queryParams };
}
if (options.fragment !== undefined) {
urlTree.fragment = options.fragment;
}
/**
* `navigateByUrl` will still apply `NavigationExtras` properties
* that do not modify the url, such as `replaceUrl` which is why
* `options` is passed in here.
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.router!.navigateByUrl(urlTree, options);
}
}
}
const getAnimation = (
direction: RouterDirection,
animated: boolean | undefined,
animationDirection: 'forward' | 'back' | undefined
): NavDirection | undefined => {
if (animated === false) {
return undefined;
}
if (animationDirection !== undefined) {
return animationDirection;
}
if (direction === 'forward' || direction === 'back') {
return direction;
} else if (direction === 'root' && animated === true) {
return 'forward';
}
return undefined;
};
const DEFAULT_DIRECTION = 'auto';
const DEFAULT_ANIMATED = undefined;

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import type { PickerOptions } from '@ionic/core/components';
import { pickerController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
@Injectable({
providedIn: 'root',
})
export class PickerController extends OverlayBaseController<PickerOptions, HTMLIonPickerElement> {
constructor() {
super(pickerController);
}
}

View File

@ -0,0 +1,273 @@
import { DOCUMENT } from '@angular/common';
import { NgZone, Inject, Injectable } from '@angular/core';
import { getPlatforms, isPlatform } from '@ionic/core/components';
import type { BackButtonEventDetail, KeyboardEventDetail, Platforms } from '@ionic/core/components';
import { Subscription, Subject } from 'rxjs';
// TODO(FW-2827): types
export interface BackButtonEmitter extends Subject<BackButtonEventDetail> {
subscribeWithPriority(
priority: number,
callback: (processNextHandler: () => void) => Promise<any> | void
): Subscription;
}
@Injectable({
providedIn: 'root',
})
export class Platform {
private _readyPromise: Promise<string>;
private win: any;
/**
* @hidden
*/
backButton = new Subject<BackButtonEventDetail>() as BackButtonEmitter;
/**
* The keyboardDidShow event emits when the
* on-screen keyboard is presented.
*/
keyboardDidShow = new Subject<KeyboardEventDetail>();
/**
* The keyboardDidHide event emits when the
* on-screen keyboard is hidden.
*/
keyboardDidHide = new Subject<void>();
/**
* The pause event emits when the native platform puts the application
* into the background, typically when the user switches to a different
* application. This event would emit when a Cordova app is put into
* the background, however, it would not fire on a standard web browser.
*/
pause = new Subject<void>();
/**
* The resume event emits when the native platform pulls the application
* out from the background. This event would emit when a Cordova app comes
* out from the background, however, it would not fire on a standard web browser.
*/
resume = new Subject<void>();
/**
* The resize event emits when the browser window has changed dimensions. This
* could be from a browser window being physically resized, or from a device
* changing orientation.
*/
resize = new Subject<void>();
constructor(@Inject(DOCUMENT) private doc: any, zone: NgZone) {
zone.run(() => {
this.win = doc.defaultView;
this.backButton.subscribeWithPriority = function (priority, callback) {
return this.subscribe((ev) => {
return ev.register(priority, (processNextHandler) => zone.run(() => callback(processNextHandler)));
});
};
proxyEvent(this.pause, doc, 'pause');
proxyEvent(this.resume, doc, 'resume');
proxyEvent(this.backButton, doc, 'ionBackButton');
proxyEvent(this.resize, this.win, 'resize');
proxyEvent(this.keyboardDidShow, this.win, 'ionKeyboardDidShow');
proxyEvent(this.keyboardDidHide, this.win, 'ionKeyboardDidHide');
let readyResolve: (value: string) => void;
this._readyPromise = new Promise((res) => {
readyResolve = res;
});
if (this.win?.['cordova']) {
doc.addEventListener(
'deviceready',
() => {
readyResolve('cordova');
},
{ once: true }
);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
readyResolve!('dom');
}
});
}
/**
* @returns returns true/false based on platform.
* @description
* Depending on the platform the user is on, `is(platformName)` will
* return `true` or `false`. Note that the same app can return `true`
* for more than one platform name. For example, an app running from
* an iPad would return `true` for the platform names: `mobile`,
* `ios`, `ipad`, and `tablet`. Additionally, if the app was running
* from Cordova then `cordova` would be true, and if it was running
* from a web browser on the iPad then `mobileweb` would be `true`.
*
* ```
* import { Platform } from 'ionic-angular';
*
* @Component({...})
* export MyPage {
* constructor(public platform: Platform) {
* if (this.platform.is('ios')) {
* // This will only print when on iOS
* console.log('I am an iOS device!');
* }
* }
* }
* ```
*
* | Platform Name | Description |
* |-----------------|------------------------------------|
* | android | on a device running Android. |
* | capacitor | on a device running Capacitor. |
* | cordova | on a device running Cordova. |
* | ios | on a device running iOS. |
* | ipad | on an iPad device. |
* | iphone | on an iPhone device. |
* | phablet | on a phablet device. |
* | tablet | on a tablet device. |
* | electron | in Electron on a desktop device. |
* | pwa | as a PWA app. |
* | mobile | on a mobile device. |
* | mobileweb | on a mobile device in a browser. |
* | desktop | on a desktop device. |
* | hybrid | is a cordova or capacitor app. |
*
*/
is(platformName: Platforms): boolean {
return isPlatform(this.win, platformName);
}
/**
* @returns the array of platforms
* @description
* Depending on what device you are on, `platforms` can return multiple values.
* Each possible value is a hierarchy of platforms. For example, on an iPhone,
* it would return `mobile`, `ios`, and `iphone`.
*
* ```
* import { Platform } from 'ionic-angular';
*
* @Component({...})
* export MyPage {
* constructor(public platform: Platform) {
* // This will print an array of the current platforms
* console.log(this.platform.platforms());
* }
* }
* ```
*/
platforms(): string[] {
return getPlatforms(this.win);
}
/**
* Returns a promise when the platform is ready and native functionality
* can be called. If the app is running from within a web browser, then
* the promise will resolve when the DOM is ready. When the app is running
* from an application engine such as Cordova, then the promise will
* resolve when Cordova triggers the `deviceready` event.
*
* The resolved value is the `readySource`, which states which platform
* ready was used. For example, when Cordova is ready, the resolved ready
* source is `cordova`. The default ready source value will be `dom`. The
* `readySource` is useful if different logic should run depending on the
* platform the app is running from. For example, only Cordova can execute
* the status bar plugin, so the web should not run status bar plugin logic.
*
* ```
* import { Component } from '@angular/core';
* import { Platform } from 'ionic-angular';
*
* @Component({...})
* export MyApp {
* constructor(public platform: Platform) {
* this.platform.ready().then((readySource) => {
* console.log('Platform ready from', readySource);
* // Platform now ready, execute any required native code
* });
* }
* }
* ```
*/
ready(): Promise<string> {
return this._readyPromise;
}
/**
* Returns if this app is using right-to-left language direction or not.
* We recommend the app's `index.html` file already has the correct `dir`
* attribute value set, such as `<html dir="ltr">` or `<html dir="rtl">`.
* [W3C: Structural markup and right-to-left text in HTML](http://www.w3.org/International/questions/qa-html-dir)
*/
get isRTL(): boolean {
return this.doc.dir === 'rtl';
}
/**
* Get the query string parameter
*/
getQueryParam(key: string): string | null {
return readQueryParam(this.win.location.href, key);
}
/**
* Returns `true` if the app is in landscape mode.
*/
isLandscape(): boolean {
return !this.isPortrait();
}
/**
* Returns `true` if the app is in portrait mode.
*/
isPortrait(): boolean {
return this.win.matchMedia?.('(orientation: portrait)').matches;
}
testUserAgent(expression: string): boolean {
const nav = this.win.navigator;
return !!(nav?.userAgent && nav.userAgent.indexOf(expression) >= 0);
}
/**
* Get the current url.
*/
url(): string {
return this.win.location.href;
}
/**
* Gets the width of the platform's viewport using `window.innerWidth`.
*/
width(): number {
return this.win.innerWidth;
}
/**
* Gets the height of the platform's viewport using `window.innerHeight`.
*/
height(): number {
return this.win.innerHeight;
}
}
const readQueryParam = (url: string, key: string) => {
key = key.replace(/[[\]\\]/g, '\\$&');
const regex = new RegExp('[\\?&]' + key + '=([^&#]*)');
const results = regex.exec(url);
return results ? decodeURIComponent(results[1].replace(/\+/g, ' ')) : null;
};
const proxyEvent = <T>(emitter: Subject<T>, el: EventTarget, eventName: string) => {
if (el) {
el.addEventListener(eventName, (ev) => {
// ?? cordova might emit "null" events
const value = ev != null ? (ev as any).detail : undefined;
emitter.next(value);
});
}
};

View File

@ -0,0 +1,25 @@
import { Injector, Injectable, inject, EnvironmentInjector } from '@angular/core';
import type { PopoverOptions } from '@ionic/core/components';
import { popoverController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
import { AngularDelegate } from './angular-delegate';
@Injectable()
export class PopoverController extends OverlayBaseController<PopoverOptions, HTMLIonPopoverElement> {
private angularDelegate = inject(AngularDelegate);
private injector = inject(Injector);
private environmentInjector = inject(EnvironmentInjector);
constructor() {
super(popoverController);
}
create(opts: PopoverOptions): Promise<HTMLIonPopoverElement> {
return super.create({
...opts,
delegate: this.angularDelegate.create(this.environmentInjector, this.injector, 'popover'),
});
}
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import type { ToastOptions } from '@ionic/core/components';
import { toastController } from '@ionic/core/components';
import { OverlayBaseController } from '../utils/overlay';
@Injectable({
providedIn: 'root',
})
export class ToastController extends OverlayBaseController<ToastOptions, HTMLIonToastElement> {
constructor() {
super(toastController);
}
}