Skip to content

Commit

Permalink
feat(angular): standalone form controls can participate in forms (#28125
Browse files Browse the repository at this point in the history
)

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. -->

Ionic standalone form controls cannot participate in Angular forms.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Ionic form controls can participate in Angular forms by importing the
standalone component
- Applies to: `ion-input`, `ion-textarea`, `ion-searchbar`,
`ion-toggle`, `ion-checkbox`, `ion-segment`, `ion-radio`,
`ion-radio-group`, `ion-datetime` and `ion-range`.
- Refactors `ValueAccessor` from `@ionic/angular` to
`@ionic/angular/common`
- Refactors `raf` utility from `@ionic/angular` to
`@ionic/angular/common`

## 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. -->

---------
  • Loading branch information
sean-perkins committed Sep 19, 2023
1 parent dad7e66 commit 28f2ec9
Show file tree
Hide file tree
Showing 47 changed files with 1,626 additions and 902 deletions.
17 changes: 16 additions & 1 deletion core/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,22 @@ const getAngularOutputTargets = () => {
* are reliant on the CE build will reference the wrong
* import location.
*/
'ion-icon'
'ion-icon',
/**
* Value Accessors are manually implemented in the `@ionic/angular/standalone` package.
*/
'ion-input',
'ion-textarea',
'ion-searchbar',
'ion-datetime',
'ion-radio',
'ion-segment',
'ion-checkbox',
'ion-toggle',
'ion-range',
'ion-radio-group',
'ion-select'

],
outputType: 'standalone',
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './value-accessor';
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AfterViewInit, ElementRef, Injector, OnDestroy, Directive, HostListener
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { Subscription } from 'rxjs';

import { raf } from '../../util/util';
import { raf } from '../../utils/util';

// TODO(FW-2827): types

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

constructor(protected injector: Injector, protected el: ElementRef) {}
constructor(protected injector: Injector, protected elementRef: ElementRef) {}

writeValue(value: any): void {
this.el.nativeElement.value = this.lastValue = value;
setIonicClasses(this.el);
this.elementRef.nativeElement.value = this.lastValue = value;
setIonicClasses(this.elementRef);
}

/**
Expand All @@ -38,20 +38,20 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
* @param value The new value of the control.
*/
handleValueChange(el: HTMLElement, value: any): void {
if (el === this.el.nativeElement) {
if (el === this.elementRef.nativeElement) {
if (value !== this.lastValue) {
this.lastValue = value;
this.onChange(value);
}
setIonicClasses(this.el);
setIonicClasses(this.elementRef);
}
}

@HostListener('ionBlur', ['$event.target'])
_handleBlurEvent(el: any): void {
if (el === this.el.nativeElement) {
if (el === this.elementRef.nativeElement) {
this.onTouched();
setIonicClasses(this.el);
setIonicClasses(this.elementRef);
}
}

Expand All @@ -64,7 +64,7 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
}

setDisabledState(isDisabled: boolean): void {
this.el.nativeElement.disabled = isDisabled;
this.elementRef.nativeElement.disabled = isDisabled;
}

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

// Listen for changes in validity, disabled, or pending states
if (ngControl.statusChanges) {
this.statusChanges = ngControl.statusChanges.subscribe(() => setIonicClasses(this.el));
this.statusChanges = ngControl.statusChanges.subscribe(() => setIonicClasses(this.elementRef));
}

/**
Expand All @@ -102,7 +102,7 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
const oldFn = formControl[method].bind(formControl);
formControl[method] = (...params: any[]) => {
oldFn(...params);
setIonicClasses(this.el);
setIonicClasses(this.elementRef);
};
}
});
Expand All @@ -129,7 +129,7 @@ export const setIonicClasses = (element: ElementRef): void => {

const getClasses = (element: HTMLElement) => {
const classList = element.classList;
const classes = [];
const classes: string[] = [];
for (let i = 0; i < classList.length; i++) {
const item = classList.item(i);
if (item !== null && startsWith(item, 'ng-')) {
Expand Down
3 changes: 3 additions & 0 deletions packages/angular/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export {
} from './directives/navigation/router-link-delegate';
export { IonNav } from './directives/navigation/nav';
export { IonTabs } from './directives/navigation/tabs';
export * from './directives/control-value-accessors';

export { ProxyCmp } from './utils/proxy';

export { IonicRouteStrategy } from './utils/routing';

export { raf } from './utils/util';
File renamed without changes.
3 changes: 1 addition & 2 deletions packages/angular/src/app-initialize.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { NgZone } from '@angular/core';
import type { Config, IonicWindow } from '@ionic/angular/common';
import { raf } from '@ionic/angular/common';
import { setupConfig } from '@ionic/core';
import { applyPolyfills, defineCustomElements } from '@ionic/core/loader';

import { raf } from './util/util';

// TODO(FW-2827): types

export const appInitialize = (config: Config, doc: Document, zone: NgZone) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Directive, HostListener, ElementRef, Injector } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { ValueAccessor, setIonicClasses } from './value-accessor';
import { ValueAccessor, setIonicClasses } from '@ionic/angular/common';

@Directive({
selector: 'ion-checkbox,ion-toggle',
Expand All @@ -19,8 +18,8 @@ export class BooleanValueAccessorDirective extends ValueAccessor {
}

writeValue(value: boolean): void {
this.el.nativeElement.checked = this.lastValue = value;
setIonicClasses(this.el);
this.elementRef.nativeElement.checked = this.lastValue = value;
setIonicClasses(this.elementRef);
}

@HostListener('ionChange', ['$event.target'])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Directive, HostListener, ElementRef, Injector } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { ValueAccessor } from './value-accessor';
import { ValueAccessor } from '@ionic/angular/common';

@Directive({
selector: 'ion-input[type=number]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ElementRef, Injector, Directive, HostListener } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { ValueAccessor } from './value-accessor';
import { ValueAccessor } from '@ionic/angular/common';

@Directive({
/* tslint:disable-next-line:directive-selector */
Expand All @@ -19,9 +18,12 @@ export class RadioValueAccessorDirective extends ValueAccessor {
super(injector, el);
}

// TODO(FW-2827): type (HTMLIonRadioElement and HTMLElement are both missing `checked`)
@HostListener('ionSelect', ['$event.target'])
_handleIonSelect(el: any): void {
/**
* The `el` type is any to access the `checked` state property
* that is not exposed on the type interface.
*/
this.handleValueChange(el, el.checked);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ElementRef, Injector, Directive, HostListener } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { ValueAccessor } from './value-accessor';
import { ValueAccessor } from '@ionic/angular/common';

@Directive({
/* tslint:disable-next-line:directive-selector */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ElementRef, Injector, Directive, HostListener } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

import { ValueAccessor } from './value-accessor';
import { ValueAccessor } from '@ionic/angular/common';

@Directive({
selector: 'ion-input:not([type=number]),ion-textarea,ion-searchbar',
Expand Down
86 changes: 86 additions & 0 deletions packages/angular/standalone/src/directives/checkbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Injector,
NgZone,
} from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ValueAccessor, setIonicClasses } from '@ionic/angular/common';
import type { CheckboxChangeEventDetail, Components } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-checkbox.js';

import { ProxyCmp, proxyOutputs } from './angular-component-lib/utils';

const CHECKBOX_INPUTS = [
'checked',
'color',
'disabled',
'indeterminate',
'justify',
'labelPlacement',
'legacy',
'mode',
'name',
'value',
];

@ProxyCmp({
defineCustomElementFn: defineCustomElement,
inputs: CHECKBOX_INPUTS,
})
@Component({
selector: 'ion-checkbox',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: CHECKBOX_INPUTS,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: IonCheckbox,
multi: true,
},
],
standalone: true,
})
export class IonCheckbox extends ValueAccessor {
protected el: HTMLElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone, injector: Injector) {
super(injector, r);
c.detach();
this.el = r.nativeElement;
proxyOutputs(this, this.el, ['ionChange', 'ionFocus', 'ionBlur']);
}

