Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: javascript
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [9.0.4](https://github.com/ionic-team/ionic-framework/compare/v9.0.3...v9.0.4) (2026-09-16)

### Bug Fixes

* **input, select, textarea:** emit one click event when slotted content is clicked ([#31423](https://github.com/ionic-team/ionic-framework/issues/31423)) ([d6acf12](https://github.com/ionic-team/ionic-framework/commit/d6acf12477d1f6633580a10b4655c5ddb382844f))
* **input, textarea:** keep the value visible when slotted content is wide ([#31435](https://github.com/ionic-team/ionic-framework/issues/31435)) ([1fb47c5](https://github.com/ionic-team/ionic-framework/commit/1fb47c55fbc590ef38b0b9600d1287be7a2177f1))
* **modal:** prevent ion-content collapsing at content-based heights ([#31413](https://github.com/ionic-team/ionic-framework/issues/31413)) ([8a713ab](https://github.com/ionic-team/ionic-framework/commit/8a713ab84e4a4fdfe2eba68142cfd65ec1fd13b4)), closes [#31149](https://github.com/ionic-team/ionic-framework/issues/31149)


## [9.0.3](https://github.com/ionic-team/ionic-framework/compare/v9.0.2...v9.0.3) (2026-09-09)

### Bug Fixes
Expand Down
9 changes: 9 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [9.0.4](https://github.com/ionic-team/ionic-framework/compare/v9.0.3...v9.0.4) (2026-09-16)

### Bug Fixes

* **input, select, textarea:** emit one click event when slotted content is clicked ([#31423](https://github.com/ionic-team/ionic-framework/issues/31423)) ([d6acf12](https://github.com/ionic-team/ionic-framework/commit/d6acf12477d1f6633580a10b4655c5ddb382844f))
* **input, textarea:** keep the value visible when slotted content is wide ([#31435](https://github.com/ionic-team/ionic-framework/issues/31435)) ([1fb47c5](https://github.com/ionic-team/ionic-framework/commit/1fb47c55fbc590ef38b0b9600d1287be7a2177f1))
* **modal:** prevent ion-content collapsing at content-based heights ([#31413](https://github.com/ionic-team/ionic-framework/issues/31413)) ([8a713ab](https://github.com/ionic-team/ionic-framework/commit/8a713ab84e4a4fdfe2eba68142cfd65ec1fd13b4)), closes [#31149](https://github.com/ionic-team/ionic-framework/issues/31149)


## [9.0.3](https://github.com/ionic-team/ionic-framework/compare/v9.0.2...v9.0.3) (2026-09-09)

**Note:** Version bump only for package @ionic/core
Expand Down
16 changes: 8 additions & 8 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
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "9.0.3",
"version": "9.0.4",
"description": "Base components for Ionic",
"engines": {
"node": ">= 16"
Expand Down
2 changes: 1 addition & 1 deletion core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ export namespace Components {
*/
"getScrollElement": () => Promise<HTMLElement>;
/**
* Recalculate content dimensions. Called by overlays (e.g., popover) when sibling elements like headers or footers have finished rendering and their heights are available, ensuring accurate offset-top calculations.
* Recalculates the content dimensions and whether it should size itself to its content. Called by overlays when something they own changes, such as a header finishing its render or `--height` being updated.
*/
"recalculateDimensions": () => Promise<void>;
/**
Expand Down
65 changes: 61 additions & 4 deletions core/src/components/content/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
Listen,
Method,
Prop,
State,
Watch,
forceUpdate,
h,
readTask,
} from '@stencil/core';
import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { getOverlaySizeType } from '@utils/overlays';
import { isPlatform } from '@utils/platform';
import { isRTL } from '@utils/rtl';
import { createColorClasses, hostContext } from '@utils/theme';
Expand Down Expand Up @@ -77,6 +79,11 @@ export class Content implements ComponentInterface {

@Element() el!: HTMLIonContentElement;

/**
* Whether the host is sized to its content.
*/
@State() sizeToContent = false;

/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
Expand Down Expand Up @@ -148,6 +155,7 @@ export class Content implements ComponentInterface {

componentWillLoad() {
this.inheritedAttributes = inheritAriaAttributes(this.el);
this.sizeToContent = this.readSizeToContent();
}

connectedCallback() {
Expand Down Expand Up @@ -190,6 +198,7 @@ export class Content implements ComponentInterface {

// Re-observe on reattach, since componentDidLoad only fires once.
this.setupFullscreenResizeObserver();
this.updateSizeToContent();
}

componentDidLoad() {
Expand Down Expand Up @@ -258,6 +267,17 @@ export class Content implements ComponentInterface {
this.fullscreenResizeObserver.observe(this.el);
}

/**
* Picks up an overlay that is no longer sized the way the last render
* assumed, re-rendering only when the answer changes. Read in a `readTask`
* because resolving the custom property forces a style recalculation.
*/
private updateSizeToContent() {
readTask(() => {
this.sizeToContent = this.readSizeToContent();
});
}

private destroyFullscreenResizeObserver() {
if (this.fullscreenResizeObserver !== undefined) {
this.fullscreenResizeObserver.disconnect();
Expand Down Expand Up @@ -310,6 +330,34 @@ export class Content implements ComponentInterface {
return forceOverscroll === undefined ? mode === 'ios' && isPlatform('ios') : forceOverscroll;
}

/**
* Reads whether to size the component to its content height. Forces a style
* recalculation, so it belongs in a read task or before the first render.
*
* This applies inside popovers and modals with a content-based `--height`,
* where the overlay does not provide the content with a definite height
* to fill.
*
* Only `--height` is consulted. Styling the wrapper directly, such as
* `ion-modal::part(content) { height: fit-content; }`, does not change
* `--height` and therefore cannot be observed. `--height` is the only
* supported way to opt into content-based sizing.
*/
private readSizeToContent() {
if (hostContext('ion-popover', this.el)) {
return true;
}

const modal = this.el.closest('ion-modal');
if (modal === null) {
return false;
}

const height = getComputedStyle(modal).getPropertyValue('--height');

return getOverlaySizeType(height) === 'content';
}

private resize() {
/**
* Only force update if the component is rendered in a browser context.
Expand All @@ -320,6 +368,13 @@ export class Content implements ComponentInterface {
* TODO: Remove if STENCIL-834 determines Stencil will account for this.
*/
if (Build.isBrowser) {
/**
* A window resize can cross a media query that changes the modal's
* `--height`. The content's own offsets are unchanged, so neither branch
* below re-renders and the class from the last render would go stale.
*/
this.updateSizeToContent();

if (this.fullscreen) {
readTask(() => this.readDimensions());
} else if (this.cTop !== 0 || this.cBottom !== 0) {
Expand All @@ -330,14 +385,16 @@ export class Content implements ComponentInterface {
}

/**
* Recalculate content dimensions. Called by overlays (e.g., popover) when
* sibling elements like headers or footers have finished rendering and their
* heights are available, ensuring accurate offset-top calculations.
* Recalculates the content dimensions and whether it should size itself to
* its content. Called by overlays when something they own changes, such as
* a header finishing its render or `--height` being updated.
*
* @internal
*/
@Method()
async recalculateDimensions(): Promise<void> {
readTask(() => this.readDimensions());
this.updateSizeToContent();
}

private readDimensions() {
Expand Down Expand Up @@ -538,7 +595,7 @@ export class Content implements ComponentInterface {
class={createColorClasses(this.color, {
[mode]: true,
'content-fullscreen': this.fullscreen,
'content-sizing': hostContext('ion-popover', this.el),
'content-sizing': this.sizeToContent,
overscroll: forceOverscroll,
[`content-${rtl}`]: true,
})}
Expand Down
7 changes: 7 additions & 0 deletions core/src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
flex: 1;

width: 100%;

/**
* The input keeps a minimum width so that the value stays visible
* when a long label or wide slotted content would otherwise shrink
* it to nothing.
*/
min-width: $form-control-min-width;
max-width: 100%;

// Ensure the input fills the full height of the native wrapper.
Expand Down
18 changes: 11 additions & 7 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import {
forceUpdate,
h,
} from '@stencil/core';
import type { NotchController, StartContainerController } from '@utils/forms';
import { createNotchController, createStartContainerController, checkInvalidState } from '@utils/forms';
import type { ClickController, NotchController, StartContainerController } from '@utils/forms';
import {
createClickController,
createNotchController,
createStartContainerController,
checkInvalidState,
} from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, debounceEvent, inheritAttributes, componentOnReady } from '@utils/helpers';
import { createSlotMutationController } from '@utils/slot-mutation-controller';
Expand Down Expand Up @@ -56,6 +61,7 @@ export class Input implements ComponentInterface {
private notchSpacerEl: HTMLElement | undefined;
private startContainerController?: StartContainerController;
private startContainerEl: HTMLElement | undefined;
private clickController?: ClickController;

private originalIonInput?: EventEmitter<InputInputEventDetail>;

Expand Down Expand Up @@ -392,11 +398,7 @@ export class Input implements ComponentInterface {
*/
@Listen('click', { capture: true })
onClickCapture(ev: Event) {
const nativeInput = this.nativeInput;
if (nativeInput && ev.target === nativeInput) {
ev.stopPropagation();
this.el.click();
}
this.clickController?.handleClickCapture(ev);
}

componentWillLoad() {
Expand Down Expand Up @@ -433,6 +435,8 @@ export class Input implements ComponentInterface {

this.startContainerController.calculateStartContainerWidth();

this.clickController = createClickController(el, () => this.nativeInput);

// Watch for class changes to update validation state
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
Expand Down
78 changes: 78 additions & 0 deletions core/src/components/input/test/basic/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,81 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
});
});

/**
* This behavior does not vary across directions/modes
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: slotted click'), () => {
test.beforeEach(async ({ page }) => {
await page.setContent(
`
<ion-input label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button slot="end" aria-label="Show password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
<ion-checkbox slot="end" aria-label="Remember"></ion-checkbox>
<ion-radio slot="end" aria-label="Preferred"></ion-radio>
<ion-toggle slot="end" aria-label="Notify"></ion-toggle>
</ion-input>
`,
config
);
});

test('should emit one click and focus the input when a slotted icon is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('ion-icon[slot="start"]').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-icon');

await expect(page.locator('ion-input input.native-input')).toBeFocused();
});

test('should emit one click without focusing the input when a slotted button is clicked', async ({ page }) => {
const clickEvent = await page.spyOnEvent('click');

await page.locator('ion-button[slot="end"]').click();

expect(clickEvent).toHaveReceivedEventTimes(1);

await expect(page.locator('ion-input input.native-input')).not.toBeFocused();
});

/**
* Browsers skip the label forwarding when a click lands on interactive
* content, so activating a slotted control leaves the input alone.
*/
['ion-checkbox', 'ion-radio', 'ion-toggle'].forEach((tag) => {
test(`should activate a slotted ${tag} without focusing the input`, async ({ page }) => {
const control = page.locator(tag);

await control.click();
await page.waitForChanges();

await expect(control).toHaveAttribute('aria-checked', 'true');
await expect(page.locator('ion-input input.native-input')).not.toBeFocused();
});
});

test('should emit one click when the input is clicked after slotted content', async ({ page }) => {
/**
* Clicking a slotted button does not produce a forwarded click for the
* input to ignore, so the following click on the input itself must
* still be emitted.
*/
await page.locator('ion-button[slot="end"]').click();

const clickEvent = await page.spyOnEvent('click');

await page.locator('ion-input input.native-input').click();

expect(clickEvent).toHaveReceivedEventTimes(1);
});
});
});
Loading
Loading