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
23 changes: 23 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,24 @@ 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 can now be set by attribute presence, so `<ion-item button detail>` is valid where it previously reported `Type 'string' is not assignable to type 'boolean'`.

Declaring the transform that allows this also makes Angular type check these inputs, which it did not do before. 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"></ion-item>
```

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

Coerce the expression to a boolean, with `!!value` or an explicit comparison such as `items.length > 0`. Bindings that already pass a boolean, a string, `null` or `undefined` are unaffected, and runtime behavior is unchanged.
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,30 @@ import type { Components } from '@ionic/core';

import { Config } from '../../providers/config';
import { NavController } from '../../providers/nav-controller';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { 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',
];

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

// 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
14 changes: 12 additions & 2 deletions packages/angular/src/common/directives/navigation/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ import {
import type { Components } from '@ionic/core';

import { AngularDelegate } from '../../providers/angular-delegate';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { 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 },
];

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

const NAV_METHODS = [
'push',
Expand Down Expand Up @@ -42,7 +52,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
24 changes: 14 additions & 10 deletions packages/angular/src/common/overlays/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@angular/core';
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';

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

export declare interface IonModal extends Components.IonModal {
Expand Down Expand Up @@ -63,30 +64,33 @@ 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',
];

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

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

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

import { nullableBooleanAttribute } from '../utils/boolean-attribute';
import { 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 },
'leaveAnimation',
'mode',
'showBackdrop',
'translucent',
{ name: 'showBackdrop', transform: nullableBooleanAttribute },
{ name: 'translucent', transform: nullableBooleanAttribute },
'trigger',
'triggerAction',
'reference',
'size',
'side',
];

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

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

@ProxyCmp({
inputs: POPOVER_INPUTS,
inputs: POPOVER_PROXY_INPUTS,
methods: POPOVER_METHODS,
})
/**
Expand Down
36 changes: 36 additions & 0 deletions packages/angular/src/common/utils/boolean-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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`.
*
* Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than
* coerced to `false`. Components frequently treat them as a state distinct from `false`, and both
* reach inputs routinely from the `async` pipe before its first emission and from form control
* values:
*
* ```tsx
* // `undefined` means "decide based on the mode", which is not the same as `false`
* const showDetail = detail !== undefined ? detail : mode === 'ios';
*
* // a strict comparison also behaves differently for `null` than it does for `false`
* const showHandle = handle !== false;
* ```
*
* 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';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* eslint-disable */
/* tslint:disable */

/**
* Transforms a value to a boolean so that boolean properties can be set by attribute presence,
* e.g. `<my-component disabled>` instead of `<my-component [disabled]="true">`.
*
* Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare
* attribute) becomes `true` and `'false'` becomes `false`.
*
* Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than
* coerced to `false`. Components frequently treat them as a state distinct from `false`:
*
* ```tsx
* // `undefined` means "decide based on the mode", which is not the same as `false`
* const showDetail = detail !== undefined ? detail : mode === 'ios';
*
* // a strict comparison also behaves differently for `null` than it does for `false`
* const showHandle = handle !== false;
* ```
*
* Both values reach inputs routinely in Angular templates, from the `async` pipe before its
* first emission and from form control values, so coercing them would change the behavior of
* bindings that work today.
*
* This is implemented here rather than imported from `@angular/core` so that consumers on
* Angular versions without `booleanAttribute` are unaffected by this file being generated.
*
* 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.
*
* This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports.
* That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`.
*/
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';
}
Loading
Loading