writeValue(value: boolean): void {
this.elementRef.nativeElement.checked = this.lastValue = value;
setIonicClasses(this.elementRef);
}

@HostListener('ionChange', ['$event.target'])
handleIonChange(el: HTMLIonCheckboxElement | HTMLIonToggleElement): void {
this.handleValueChange(el, el.checked);
}
}

export declare interface IonCheckbox extends Components.IonCheckbox {
/**
* Emitted when the checked property has changed
as a result of a user action such as a click.
This event will not emit when programmatically
setting the checked property.
*/
ionChange: EventEmitter<CustomEvent<CheckboxChangeEventDetail>>;
/**
* Emitted when the checkbox has focus.
*/
ionFocus: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the checkbox loses focus.
*/
ionBlur: EventEmitter<CustomEvent<void>>;
}
103 changes: 103 additions & 0 deletions packages/angular/standalone/src/directives/datetime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Injector,
NgZone,
} from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ValueAccessor } from '@ionic/angular/common';
import type { DatetimeChangeEventDetail, Components } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-datetime.js';

import { ProxyCmp, proxyOutputs } from './angular-component-lib/utils';

const DATETIME_INPUTS = [
'cancelText',
'clearText',
'color',
'dayValues',
'disabled',
'doneText',
'firstDayOfWeek',
'highlightedDates',
'hourCycle',
'hourValues',
'isDateEnabled',
'locale',
'max',
'min',
'minuteValues',
'mode',
'monthValues',
'multiple',
'name',
'preferWheel',
'presentation',
'readonly',
'showClearButton',
'showDefaultButtons',
'showDefaultTimeLabel',
'showDefaultTitle',
'size',
'titleSelectedDatesFormatter',
'value',
'yearValues',
];

@ProxyCmp({
defineCustomElementFn: defineCustomElement,
inputs: DATETIME_INPUTS,
methods: ['confirm', 'reset', 'cancel'],
})
@Component({
selector: 'ion-datetime',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: DATETIME_INPUTS,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: IonDatetime,
multi: true,
},
],
standalone: true,
})
export class IonDatetime extends ValueAccessor {
protected el: HTMLElement;
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone, injector: Injector) {
super(injector, r);
c.detach();
this.el = r.nativeElement;
proxyOutputs(this, this.el, ['ionCancel', 'ionChange', 'ionFocus', 'ionBlur']);
}

@HostListener('ionChange', ['$event.target'])
handleIonChange(el: HTMLIonDatetimeElement): void {
this.handleValueChange(el, el.value);
}
}

export declare interface IonDatetime extends Components.IonDatetime {
/**
* Emitted when the datetime selection was cancelled.
*/
ionCancel: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the value (selected date) has changed.
*/
ionChange: EventEmitter<CustomEvent<DatetimeChangeEventDetail>>;
/**
* Emitted when the datetime has focus.
*/
ionFocus: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the datetime loses focus.
*/
ionBlur: EventEmitter<CustomEvent<void>>;
}
Loading

0 comments on commit 28f2ec9

Please sign in to comment.