Skip to content
Open
31 changes: 31 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Input Otp](#version-10x-input-otp)
- [Radio Group](#version-10x-radio-group)
- [Textarea](#version-10x-textarea)
- [Framework Specific](#version-10x-framework-specific)
- [Angular](#version-10x-angular)

<h2 id="version-10x-components">Components</h2>

Expand Down Expand Up @@ -221,3 +223,32 @@ The internal wrappers that Ionic 9 introduced are no longer reachable as descend
```

To style the wrappers themselves rather than the slotted content, use `part="start"` and `part="end"`.

<h2 id="version-10x-framework-specific">Framework Specific</h2>

<h4 id="version-10x-angular">Angular</h4>

**Boolean Inputs Are Type Checked**

Boolean inputs now declare an input transform, so attribute presence is an explicitly supported way to set them:

```html
<!-- Both set `button` to `true` -->
<ion-item button></ion-item>
<ion-item [button]="true"></ion-item>
```

Declaring the transform also makes Angular type check these inputs, which it did not do before. The generated proxies declare no class fields, so Angular had nothing to check a binding against and accepted any value. Bindings that pass a value outside `boolean | string | null | undefined` now fail to compile. The common case is a truthiness binding:

```diff
- <ion-item [button]="items.length"></ion-item>
+ <ion-item [button]="items.length > 0"></ion-item>
```

```
error TS2322: Type 'number' is not assignable to type 'string | boolean | null | undefined'.
```

Coerce the expression to a boolean, with an explicit comparison or `!!value`. Bindings that already pass a boolean, a string, `null` or `undefined` are unaffected.

Coercion also moves from Stencil to Angular, which changes the result for numbers. `0` and `NaN` previously became `false` and now become `true`, matching Angular's own `booleanAttribute`. An app without `strictTemplates` gets no compile error for the binding above, so an empty list now disables the item rather than enabling it. Coercing the expression fixes both the type error and the runtime change.
8 changes: 4 additions & 4 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"@playwright/test": "^1.62.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-virtual": "^2.0.3",
"@stencil/angular-output-target": "^1.4.1",
"@stencil/angular-output-target": "^1.5.0",
"@stencil/react-output-target": "^1.6.2",
"@stencil/sass": "^3.0.9",
"@stencil/vue-output-target": "0.14.2",
Expand Down
2 changes: 2 additions & 0 deletions core/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const getAngularOutputTargets = () => {
directivesArrayFile: '../packages/angular/src/lazy/directives/proxies-list.ts',
excludeComponents,
outputType: 'component',
booleanAttributes: true,
}),
angularOutputTarget({
componentCorePackage,
Expand Down Expand Up @@ -66,6 +67,7 @@ const getAngularOutputTargets = () => {
outputType: 'standalone',
// Emit each component in a separate file rather than putting them all in one large file.
esModules: true,
booleanAttributes: true,
})
];
}
Expand Down
15 changes: 15 additions & 0 deletions docs/component-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,21 @@ For standalone components, create a directive in the [standalone package](/packa
- For boolean inputs: See [ion-checkbox](/packages/angular/src/standalone/directives/checkbox.ts) or [ion-toggle](/packages/angular/src/standalone/directives/toggle.ts)
- For select-like inputs: See [ion-select](/packages/angular/src/standalone/directives/select.ts) or [ion-radio-group](/packages/angular/src/standalone/directives/radio-group.ts)

Boolean inputs take the `nullableBooleanAttribute` transform, so that they can be set by attribute presence the same way they can on the generated proxies:

```typescript
import { nullableBooleanAttribute } from './angular-component-lib/boolean-attribute';

const NEW_COMPONENT_INPUTS = [{ name: 'disabled', transform: nullableBooleanAttribute }, 'mode'];

/* ProxyCmp only needs the names, and runs at runtime rather than through the Angular compiler. */
const NEW_COMPONENT_PROXY_INPUTS = NEW_COMPONENT_INPUTS.map((input) =>
typeof input === 'string' ? input : input.name
);
```

Pass `NEW_COMPONENT_INPUTS` to `@Component({ inputs })` and `NEW_COMPONENT_PROXY_INPUTS` to `@ProxyCmp({ inputs })`. Unlike Angular's own `booleanAttribute`, the transform passes `null` and `undefined` through rather than coercing them to `false`, because components frequently treat them as a state distinct from `false`. Wrappers under [`common/`](/packages/angular/src/common) import it from `../utils/boolean-attribute`, since the output target only copies `angular-component-lib/` next to the files it generates.

After creating the directive, you need to export it in two places:

1. First, add your component to the directives export group in [`packages/angular/src/standalone/directives/index.ts`](/packages/angular/src/standalone/directives/index.ts):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@ import type { Components } from '@ionic/core';

import { Config } from '../../providers/config';
import { NavController } from '../../providers/nav-controller';
import { ProxyCmp } from '../../utils/proxy';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { inputNames, ProxyCmp } from '../../utils/proxy';

import { IonRouterOutlet } from './router-outlet';

const BACK_BUTTON_INPUTS = ['color', 'defaultHref', 'disabled', 'icon', 'mode', 'routerAnimation', 'text', 'type'];
const BACK_BUTTON_INPUTS = [
'color',
'defaultHref',
{ name: 'disabled', transform: nullableBooleanAttribute },
'icon',
'mode',
'routerAnimation',
'text',
'type',
];

const BACK_BUTTON_PROXY_INPUTS = inputNames(BACK_BUTTON_INPUTS);

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export declare interface IonBackButton extends Components.IonBackButton {}

@ProxyCmp({
inputs: BACK_BUTTON_INPUTS,
inputs: BACK_BUTTON_PROXY_INPUTS,
})
@Directive({
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
Expand Down
15 changes: 12 additions & 3 deletions packages/angular/src/common/directives/navigation/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ import {
import type { Components } from '@ionic/core';

import { AngularDelegate } from '../../providers/angular-delegate';
import { ProxyCmp, proxyOutputs } from '../../utils/proxy';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { inputNames, ProxyCmp, proxyOutputs } from '../../utils/proxy';

const NAV_INPUTS = ['animated', 'animation', 'root', 'rootParams', 'swipeGesture'];
const NAV_INPUTS = [
{ name: 'animated', transform: nullableBooleanAttribute },
'animation',
'root',
'rootParams',
{ name: 'swipeGesture', transform: nullableBooleanAttribute },
];

const NAV_PROXY_INPUTS = inputNames(NAV_INPUTS);

const NAV_METHODS = [
'push',
Expand Down Expand Up @@ -42,7 +51,7 @@ export declare interface IonNav extends Components.IonNav {
}

@ProxyCmp({
inputs: NAV_INPUTS,
inputs: NAV_PROXY_INPUTS,
methods: NAV_METHODS,
})
@Directive({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators';

import { Config } from '../../providers/config';
import { NavController } from '../../providers/nav-controller';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';

import { StackController } from './stack-controller';
import { RouteView, StackDidChangeEvent, StackWillChangeEvent, getUrl, isTabSwitch } from './stack-utils';
Expand All @@ -39,7 +40,12 @@ import { RouteView, StackDidChangeEvent, StackWillChangeEvent, getUrl, isTabSwit
selector: 'ion-router-outlet',
exportAs: 'outlet',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'animation', 'mode', 'swipeGesture'],
inputs: [
{ name: 'animated', transform: nullableBooleanAttribute },
'animation',
'mode',
{ name: 'swipeGesture', transform: nullableBooleanAttribute },
],
})
export abstract class IonRouterOutlet implements OnDestroy, OnInit {
abstract outletContent: any;
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export {
} from './directives/navigation/router-link-delegate';
export { IonTabs } from './directives/navigation/tabs';

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

export { OverlayBaseController } from './utils/overlay';
export { IonicRouteStrategy } from './utils/routing';
Expand Down
25 changes: 14 additions & 11 deletions packages/angular/src/common/overlays/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from '@angular/core';
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';

import { ProxyCmp, proxyOutputs } from '../utils/proxy';
import { nullableBooleanAttribute } from '../utils/boolean-attribute';
import { inputNames, ProxyCmp, proxyOutputs } from '../utils/proxy';

export declare interface IonModal extends Components.IonModal {
/**
Expand Down Expand Up @@ -63,30 +64,32 @@ export declare interface IonModal extends Components.IonModal {
}

const MODAL_INPUTS = [
'animated',
'keepContentsMounted',
{ name: 'animated', transform: nullableBooleanAttribute },
{ name: 'keepContentsMounted', transform: nullableBooleanAttribute },
'backdropBreakpoint',
'backdropDismiss',
{ name: 'backdropDismiss', transform: nullableBooleanAttribute },
'breakpoints',
'canDismiss',
'cssClass',
'enterAnimation',
'expandToScroll',
{ name: 'expandToScroll', transform: nullableBooleanAttribute },
'event',
'focusTrap',
'handle',
{ name: 'focusTrap', transform: nullableBooleanAttribute },
{ name: 'handle', transform: nullableBooleanAttribute },
'handleBehavior',
'initialBreakpoint',
'isOpen',
'keyboardClose',
{ name: 'isOpen', transform: nullableBooleanAttribute },
{ name: 'keyboardClose', transform: nullableBooleanAttribute },
'leaveAnimation',
'mode',
'presentingElement',
'showBackdrop',
{ name: 'showBackdrop', transform: nullableBooleanAttribute },
'translucent',
'trigger',
];

const MODAL_PROXY_INPUTS = inputNames(MODAL_INPUTS);

const MODAL_METHODS = [
'present',
'dismiss',
Expand All @@ -97,7 +100,7 @@ const MODAL_METHODS = [
];

@ProxyCmp({
inputs: MODAL_INPUTS,
inputs: MODAL_PROXY_INPUTS,
methods: MODAL_METHODS,
})
/**
Expand Down
28 changes: 16 additions & 12 deletions packages/angular/src/common/overlays/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from '@angular/core';
import type { Components } from '@ionic/core/components';

import { ProxyCmp, proxyOutputs } from '../utils/proxy';
import { nullableBooleanAttribute } from '../utils/boolean-attribute';
import { inputNames, ProxyCmp, proxyOutputs } from '../utils/proxy';

export declare interface IonPopover extends Components.IonPopover {
/**
Expand Down Expand Up @@ -48,32 +49,35 @@ export declare interface IonPopover extends Components.IonPopover {

const POPOVER_INPUTS = [
'alignment',
'animated',
'arrow',
'keepContentsMounted',
'backdropDismiss',
{ name: 'animated', transform: nullableBooleanAttribute },
{ name: 'arrow', transform: nullableBooleanAttribute },
{ name: 'keepContentsMounted', transform: nullableBooleanAttribute },
{ name: 'backdropDismiss', transform: nullableBooleanAttribute },
'cssClass',
'dismissOnSelect',
{ name: 'dismissOnSelect', transform: nullableBooleanAttribute },
'enterAnimation',
'event',
'focusTrap',
'isOpen',
'keyboardClose',
{ name: 'focusTrap', transform: nullableBooleanAttribute },
{ name: 'isOpen', transform: nullableBooleanAttribute },
{ name: 'keyboardClose', transform: nullableBooleanAttribute },
{ name: 'keyboardEvents', transform: nullableBooleanAttribute },
'leaveAnimation',
'mode',
'showBackdrop',
'translucent',
{ name: 'showBackdrop', transform: nullableBooleanAttribute },
{ name: 'translucent', transform: nullableBooleanAttribute },
'trigger',
'triggerAction',
'reference',
'size',
'side',
];

const POPOVER_PROXY_INPUTS = inputNames(POPOVER_INPUTS);

const POPOVER_METHODS = ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'];

@ProxyCmp({
inputs: POPOVER_INPUTS,
inputs: POPOVER_PROXY_INPUTS,
methods: POPOVER_METHODS,
})
/**
Expand Down
31 changes: 31 additions & 0 deletions packages/angular/src/common/utils/boolean-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Duplicates `angular-component-lib/boolean-attribute.ts`, which the output
* target copies next to each generated proxies file and so is not reachable
* from here. `proxy.ts` duplicates `ProxyCmp` for the same reason. Refer to
* the TODO at the top of `proxy.ts`.
*/

/**
* Transforms a value to a boolean so that boolean properties can be set by attribute presence,
* e.g. `<ion-modal handle>` instead of `<ion-modal [handle]="true">`.
*
* Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare
* attribute) becomes `true` and `'false'` becomes `false`.
*
* Angular's own `booleanAttribute` is not used because it coerces `null` and `undefined` to
* `false`. Components frequently treat those as a state distinct from `false`, such as
* `ion-item`'s `detail` resolving `undefined` to a computed default, and both reach inputs
* routinely from the `async` pipe before its first emission and from form control values.
*
* The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which
* template bindings compile. Widening it to `unknown` would let any expression through.
*
* Declared as a function rather than an arrow constant because Angular has to resolve input
* transforms statically when compiling a library in partial compilation mode.
*/
export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined {
if (value === null || value === undefined) {
return value;
}
return typeof value === 'boolean' ? value : value !== 'false';
}
7 changes: 7 additions & 0 deletions packages/angular/src/common/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export const proxyMethods = (Cmp: any, methods: string[]) => {
});
};

/**
* Extracts the plain names from an inputs array that may contain transform entries.
* `ProxyCmp` only needs the names, and runs at runtime rather than through the Angular compiler.
*/
export const inputNames = (inputs: (string | { name: string })[]): string[] =>
inputs.map((input) => (typeof input === 'string' ? input : input.name));

export const proxyOutputs = (instance: any, el: any, events: string[]) => {
events.forEach((eventName) => (instance[eventName] = fromEvent(el, eventName)));
};
Expand Down
Loading
Loading